From 48c6d1abf5de6ac8167bbe3af07963ceb91a6716 Mon Sep 17 00:00:00 2001 From: Eric Chiang Date: Fri, 19 Jan 2018 11:14:05 -0800 Subject: [PATCH] oidc authentication: switch to v2 of coreos/go-oidc --- .../app/options/options_test.go | 1 + pkg/kubeapiserver/authenticator/config.go | 22 +- pkg/kubeapiserver/options/authentication.go | 6 + .../pkg/authenticator/token/oidc/oidc.go | 336 ++--- .../pkg/authenticator/token/oidc/oidc_test.go | 1156 ++++++++++------- .../authenticator/token/oidc/testdata/gen.sh | 27 + 6 files changed, 933 insertions(+), 615 deletions(-) create mode 100755 staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/testdata/gen.sh diff --git a/cmd/kube-apiserver/app/options/options_test.go b/cmd/kube-apiserver/app/options/options_test.go index d3cc1eafddf..f67c86b4373 100644 --- a/cmd/kube-apiserver/app/options/options_test.go +++ b/cmd/kube-apiserver/app/options/options_test.go @@ -210,6 +210,7 @@ func TestAddFlags(t *testing.T) { BootstrapToken: &kubeoptions.BootstrapTokenAuthenticationOptions{}, OIDC: &kubeoptions.OIDCAuthenticationOptions{ UsernameClaim: "sub", + SigningAlgs: []string{"RS256"}, }, PasswordFile: &kubeoptions.PasswordFileAuthenticationOptions{}, RequestHeader: &apiserveroptions.RequestHeaderAuthenticationOptions{}, diff --git a/pkg/kubeapiserver/authenticator/config.go b/pkg/kubeapiserver/authenticator/config.go index c182365bfac..f81d85eda40 100644 --- a/pkg/kubeapiserver/authenticator/config.go +++ b/pkg/kubeapiserver/authenticator/config.go @@ -58,6 +58,7 @@ type AuthenticatorConfig struct { OIDCUsernamePrefix string OIDCGroupsClaim string OIDCGroupsPrefix string + OIDCSigningAlgs []string ServiceAccountKeyFiles []string ServiceAccountLookup bool WebhookTokenAuthnConfigFile string @@ -143,7 +144,7 @@ func (config AuthenticatorConfig) New() (authenticator.Request, *spec.SecurityDe // simply returns an error, the OpenID Connect plugin may query the provider to // update the keys, causing performance hits. if len(config.OIDCIssuerURL) > 0 && len(config.OIDCClientID) > 0 { - oidcAuth, err := newAuthenticatorFromOIDCIssuerURL(config.OIDCIssuerURL, config.OIDCClientID, config.OIDCCAFile, config.OIDCUsernameClaim, config.OIDCUsernamePrefix, config.OIDCGroupsClaim, config.OIDCGroupsPrefix) + oidcAuth, err := newAuthenticatorFromOIDCIssuerURL(config.OIDCIssuerURL, config.OIDCClientID, config.OIDCCAFile, config.OIDCUsernameClaim, config.OIDCUsernamePrefix, config.OIDCGroupsClaim, config.OIDCGroupsPrefix, config.OIDCSigningAlgs) if err != nil { return nil, nil, err } @@ -235,7 +236,7 @@ func newAuthenticatorFromTokenFile(tokenAuthFile string) (authenticator.Token, e } // newAuthenticatorFromOIDCIssuerURL returns an authenticator.Token or an error. -func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClaim, usernamePrefix, groupsClaim, groupsPrefix string) (authenticator.Token, error) { +func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClaim, usernamePrefix, groupsClaim, groupsPrefix string, signingAlgs []string) (authenticator.Token, error) { const noUsernamePrefix = "-" if usernamePrefix == "" && usernameClaim != "email" { @@ -251,14 +252,15 @@ func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClai usernamePrefix = "" } - tokenAuthenticator, err := oidc.New(oidc.OIDCOptions{ - IssuerURL: issuerURL, - ClientID: clientID, - CAFile: caFile, - UsernameClaim: usernameClaim, - UsernamePrefix: usernamePrefix, - GroupsClaim: groupsClaim, - GroupsPrefix: groupsPrefix, + tokenAuthenticator, err := oidc.New(oidc.Options{ + IssuerURL: issuerURL, + ClientID: clientID, + CAFile: caFile, + UsernameClaim: usernameClaim, + UsernamePrefix: usernamePrefix, + GroupsClaim: groupsClaim, + GroupsPrefix: groupsPrefix, + SupportedSigningAlgs: signingAlgs, }) if err != nil { return nil, err diff --git a/pkg/kubeapiserver/options/authentication.go b/pkg/kubeapiserver/options/authentication.go index 2ed80a8d841..b1b27ba5acb 100644 --- a/pkg/kubeapiserver/options/authentication.go +++ b/pkg/kubeapiserver/options/authentication.go @@ -61,6 +61,7 @@ type OIDCAuthenticationOptions struct { UsernamePrefix string GroupsClaim string GroupsPrefix string + SigningAlgs []string } type PasswordFileAuthenticationOptions struct { @@ -208,6 +209,10 @@ func (s *BuiltInAuthenticationOptions) AddFlags(fs *pflag.FlagSet) { "If provided, all groups will be prefixed with this value to prevent conflicts with "+ "other authentication strategies.") + fs.StringSliceVar(&s.OIDC.SigningAlgs, "oidc-signing-algs", []string{"RS256"}, ""+ + "Comma-separated list of allowed JOSE asymmetric signing algorithms. JWTs with a "+ + "'alg' header value not in this list will be rejected. "+ + "Values are defined by RFC 7518 https://tools.ietf.org/html/rfc7518#section-3.1.") } if s.PasswordFile != nil { @@ -272,6 +277,7 @@ func (s *BuiltInAuthenticationOptions) ToAuthenticationConfig() authenticator.Au ret.OIDCIssuerURL = s.OIDC.IssuerURL ret.OIDCUsernameClaim = s.OIDC.UsernameClaim ret.OIDCUsernamePrefix = s.OIDC.UsernamePrefix + ret.OIDCSigningAlgs = s.OIDC.SigningAlgs } if s.PasswordFile != nil { diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go index a8c95ecb8b0..c33c540c005 100644 --- a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go @@ -17,7 +17,7 @@ limitations under the License. /* oidc implements the authenticator.Token interface using the OpenID Connect protocol. - config := oidc.OIDCOptions{ + config := oidc.Options{ IssuerURL: "https://accounts.google.com", ClientID: os.Getenv("GOOGLE_CLIENT_ID"), UsernameClaim: "email", @@ -27,25 +27,28 @@ oidc implements the authenticator.Token interface using the OpenID Connect proto package oidc import ( + "context" "crypto/tls" "crypto/x509" + "encoding/base64" + "encoding/json" "errors" "fmt" "net/http" "net/url" - "sync" + "strings" "sync/atomic" + "time" - "github.com/coreos/go-oidc/jose" - "github.com/coreos/go-oidc/oidc" + oidc "github.com/coreos/go-oidc" "github.com/golang/glog" "k8s.io/apimachinery/pkg/util/net" - "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apiserver/pkg/authentication/user" certutil "k8s.io/client-go/util/cert" ) -type OIDCOptions struct { +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. @@ -83,30 +86,85 @@ type OIDCOptions struct { // 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 + + // now is used for testing. It defaults to time.Now. + now func() time.Time } -type OIDCAuthenticator struct { +type Authenticator struct { issuerURL string - trustedClientID string - usernameClaim string usernamePrefix string groupsClaim string groupsPrefix string - httpClient *http.Client + // Contains an *oidc.IDTokenVerifier. Do not access directly use the + // idTokenVerifier method. + verifier atomic.Value - // Contains an *oidc.Client. Do not access directly. Use client() method. - oidcClient atomic.Value - - // Guards the close method and is used to lock during initialization and closing. - mu sync.Mutex - close func() // May be nil + cancel context.CancelFunc } -// New creates a token authenticator which validates OpenID Connect ID Tokens. -func New(opts OIDCOptions) (*OIDCAuthenticator, error) { +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 @@ -120,6 +178,18 @@ func New(opts OIDCOptions) (*OIDCAuthenticator, error) { 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) @@ -137,137 +207,91 @@ func New(opts OIDCOptions) (*OIDCAuthenticator, error) { TLSClientConfig: &tls.Config{RootCAs: roots}, }) - authenticator := &OIDCAuthenticator{ - issuerURL: opts.IssuerURL, - trustedClientID: opts.ClientID, - usernameClaim: opts.UsernameClaim, - usernamePrefix: opts.UsernamePrefix, - groupsClaim: opts.GroupsClaim, - groupsPrefix: opts.GroupsPrefix, - httpClient: &http.Client{Transport: tr}, + 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, + cancel: cancel, } - // Attempt to initialize the authenticator asynchronously. - // - // Ignore errors instead of returning it since the OpenID Connect provider might not be - // available yet, for instance if it's running on the cluster and needs the API server - // to come up first. Errors will be logged within the client() method. - go func() { - defer runtime.HandleCrash() - authenticator.client() - }() + 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 } -// Close stops all goroutines used by the authenticator. -func (a *OIDCAuthenticator) Close() { - a.mu.Lock() - defer a.mu.Unlock() - - if a.close != nil { - a.close() +func hasCorrectIssuer(iss, tokenData string) bool { + parts := strings.Split(tokenData, ".") + if len(parts) != 3 { + return false } - return + 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 *OIDCAuthenticator) client() (*oidc.Client, error) { - // Fast check to see if client has already been initialized. - if client := a.oidcClient.Load(); client != nil { - return client.(*oidc.Client), nil +func (a *Authenticator) AuthenticateToken(token string) (user.Info, bool, error) { + if !hasCorrectIssuer(a.issuerURL, token) { + return nil, false, nil } - // Acquire lock, then recheck initialization. - a.mu.Lock() - defer a.mu.Unlock() - if client := a.oidcClient.Load(); client != nil { - return client.(*oidc.Client), nil - } - - // Try to initialize client. - providerConfig, err := oidc.FetchProviderConfig(a.httpClient, a.issuerURL) - if err != nil { - glog.Errorf("oidc authenticator: failed to fetch provider discovery data: %v", err) - return nil, fmt.Errorf("fetch provider config: %v", err) - } - - clientConfig := oidc.ClientConfig{ - HTTPClient: a.httpClient, - Credentials: oidc.ClientCredentials{ID: a.trustedClientID}, - ProviderConfig: providerConfig, - } - - client, err := oidc.NewClient(clientConfig) - if err != nil { - glog.Errorf("oidc authenticator: failed to create client: %v", err) - return nil, fmt.Errorf("create client: %v", err) - } - - // SyncProviderConfig will start a goroutine to periodically synchronize the provider config. - // The synchronization interval is set by the expiration length of the config, and has a minimum - // and maximum threshold. - stop := client.SyncProviderConfig(a.issuerURL) - a.oidcClient.Store(client) - a.close = func() { - // This assumes the stop is an unbuffered channel. - // So instead of closing the channel, we send am empty struct here. - // This guarantees that when this function returns, there is no flying requests, - // because a send to an unbuffered channel happens after the receive from the channel. - stop <- struct{}{} - } - return client, nil -} - -// AuthenticateToken decodes and verifies an ID Token using the OIDC client, if the verification succeeds, -// then it will extract the user info from the JWT claims. -func (a *OIDCAuthenticator) AuthenticateToken(value string) (user.Info, bool, error) { - jwt, err := jose.ParseJWT(value) - if err != nil { - return nil, false, err - } - - client, err := a.client() - if err != nil { - return nil, false, err - } - if err := client.VerifyJWT(jwt); err != nil { - return nil, false, err - } - claims, err := jwt.Claims() - if err != nil { - return nil, false, err - } - return a.parseTokenClaims(claims) -} - -// parseTokenClaims maps a set of claims to a user. It performs basic validation such as -// ensuring the email is verified. -func (a *OIDCAuthenticator) parseTokenClaims(claims jose.Claims) (user.Info, bool, error) { - username, ok, err := claims.StringClaim(a.usernameClaim) - if err != nil { - return nil, false, err - } + ctx := context.Background() + verifier, ok := a.idTokenVerifier() if !ok { - return nil, false, fmt.Errorf("cannot find %q in JWT claims", a.usernameClaim) + 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" { - verified, ok := claims["email_verified"] - if !ok { - return nil, false, errors.New("'email_verified' claim not present") + // Check the email_verified claim to ensure the email is valid. + // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + var emailVerified bool + if err := c.unmarshalClaim("email_verified", &emailVerified); err != nil { + return nil, false, fmt.Errorf("oidc: parse 'email_verified' claim: %v", err) } - - emailVerified, ok := verified.(bool) - if !ok { - // OpenID Connect spec defines 'email_verified' as a boolean. For now, be a pain and error if - // it's a different type. If there are enough misbehaving providers we can relax this latter. - // - // See: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - return nil, false, fmt.Errorf("malformed claim 'email_verified', expected boolean got %T", verified) - } - if !emailVerified { - return nil, false, errors.New("email not verified") + return nil, false, fmt.Errorf("oidc: email not verified") } } @@ -275,21 +299,18 @@ func (a *OIDCAuthenticator) parseTokenClaims(claims jose.Claims) (user.Info, boo username = a.usernamePrefix + username } - // TODO(yifan): Add UID, also populate the issuer to upper layer. info := &user.DefaultInfo{Name: username} - if a.groupsClaim != "" { - groups, found, err := claims.StringsClaim(a.groupsClaim) - if err != nil { - // Groups type is present but is not an array of strings, try to decode as a string. - group, _, err := claims.StringClaim(a.groupsClaim) - if err != nil { - // Custom claim is present, but isn't an array of strings or a string. - return nil, false, fmt.Errorf("custom group claim contains invalid type: %T", claims[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{group} - } else if found { - info.Groups = groups + info.Groups = []string(groups) } } @@ -298,6 +319,31 @@ func (a *OIDCAuthenticator) parseTokenClaims(claims jose.Claims) (user.Info, boo info.Groups[i] = a.groupsPrefix + group } } - 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) +} diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go index f94d91b1a3f..412c32a6fe1 100644 --- a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go @@ -17,518 +17,754 @@ limitations under the License. package oidc import ( - "os" - "path" + "context" + "crypto" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "io/ioutil" "reflect" - "sort" "strings" "testing" "time" - "github.com/coreos/go-oidc/jose" - "github.com/coreos/go-oidc/oidc" - + oidc "github.com/coreos/go-oidc" + jose "gopkg.in/square/go-jose.v2" "k8s.io/apiserver/pkg/authentication/user" - oidctesting "k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/testing" ) -func generateToken(t *testing.T, op *oidctesting.OIDCProvider, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups interface{}, iat, exp time.Time, emailVerified bool) string { - claims := oidc.NewClaims(iss, sub, aud, iat, exp) - claims.Add(usernameClaim, value) - if groups != nil && groupsClaim != "" { - claims.Add(groupsClaim, groups) - } - claims.Add("email_verified", emailVerified) +// utilities for loading JOSE keys. - signer := op.PrivKey.Signer() - jwt, err := jose.NewSignedJWT(claims, signer) +func loadRSAKey(t *testing.T, filepath string, alg jose.SignatureAlgorithm) *jose.JSONWebKey { + return loadKey(t, filepath, alg, func(b []byte) (interface{}, error) { + key, err := x509.ParsePKCS1PrivateKey(b) + if err != nil { + return nil, err + } + return key.Public(), nil + }) +} + +func loadRSAPrivKey(t *testing.T, filepath string, alg jose.SignatureAlgorithm) *jose.JSONWebKey { + return loadKey(t, filepath, alg, func(b []byte) (interface{}, error) { + return x509.ParsePKCS1PrivateKey(b) + }) +} + +func loadECDSAKey(t *testing.T, filepath string, alg jose.SignatureAlgorithm) *jose.JSONWebKey { + return loadKey(t, filepath, alg, func(b []byte) (interface{}, error) { + key, err := x509.ParseECPrivateKey(b) + if err != nil { + return nil, err + } + return key.Public(), nil + }) +} + +func loadECDSAPrivKey(t *testing.T, filepath string, alg jose.SignatureAlgorithm) *jose.JSONWebKey { + return loadKey(t, filepath, alg, func(b []byte) (interface{}, error) { + return x509.ParseECPrivateKey(b) + }) +} + +func loadKey(t *testing.T, filepath string, alg jose.SignatureAlgorithm, unmarshal func([]byte) (interface{}, error)) *jose.JSONWebKey { + data, err := ioutil.ReadFile(filepath) if err != nil { - t.Fatalf("Cannot generate token: %v", err) - return "" + t.Fatalf("load file: %v", err) } - return jwt.Encode() + block, _ := pem.Decode(data) + if block == nil { + t.Fatalf("file contained no PEM encoded data: %s", filepath) + } + priv, err := unmarshal(block.Bytes) + if err != nil { + t.Fatalf("unmarshal key: %v", err) + } + key := &jose.JSONWebKey{Key: priv, Use: "sig", Algorithm: string(alg)} + thumbprint, err := key.Thumbprint(crypto.SHA256) + if err != nil { + t.Fatalf("computing thumbprint: %v", err) + } + key.KeyID = hex.EncodeToString(thumbprint) + return key } -func generateTokenWithUnverifiedEmail(t *testing.T, op *oidctesting.OIDCProvider, iss, sub, aud string, email string) string { - return generateToken(t, op, iss, sub, aud, "email", email, "", nil, time.Now(), time.Now().Add(time.Hour), false) +// staticKeySet implements oidc.KeySet. +type staticKeySet struct { + keys []*jose.JSONWebKey } -func generateGoodToken(t *testing.T, op *oidctesting.OIDCProvider, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups interface{}) string { - return generateToken(t, op, iss, sub, aud, usernameClaim, value, groupsClaim, groups, time.Now(), time.Now().Add(time.Hour), true) +func (s *staticKeySet) VerifySignature(ctx context.Context, jwt string) (payload []byte, err error) { + jws, err := jose.ParseSigned(jwt) + if err != nil { + return nil, err + } + if len(jws.Signatures) == 0 { + return nil, fmt.Errorf("jwt contained no signatures") + } + kid := jws.Signatures[0].Header.KeyID + + for _, key := range s.keys { + if key.KeyID == kid { + return jws.Verify(key) + } + } + + return nil, fmt.Errorf("no keys matches jwk keyid") } -func generateMalformedToken(t *testing.T, op *oidctesting.OIDCProvider, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups interface{}) string { - return generateToken(t, op, iss, sub, aud, usernameClaim, value, groupsClaim, groups, time.Now(), time.Now().Add(time.Hour), true) + "randombits" +var ( + expired, _ = time.Parse(time.RFC3339Nano, "2009-11-10T22:00:00Z") + now, _ = time.Parse(time.RFC3339Nano, "2009-11-10T23:00:00Z") + valid, _ = time.Parse(time.RFC3339Nano, "2009-11-11T00:00:00Z") +) + +type claimsTest struct { + name string + options Options + now time.Time + signingKey *jose.JSONWebKey + pubKeys []*jose.JSONWebKey + claims string + want *user.DefaultInfo + wantSkip bool + wantErr bool + wantInitErr bool } -func generateExpiredToken(t *testing.T, op *oidctesting.OIDCProvider, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups interface{}) string { - return generateToken(t, op, iss, sub, aud, usernameClaim, value, groupsClaim, groups, time.Now().Add(-2*time.Hour), time.Now().Add(-1*time.Hour), true) +func (c *claimsTest) run(t *testing.T) { + a, err := newAuthenticator(c.options, func(ctx context.Context, a *Authenticator, config *oidc.Config) { + // Set the verifier to use the public key set instead of reading + // from a remote. + a.setVerifier(oidc.NewVerifier( + c.options.IssuerURL, + &staticKeySet{keys: c.pubKeys}, + config, + )) + }) + if err != nil { + if !c.wantInitErr { + t.Fatalf("initialize authenticator: %v", err) + } + return + } + if c.wantInitErr { + t.Fatalf("wanted initialization error") + } + + // Sign and serialize the claims in a JWT. + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(c.signingKey.Algorithm), + Key: c.signingKey, + }, nil) + if err != nil { + t.Fatalf("initialize signer: %v", err) + } + jws, err := signer.Sign([]byte(c.claims)) + if err != nil { + t.Fatalf("sign claims: %v", err) + } + token, err := jws.CompactSerialize() + if err != nil { + t.Fatalf("serialize token: %v", err) + } + + got, ok, err := a.AuthenticateToken(token) + if err != nil { + if !c.wantErr { + t.Fatalf("authenticate token: %v", err) + } + return + } + + if c.wantErr { + t.Fatalf("expected error authenticating token") + } + if !ok { + if !c.wantSkip { + // We don't have any cases where we return (nil, false, nil) + t.Fatalf("no error but token not authenticated") + } + return + } + if c.wantSkip { + t.Fatalf("expected authenticator to skip token") + } + + gotUser := got.(*user.DefaultInfo) + if !reflect.DeepEqual(gotUser, c.want) { + t.Fatalf("wanted user=%#v, got=%#v", c.want, gotUser) + } } -func TestTLSConfig(t *testing.T) { - // Verify the cert/key pair works. - cert1 := path.Join(os.TempDir(), "oidc-cert-1") - key1 := path.Join(os.TempDir(), "oidc-key-1") - cert2 := path.Join(os.TempDir(), "oidc-cert-2") - key2 := path.Join(os.TempDir(), "oidc-key-2") +func TestToken(t *testing.T) { + tests := []claimsTest{ + { + name: "token", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "no-username", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "exp": %d + }`, valid.Unix()), + wantErr: true, + }, + { + name: "email", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "email", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "email": "jane@example.com", + "email_verified": true, + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane@example.com", + }, + }, + { + name: "email-not-verified", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "email", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "email": "jane@example.com", + "email_verified": false, + "exp": %d + }`, valid.Unix()), + wantErr: true, + }, + { + // If "email_verified" isn't present, assume false + name: "no-email-verified-claim", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "email", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "email": "jane@example.com", + "exp": %d + }`, valid.Unix()), + wantErr: true, + }, + { + name: "groups", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + GroupsClaim: "groups", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": ["team1", "team2"], + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + Groups: []string{"team1", "team2"}, + }, + }, + { + // Groups should be able to be a single string, not just a slice. + name: "group-string-claim", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + GroupsClaim: "groups", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": "team1", + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + Groups: []string{"team1"}, + }, + }, + { + // if the groups claim isn't provided, this shouldn't error out + name: "no-groups-claim", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + GroupsClaim: "groups", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "invalid-groups-claim", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + GroupsClaim: "groups", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": 42, + "exp": %d + }`, valid.Unix()), + wantErr: true, + }, + { + name: "invalid-signature", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_2.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + wantErr: true, + }, + { + name: "expired", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, expired.Unix()), + wantErr: true, + }, + { + name: "invalid-aud", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "not-my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + wantErr: true, + }, + { + // ID tokens may contain multiple audiences: + // https://openid.net/specs/openid-connect-core-1_0.html#IDToken + name: "multiple-audiences", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": ["not-my-client", "my-client"], + "azp": "not-my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "invalid-issuer", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + wantSkip: true, + }, + { + name: "username-prefix", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + UsernamePrefix: "oidc:", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "oidc:jane", + }, + }, + { + name: "groups-prefix", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + UsernamePrefix: "oidc:", + GroupsClaim: "groups", + GroupsPrefix: "groups:", + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "groups": ["team1", "team2"], + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "oidc:jane", + Groups: []string{"groups:team1", "groups:team2"}, + }, + }, + { + name: "invalid-signing-alg", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + now: func() time.Time { return now }, + }, + // Correct key but invalid signature algorithm "PS256" + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.PS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + wantErr: true, + }, + { + name: "ps256", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + SupportedSigningAlgs: []string{"PS256"}, + now: func() time.Time { return now }, + }, + signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.PS256), + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.PS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "es512", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + SupportedSigningAlgs: []string{"ES512"}, + now: func() time.Time { return now }, + }, + signingKey: loadECDSAPrivKey(t, "testdata/ecdsa_2.pem", jose.ES512), + pubKeys: []*jose.JSONWebKey{ + loadECDSAKey(t, "testdata/ecdsa_1.pem", jose.ES512), + loadECDSAKey(t, "testdata/ecdsa_2.pem", jose.ES512), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "not-https", + options: Options{ + IssuerURL: "http://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + now: func() time.Time { return now }, + }, + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + wantInitErr: true, + }, + { + name: "no-username-claim", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + now: func() time.Time { return now }, + }, + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + wantInitErr: true, + }, + { + name: "invalid-sig-alg", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + UsernameClaim: "username", + SupportedSigningAlgs: []string{"HS256"}, + now: func() time.Time { return now }, + }, + pubKeys: []*jose.JSONWebKey{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + wantInitErr: true, + }, + } + for _, test := range tests { + t.Run(test.name, test.run) + } +} - defer os.Remove(cert1) - defer os.Remove(key1) - defer os.Remove(cert2) - defer os.Remove(key2) +func TestUnmarshalClaimError(t *testing.T) { + // Ensure error strings returned by unmarshaling claims don't include the claim. + const token = "96bb299a-02e9-11e8-8673-54ee7553240e" + payload := fmt.Sprintf(`{ + "token": "%s" + }`, token) - oidctesting.GenerateSelfSignedCert(t, "127.0.0.1", cert1, key1) - oidctesting.GenerateSelfSignedCert(t, "127.0.0.1", cert2, key2) + var c claims + if err := json.Unmarshal([]byte(payload), &c); err != nil { + t.Fatal(err) + } + var n int + err := c.unmarshalClaim("token", &n) + if err == nil { + t.Fatal("expected error") + } + if strings.Contains(err.Error(), token) { + t.Fatalf("unmarshal error included token") + } +} + +func TestUnmarshalClaim(t *testing.T) { tests := []struct { - testCase string - - serverCertFile string - serverKeyFile string - - trustedCertFile string - + name string + claims string + do func(claims) (interface{}, error) + want interface{} wantErr bool }{ { - testCase: "provider using untrusted custom cert", - serverCertFile: cert1, - serverKeyFile: key1, - wantErr: true, + name: "string claim", + claims: `{"aud":"foo"}`, + do: func(c claims) (interface{}, error) { + var s string + err := c.unmarshalClaim("aud", &s) + return s, err + }, + want: "foo", }, { - testCase: "provider using untrusted cert", - serverCertFile: cert1, - serverKeyFile: key1, - trustedCertFile: cert2, - wantErr: true, - }, - { - testCase: "provider using trusted cert", - serverCertFile: cert1, - serverKeyFile: key1, - trustedCertFile: cert1, - wantErr: false, - }, - } + name: "mismatched types", + claims: `{"aud":"foo"}`, + do: func(c claims) (interface{}, error) { + var n int + err := c.unmarshalClaim("aud", &n) + return n, err - for _, tc := range tests { - func() { - op := oidctesting.NewOIDCProvider(t, "") - srv, err := op.ServeTLSWithKeyPair(tc.serverCertFile, tc.serverKeyFile) - if err != nil { - t.Errorf("%s: %v", tc.testCase, err) - return - } - defer srv.Close() - - issuer := srv.URL - clientID := "client-foo" - - options := OIDCOptions{ - IssuerURL: srv.URL, - ClientID: clientID, - CAFile: tc.trustedCertFile, - UsernameClaim: "email", - GroupsClaim: "groups", - } - - authenticator, err := New(options) - if err != nil { - t.Errorf("%s: failed to initialize authenticator: %v", tc.testCase, err) - return - } - defer authenticator.Close() - - email := "user-1@example.com" - groups := []string{"group1", "group2"} - sort.Strings(groups) - - token := generateGoodToken(t, op, issuer, "user-1", clientID, "email", email, "groups", groups) - - // Because this authenticator behaves differently for subsequent requests, run these - // tests multiple times (but expect the same result). - for i := 1; i < 4; i++ { - - user, ok, err := authenticator.AuthenticateToken(token) - if err != nil { - if !tc.wantErr { - t.Errorf("%s (req #%d): failed to authenticate token: %v", tc.testCase, i, err) - } - continue - } - - if tc.wantErr { - t.Errorf("%s (req #%d): expected error authenticating", tc.testCase, i) - continue - } - if !ok { - t.Errorf("%s (req #%d): did not get user or error", tc.testCase, i) - continue - } - - if gotUsername := user.GetName(); email != gotUsername { - t.Errorf("%s (req #%d): GetName() expected=%q got %q", tc.testCase, i, email, gotUsername) - } - gotGroups := user.GetGroups() - sort.Strings(gotGroups) - if !reflect.DeepEqual(gotGroups, groups) { - t.Errorf("%s (req #%d): GetGroups() expected=%q got %q", tc.testCase, i, groups, gotGroups) - } - } - }() - } -} - -func TestOIDCAuthentication(t *testing.T) { - cert := path.Join(os.TempDir(), "oidc-cert") - key := path.Join(os.TempDir(), "oidc-key") - - defer os.Remove(cert) - defer os.Remove(key) - - oidctesting.GenerateSelfSignedCert(t, "127.0.0.1", cert, key) - - // Ensure all tests pass when the issuer is not at a base URL. - for _, path := range []string{"", "/path/with/trailing/slash/"} { - - // Create a TLS server and a client. - op := oidctesting.NewOIDCProvider(t, path) - srv, err := op.ServeTLSWithKeyPair(cert, key) - if err != nil { - t.Fatalf("Cannot start server: %v", err) - } - defer srv.Close() - - tests := []struct { - userClaim string - groupsClaim string - token string - userInfo user.Info - verified bool - err string - }{ - { - "sub", - "", - generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "sub", "user-foo", "", nil), - &user.DefaultInfo{Name: "user-foo"}, - true, - "", - }, - { - // Use user defined claim (email here). - "email", - "", - generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "email", "foo@example.com", "", nil), - &user.DefaultInfo{Name: "foo@example.com"}, - true, - "", - }, - { - // Use user defined claim (email here). - "email", - "", - generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "email", "foo@example.com", "groups", []string{"group1", "group2"}), - &user.DefaultInfo{Name: "foo@example.com"}, - true, - "", - }, - { - // Use user defined claim (email here). - "email", - "groups", - generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "email", "foo@example.com", "groups", []string{"group1", "group2"}), - &user.DefaultInfo{Name: "foo@example.com", Groups: []string{"group1", "group2"}}, - true, - "", - }, - { - // Group claim is a string rather than an array. Map that string to a single group. - "email", - "groups", - generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "email", "foo@example.com", "groups", "group1"), - &user.DefaultInfo{Name: "foo@example.com", Groups: []string{"group1"}}, - true, - "", - }, - { - // Group claim is not a string or array of strings. Throw out this as invalid. - "email", - "groups", - generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "email", "foo@example.com", "groups", 1), - nil, - false, - "custom group claim contains invalid type: float64", - }, - { - // Email not verified - "email", - "", - generateTokenWithUnverifiedEmail(t, op, srv.URL, "client-foo", "client-foo", "foo@example.com"), - nil, - false, - "email not verified", - }, - { - "sub", - "", - generateMalformedToken(t, op, srv.URL, "client-foo", "client-foo", "sub", "user-foo", "", nil), - nil, - false, - "oidc: unable to verify JWT signature: no matching keys", - }, - { - // Invalid 'aud'. - "sub", - "", - generateGoodToken(t, op, srv.URL, "client-foo", "client-bar", "sub", "user-foo", "", nil), - nil, - false, - "oidc: JWT claims invalid: invalid claims, 'aud' claim and 'client_id' do not match", - }, - { - // Invalid issuer. - "sub", - "", - generateGoodToken(t, op, "http://foo-bar.com", "client-foo", "client-foo", "sub", "user-foo", "", nil), - nil, - false, - "oidc: JWT claims invalid: invalid claim value: 'iss'.", - }, - { - "sub", - "", - generateExpiredToken(t, op, srv.URL, "client-foo", "client-foo", "sub", "user-foo", "", nil), - nil, - false, - "oidc: JWT claims invalid: token is expired", - }, - } - - for i, tt := range tests { - client, err := New(OIDCOptions{srv.URL, "client-foo", cert, tt.userClaim, "", tt.groupsClaim, ""}) - if err != nil { - t.Errorf("Unexpected error: %v", err) - continue - } - - user, result, err := client.AuthenticateToken(tt.token) - if tt.err != "" { - if !strings.HasPrefix(err.Error(), tt.err) { - t.Errorf("#%d: Expecting: %v..., but got: %v", i, tt.err, err) - } - } else { - if err != nil { - t.Errorf("#%d: Unexpected error: %v", i, err) - } - } - if !reflect.DeepEqual(tt.verified, result) { - t.Errorf("#%d: Expecting: %v, but got: %v", i, tt.verified, result) - } - if !reflect.DeepEqual(tt.userInfo, user) { - t.Errorf("#%d: Expecting: %v, but got: %v", i, tt.userInfo, user) - } - client.Close() - } - } -} - -func TestParseTokenClaims(t *testing.T) { - tests := []struct { - name string - - // Note this is missing a lot of configuration options because - // parseTokenClaim doesn't handle: - // - // - 'iss' claim matching issuer URL - // - 'exp' claim having not expired - // - 'sub' claim matching a trusted client id - // - // That logic has coverage in other tests. - - issuerURL string - usernameClaim string - usernamePrefix string - groupsClaim string - groupsPrefix string - - claims jose.Claims - - wantUser *user.DefaultInfo - wantErr bool - }{ - { - name: "email username", - issuerURL: "https://foo.com/", - usernameClaim: "email", - claims: jose.Claims{ - "email": "jane.doe@example.com", - "email_verified": true, - }, - wantUser: &user.DefaultInfo{ - Name: "jane.doe@example.com", - }, - }, - { - name: "no email_verified claim", - issuerURL: "https://foo.com/", - usernameClaim: "email", - claims: jose.Claims{ - "email": "jane.doe@example.com", }, wantErr: true, }, { - name: "email unverified", - issuerURL: "https://foo.com/", - usernameClaim: "email", - claims: jose.Claims{ - "email": "jane.doe@example.com", - "email_verified": false, + name: "bool claim", + claims: `{"email":"foo@coreos.com","email_verified":true}`, + do: func(c claims) (interface{}, error) { + var verified bool + err := c.unmarshalClaim("email_verified", &verified) + return verified, err }, - wantErr: true, + want: true, }, { - name: "non-email user claim", - issuerURL: "https://foo.com/", - usernameClaim: "name", - claims: jose.Claims{ - "name": "janedoe", - }, - wantUser: &user.DefaultInfo{ - Name: "janedoe", - }, - }, - { - name: "groups claim", - issuerURL: "https://foo.com/", - usernameClaim: "name", - groupsClaim: "groups", - claims: jose.Claims{ - "name": "janedoe", - "groups": []string{"foo", "bar"}, - }, - wantUser: &user.DefaultInfo{ - Name: "janedoe", - Groups: []string{"foo", "bar"}, - }, - }, - { - name: "groups claim string", - issuerURL: "https://foo.com/", - usernameClaim: "name", - groupsClaim: "groups", - claims: jose.Claims{ - "name": "janedoe", - "groups": "foo", - }, - wantUser: &user.DefaultInfo{ - Name: "janedoe", - Groups: []string{"foo"}, - }, - }, - { - name: "username prefix", - issuerURL: "https://foo.com/", - usernameClaim: "name", - groupsClaim: "groups", - usernamePrefix: "oidc:", - claims: jose.Claims{ - "name": "janedoe", - "groups": []string{"foo", "bar"}, - }, - wantUser: &user.DefaultInfo{ - Name: "oidc:janedoe", - Groups: []string{"foo", "bar"}, - }, - }, - { - name: "username prefix with email", - issuerURL: "https://foo.com/", - usernameClaim: "email", - groupsClaim: "groups", - usernamePrefix: "oidc:", - claims: jose.Claims{ - "email": "jane.doe@example.com", - "email_verified": true, - "groups": []string{"foo", "bar"}, - }, - wantUser: &user.DefaultInfo{ - Name: "oidc:jane.doe@example.com", - Groups: []string{"foo", "bar"}, - }, - }, - { - name: "groups prefix", - issuerURL: "https://foo.com/", - usernameClaim: "name", - groupsClaim: "groups", - groupsPrefix: "oidc:", - claims: jose.Claims{ - "name": "janedoe", - "groups": []string{"foo", "bar"}, - }, - wantUser: &user.DefaultInfo{ - Name: "janedoe", - Groups: []string{"oidc:foo", "oidc:bar"}, - }, - }, - { - name: "username and groups prefix", - issuerURL: "https://foo.com/", - usernameClaim: "name", - groupsClaim: "groups", - usernamePrefix: "oidc-user:", - groupsPrefix: "oidc:", - claims: jose.Claims{ - "name": "janedoe", - "groups": []string{"foo", "bar"}, - }, - wantUser: &user.DefaultInfo{ - Name: "oidc-user:janedoe", - Groups: []string{"oidc:foo", "oidc:bar"}, + name: "strings claim", + claims: `{"groups":["a","b","c"]}`, + do: func(c claims) (interface{}, error) { + var groups []string + err := c.unmarshalClaim("groups", &groups) + return groups, err }, + want: []string{"a", "b", "c"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - o := OIDCAuthenticator{ - issuerURL: test.issuerURL, - usernameClaim: test.usernameClaim, - usernamePrefix: test.usernamePrefix, - groupsClaim: test.groupsClaim, - groupsPrefix: test.groupsPrefix, + var c claims + if err := json.Unmarshal([]byte(test.claims), &c); err != nil { + t.Fatal(err) } - u, ok, err := o.parseTokenClaims(test.claims) + got, err := test.do(c) if err != nil { - if !test.wantErr { - t.Errorf("failed to authenticate user: %v", err) + if test.wantErr { + return } - return + t.Fatalf("unexpected error: %v", err) } if test.wantErr { - t.Fatalf("expected authentication to fail") + t.Fatalf("expected error") } - if !ok { - // We don't have any cases today when the claims can return - // no error with an unauthenticated signal. - // - // In the future we might. - t.Fatalf("user wasn't authenticated") - } - - got := &user.DefaultInfo{ - Name: u.GetName(), - UID: u.GetUID(), - Groups: u.GetGroups(), - Extra: u.GetExtra(), - } - if !reflect.DeepEqual(got, test.wantUser) { - t.Errorf("wanted user=%#v, got=%#v", test.wantUser, got) + if !reflect.DeepEqual(got, test.want) { + t.Errorf("wanted=%#v, got=%#v", test.want, got) } }) } diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/testdata/gen.sh b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/testdata/gen.sh new file mode 100755 index 00000000000..a3daa0fc438 --- /dev/null +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/testdata/gen.sh @@ -0,0 +1,27 @@ +#!/bin/bash -e + +# Copyright 2018 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. + +rm *.pem + +for N in `seq 1 3`; do + ssh-keygen -t rsa -b 2048 -f rsa_$N.pem -N '' +done + +for N in `seq 1 3`; do + ssh-keygen -t ecdsa -b 521 -f ecdsa_$N.pem -N '' +done + +rm *.pub