/* Copyright 2015 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /* oidc implements the authenticator.Token interface using the OpenID Connect protocol. config := oidc.Options{ IssuerURL: "https://accounts.google.com", ClientID: os.Getenv("GOOGLE_CLIENT_ID"), UsernameClaim: "email", } tokenAuthenticator, err := oidc.New(config) */ package oidc import ( "context" "crypto/tls" "crypto/x509" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "sync/atomic" "time" oidc "github.com/coreos/go-oidc" "github.com/golang/glog" "k8s.io/apimachinery/pkg/util/net" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apiserver/pkg/authentication/user" certutil "k8s.io/client-go/util/cert" ) type Options struct { // IssuerURL is the URL the provider signs ID Tokens as. This will be the "iss" // field of all tokens produced by the provider and is used for configuration // discovery. // // The URL is usually the provider's URL without a path, for example // "https://accounts.google.com" or "https://login.salesforce.com". // // The provider must implement configuration discovery. // See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig IssuerURL string // ClientID the JWT must be issued for, the "sub" field. This plugin only trusts a single // client to ensure the plugin can be used with public providers. // // The plugin supports the "authorized party" OpenID Connect claim, which allows // specialized providers to issue tokens to a client for a different client. // See: https://openid.net/specs/openid-connect-core-1_0.html#IDToken ClientID string // Path to a PEM encoded root certificate of the provider. CAFile string // UsernameClaim is the JWT field to use as the user's username. UsernameClaim string // UsernamePrefix, if specified, causes claims mapping to username to be prefix with // the provided value. A value "oidc:" would result in usernames like "oidc:john". UsernamePrefix string // GroupsClaim, if specified, causes the OIDCAuthenticator to try to populate the user's // groups with an ID Token field. If the GrouppClaim field is present in an ID Token the value // must be a string or list of strings. GroupsClaim string // GroupsPrefix, if specified, causes claims mapping to group names to be prefixed with the // value. A value "oidc:" would result in groups like "oidc:engineering" and "oidc:marketing". GroupsPrefix string // SupportedSigningAlgs sets the accepted set of JOSE signing algorithms that // can be used by the provider to sign tokens. // // https://tools.ietf.org/html/rfc7518#section-3.1 // // This value defaults to RS256, the value recommended by the OpenID Connect // spec: // // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation SupportedSigningAlgs []string // RequiredClaims, if specified, causes the OIDCAuthenticator to verify that all the // required claims key value pairs are present in the ID Token. RequiredClaims map[string]string // now is used for testing. It defaults to time.Now. now func() time.Time } type Authenticator struct { issuerURL string usernameClaim string usernamePrefix string groupsClaim string groupsPrefix string requiredClaims map[string]string // Contains an *oidc.IDTokenVerifier. Do not access directly use the // idTokenVerifier method. verifier atomic.Value cancel context.CancelFunc } func (a *Authenticator) setVerifier(v *oidc.IDTokenVerifier) { a.verifier.Store(v) } func (a *Authenticator) idTokenVerifier() (*oidc.IDTokenVerifier, bool) { if v := a.verifier.Load(); v != nil { return v.(*oidc.IDTokenVerifier), true } return nil, false } func (a *Authenticator) Close() { a.cancel() } func New(opts Options) (*Authenticator, error) { return newAuthenticator(opts, func(ctx context.Context, a *Authenticator, config *oidc.Config) { // Asynchronously attempt to initialize the authenticator. This enables // self-hosted providers, providers that run on top of Kubernetes itself. go wait.PollUntil(time.Second*10, func() (done bool, err error) { provider, err := oidc.NewProvider(ctx, a.issuerURL) if err != nil { glog.Errorf("oidc authenticator: initializing plugin: %v", err) return false, nil } verifier := provider.Verifier(config) a.setVerifier(verifier) return true, nil }, ctx.Done()) }) } // whitelist of signing algorithms to ensure users don't mistakenly pass something // goofy. var allowedSigningAlgs = map[string]bool{ oidc.RS256: true, oidc.RS384: true, oidc.RS512: true, oidc.ES256: true, oidc.ES384: true, oidc.ES512: true, oidc.PS256: true, oidc.PS384: true, oidc.PS512: true, } func newAuthenticator(opts Options, initVerifier func(ctx context.Context, a *Authenticator, config *oidc.Config)) (*Authenticator, error) { url, err := url.Parse(opts.IssuerURL) if err != nil { return nil, err } if url.Scheme != "https" { return nil, fmt.Errorf("'oidc-issuer-url' (%q) has invalid scheme (%q), require 'https'", opts.IssuerURL, url.Scheme) } if opts.UsernameClaim == "" { return nil, errors.New("no username claim provided") } supportedSigningAlgs := opts.SupportedSigningAlgs if len(supportedSigningAlgs) == 0 { // RS256 is the default recommended by OpenID Connect and an 'alg' value // providers are required to implement. supportedSigningAlgs = []string{oidc.RS256} } for _, alg := range supportedSigningAlgs { if !allowedSigningAlgs[alg] { return nil, fmt.Errorf("oidc: unsupported signing alg: %q", alg) } } var roots *x509.CertPool if opts.CAFile != "" { roots, err = certutil.NewPool(opts.CAFile) if err != nil { return nil, fmt.Errorf("Failed to read the CA file: %v", err) } } else { glog.Info("OIDC: No x509 certificates provided, will use host's root CA set") } // Copied from http.DefaultTransport. tr := net.SetTransportDefaults(&http.Transport{ // According to golang's doc, if RootCAs is nil, // TLS uses the host's root CA set. TLSClientConfig: &tls.Config{RootCAs: roots}, }) client := &http.Client{Transport: tr, Timeout: 30 * time.Second} ctx, cancel := context.WithCancel(context.Background()) ctx = oidc.ClientContext(ctx, client) authenticator := &Authenticator{ issuerURL: opts.IssuerURL, usernameClaim: opts.UsernameClaim, usernamePrefix: opts.UsernamePrefix, groupsClaim: opts.GroupsClaim, groupsPrefix: opts.GroupsPrefix, requiredClaims: opts.RequiredClaims, cancel: cancel, } now := opts.now if now == nil { now = time.Now } verifierConfig := &oidc.Config{ ClientID: opts.ClientID, SupportedSigningAlgs: supportedSigningAlgs, Now: now, } initVerifier(ctx, authenticator, verifierConfig) return authenticator, nil } func hasCorrectIssuer(iss, tokenData string) bool { parts := strings.Split(tokenData, ".") if len(parts) != 3 { return false } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return false } claims := struct { // WARNING: this JWT is not verified. Do not trust these claims. Issuer string `json:"iss"` }{} if err := json.Unmarshal(payload, &claims); err != nil { return false } if claims.Issuer != iss { return false } return true } func (a *Authenticator) AuthenticateToken(token string) (user.Info, bool, error) { if !hasCorrectIssuer(a.issuerURL, token) { return nil, false, nil } ctx := context.Background() verifier, ok := a.idTokenVerifier() if !ok { return nil, false, fmt.Errorf("oidc: authenticator not initialized") } idToken, err := verifier.Verify(ctx, token) if err != nil { return nil, false, fmt.Errorf("oidc: verify token: %v", err) } var c claims if err := idToken.Claims(&c); err != nil { return nil, false, fmt.Errorf("oidc: parse claims: %v", err) } var username string if err := c.unmarshalClaim(a.usernameClaim, &username); err != nil { return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", a.usernameClaim, err) } if a.usernameClaim == "email" { // If the email_verified claim is present, ensure the email is valid. // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims if hasEmailVerified := c.hasClaim("email_verified"); hasEmailVerified { var emailVerified bool if err := c.unmarshalClaim("email_verified", &emailVerified); err != nil { return nil, false, fmt.Errorf("oidc: parse 'email_verified' claim: %v", err) } // If the email_verified claim is present we have to verify it is set to `true`. if !emailVerified { return nil, false, fmt.Errorf("oidc: email not verified") } } } if a.usernamePrefix != "" { username = a.usernamePrefix + username } info := &user.DefaultInfo{Name: username} if a.groupsClaim != "" { if _, ok := c[a.groupsClaim]; ok { // Some admins want to use string claims like "role" as the group value. // Allow the group claim to be a single string instead of an array. // // See: https://github.com/kubernetes/kubernetes/issues/33290 var groups stringOrArray if err := c.unmarshalClaim(a.groupsClaim, &groups); err != nil { return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", a.groupsClaim, err) } info.Groups = []string(groups) } } if a.groupsPrefix != "" { for i, group := range info.Groups { info.Groups[i] = a.groupsPrefix + group } } // check to ensure all required claims are present in the ID token and have matching values. for claim, value := range a.requiredClaims { if !c.hasClaim(claim) { return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim) } // NOTE: Only string values are supported as valid required claim values. var claimValue string if err := c.unmarshalClaim(claim, &claimValue); err != nil { return nil, false, fmt.Errorf("oidc: parse claim %s: %v", claim, err) } if claimValue != value { return nil, false, fmt.Errorf("oidc: required claim %s value does not match. Got = %s, want = %s", claim, claimValue, value) } } return info, true, nil } type stringOrArray []string func (s *stringOrArray) UnmarshalJSON(b []byte) error { var a []string if err := json.Unmarshal(b, &a); err == nil { *s = a return nil } var str string if err := json.Unmarshal(b, &str); err != nil { return err } *s = []string{str} return nil } type claims map[string]json.RawMessage func (c claims) unmarshalClaim(name string, v interface{}) error { val, ok := c[name] if !ok { return fmt.Errorf("claim not present") } return json.Unmarshal([]byte(val), v) } func (c claims) hasClaim(name string) bool { if _, ok := c[name]; !ok { return false } return true }