diff --git a/pkg/validation/options.go b/pkg/validation/options.go index 0cea4b2b..652ada9e 100644 --- a/pkg/validation/options.go +++ b/pkg/validation/options.go @@ -233,7 +233,10 @@ func parseProviderInfo(o *options.Options, msgs []string) []string { p.ValidateURL, msgs = parseURL(o.ValidateURL, "validate", msgs) p.ProtectedResource, msgs = parseURL(o.ProtectedResource, "resource", msgs) - // Make the OIDC Verifier accessible to all providers that can support it + // Make the OIDC options available to all providers that support it + p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail + p.EmailClaim = o.UserIDClaim + p.GroupsClaim = o.OIDCGroupsClaim p.Verifier = o.GetOIDCVerifier() p.SetAllowedGroups(o.AllowedGroups) diff --git a/providers/oidc.go b/providers/oidc.go index 15020282..f90348d6 100644 --- a/providers/oidc.go +++ b/providers/oidc.go @@ -4,26 +4,17 @@ import ( "context" "errors" "fmt" - "reflect" - "strings" "time" - "github.com/coreos/go-oidc" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" "golang.org/x/oauth2" ) -const emailClaim = "email" - // OIDCProvider represents an OIDC based Identity Provider type OIDCProvider struct { *ProviderData - - AllowUnverifiedEmail bool - EmailClaim string - GroupsClaim string } // NewOIDCProvider initiates a new OIDCProvider @@ -213,7 +204,7 @@ func (p *OIDCProvider) CreateSessionFromToken(ctx context.Context, token string) // createSession takes an oauth2.Token and creates a SessionState from it. // It alters behavior if called from Redeem vs Refresh func (p *OIDCProvider) createSession(ctx context.Context, token *oauth2.Token, refresh bool) (*sessions.SessionState, error) { - idToken, err := p.findVerifiedIDToken(ctx, token) + idToken, err := p.verifyIDToken(ctx, token) if err != nil { return nil, fmt.Errorf("could not verify id_token: %v", err) } @@ -238,90 +229,3 @@ func (p *OIDCProvider) createSession(ctx context.Context, token *oauth2.Token, r return ss, nil } - -func (p *OIDCProvider) findVerifiedIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) { - rawIDToken := getIDToken(token) - if strings.TrimSpace(rawIDToken) != "" { - return p.Verifier.Verify(ctx, rawIDToken) - } - return nil, nil -} - -// buildSessionFromClaims uses IDToken claims to populate a fresh SessionState -// with non-Token related fields. -func (p *OIDCProvider) buildSessionFromClaims(idToken *oidc.IDToken) (*sessions.SessionState, error) { - ss := &sessions.SessionState{} - - if idToken == nil { - return ss, nil - } - - claims, err := p.getClaims(idToken) - if err != nil { - return nil, fmt.Errorf("couldn't extract claims from id_token (%v)", err) - } - - ss.User = claims.Subject - ss.Email = claims.Email - ss.Groups = claims.Groups - - // TODO (@NickMeves) Deprecate for dynamic claim to session mapping - if pref, ok := claims.rawClaims["preferred_username"].(string); ok { - ss.PreferredUsername = pref - } - - verifyEmail := (p.EmailClaim == emailClaim) && !p.AllowUnverifiedEmail - if verifyEmail && claims.Verified != nil && !*claims.Verified { - return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email) - } - - return ss, nil -} - -type OIDCClaims struct { - Subject string `json:"sub"` - Email string `json:"-"` - Groups []string `json:"-"` - Verified *bool `json:"email_verified"` - - rawClaims map[string]interface{} -} - -// getClaims extracts IDToken claims into an OIDCClaims -func (p *OIDCProvider) getClaims(idToken *oidc.IDToken) (*OIDCClaims, error) { - claims := &OIDCClaims{} - - // Extract default claims. - if err := idToken.Claims(&claims); err != nil { - return nil, fmt.Errorf("failed to parse default id_token claims: %v", err) - } - // Extract custom claims. - if err := idToken.Claims(&claims.rawClaims); err != nil { - return nil, fmt.Errorf("failed to parse all id_token claims: %v", err) - } - - email := claims.rawClaims[p.EmailClaim] - if email != nil { - claims.Email = fmt.Sprint(email) - } - claims.Groups = p.extractGroups(claims.rawClaims) - - return claims, nil -} - -func (p *OIDCProvider) extractGroups(claims map[string]interface{}) []string { - groups := []string{} - rawGroups, ok := claims[p.GroupsClaim].([]interface{}) - if rawGroups != nil && ok { - for _, rawGroup := range rawGroups { - formattedGroup, err := formatGroup(rawGroup) - if err != nil { - logger.Errorf("Warning: unable to format group of type %s with error %s", - reflect.TypeOf(rawGroup), err) - continue - } - groups = append(groups, formattedGroup) - } - } - return groups -} diff --git a/providers/oidc_test.go b/providers/oidc_test.go index 0cc6ef76..2651b4ea 100644 --- a/providers/oidc_test.go +++ b/providers/oidc_test.go @@ -2,42 +2,18 @@ package providers import ( "context" - "crypto/rand" - "crypto/rsa" - "encoding/base64" "encoding/json" "errors" - "fmt" "net/http" "net/http/httptest" "net/url" - "strings" "testing" - "time" "github.com/coreos/go-oidc" - "github.com/dgrijalva/jwt-go" - "github.com/stretchr/testify/assert" - "golang.org/x/oauth2" - "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + "github.com/stretchr/testify/assert" ) -const accessToken = "access_token" -const refreshToken = "refresh_token" -const clientID = "https://test.myapp.com" -const secret = "secret" - -type idTokenClaims struct { - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` - Phone string `json:"phone_number,omitempty"` - Picture string `json:"picture,omitempty"` - Groups interface{} `json:"groups,omitempty"` - OtherGroups interface{} `json:"other_groups,omitempty"` - jwt.StandardClaims -} - type redeemTokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` @@ -46,88 +22,12 @@ type redeemTokenResponse struct { IDToken string `json:"id_token,omitempty"` } -var defaultIDToken idTokenClaims = idTokenClaims{ - "Jane Dobbs", - "janed@me.com", - "+4798765432", - "http://mugbook.com/janed/me.jpg", - []string{"test:a", "test:b"}, - []string{"test:c", "test:d"}, - jwt.StandardClaims{ - Audience: "https://test.myapp.com", - ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(), - Id: "id-some-id", - IssuedAt: time.Now().Unix(), - Issuer: "https://issuer.example.com", - NotBefore: 0, - Subject: "123456789", - }, -} - -var customGroupClaimIDToken idTokenClaims = idTokenClaims{ - "Jane Dobbs", - "janed@me.com", - "+4798765432", - "http://mugbook.com/janed/me.jpg", - []map[string]interface{}{ - { - "groupId": "Admin Group Id", - "roles": []string{"Admin"}, - }, - }, - []string{"test:c", "test:d"}, - jwt.StandardClaims{ - Audience: "https://test.myapp.com", - ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(), - Id: "id-some-id", - IssuedAt: time.Now().Unix(), - Issuer: "https://issuer.example.com", - NotBefore: 0, - Subject: "123456789", - }, -} - -var minimalIDToken idTokenClaims = idTokenClaims{ - "", - "", - "", - "", - []string{}, - []string{}, - jwt.StandardClaims{ - Audience: "https://test.myapp.com", - ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(), - Id: "id-some-id", - IssuedAt: time.Now().Unix(), - Issuer: "https://issuer.example.com", - NotBefore: 0, - Subject: "minimal", - }, -} - -type fakeKeySetStub struct{} - -func (fakeKeySetStub) VerifySignature(_ context.Context, jwt string) (payload []byte, err error) { - decodeString, err := base64.RawURLEncoding.DecodeString(strings.Split(jwt, ".")[1]) - if err != nil { - return nil, err - } - tokenClaims := &idTokenClaims{} - err = json.Unmarshal(decodeString, tokenClaims) - - if err != nil || tokenClaims.Id == "this-id-fails-validation" { - return nil, fmt.Errorf("the validation failed for subject [%v]", tokenClaims.Subject) - } - - return decodeString, err -} - func newOIDCProvider(serverURL *url.URL) *OIDCProvider { providerData := &ProviderData{ ProviderName: "oidc", - ClientID: clientID, - ClientSecret: secret, + ClientID: oidcClientID, + ClientSecret: oidcSecret, LoginURL: &url.URL{ Scheme: serverURL.Scheme, Host: serverURL.Host, @@ -144,18 +44,17 @@ func newOIDCProvider(serverURL *url.URL) *OIDCProvider { Scheme: serverURL.Scheme, Host: serverURL.Host, Path: "/api"}, - Scope: "openid profile offline_access", + Scope: "openid profile offline_access", + EmailClaim: "email", + GroupsClaim: "groups", Verifier: oidc.NewVerifier( - "https://issuer.example.com", - fakeKeySetStub{}, - &oidc.Config{ClientID: clientID}, + oidcIssuer, + mockJWKS{}, + &oidc.Config{ClientID: oidcClientID}, ), } - p := &OIDCProvider{ - ProviderData: providerData, - EmailClaim: "email", - } + p := &OIDCProvider{ProviderData: providerData} return p } @@ -169,21 +68,6 @@ func newOIDCServer(body []byte) (*url.URL, *httptest.Server) { return u, s } -func newSignedTestIDToken(tokenClaims idTokenClaims) (string, error) { - key, _ := rsa.GenerateKey(rand.Reader, 2048) - standardClaims := jwt.NewWithClaims(jwt.SigningMethodRS256, tokenClaims) - return standardClaims.SignedString(key) -} - -func newOauth2Token() *oauth2.Token { - return &oauth2.Token{ - AccessToken: accessToken, - TokenType: "Bearer", - RefreshToken: refreshToken, - Expiry: time.Time{}.Add(time.Duration(5) * time.Second), - } -} - func newTestSetup(body []byte) (*httptest.Server, *OIDCProvider) { redeemURL, server := newOIDCServer(body) provider := newOIDCProvider(redeemURL) @@ -234,12 +118,6 @@ func TestOIDCProviderRedeem_custom_userid(t *testing.T) { } func TestOIDCProvider_EnrichSession(t *testing.T) { - const ( - idToken = "Unchanged ID Token" - accessToken = "Unchanged Access Token" - refreshToken = "Unchanged Refresh Token" - ) - testCases := map[string]struct { ExistingSession *sessions.SessionState EmailClaim string @@ -550,8 +428,6 @@ func TestOIDCProviderRefreshSessionIfNeededWithIdToken(t *testing.T) { } func TestOIDCProviderCreateSessionFromToken(t *testing.T) { - const profileURLEmail = "janed@me.com" - testCases := map[string]struct { IDToken idTokenClaims GroupsClaim string @@ -562,36 +438,35 @@ func TestOIDCProviderCreateSessionFromToken(t *testing.T) { "Default IDToken": { IDToken: defaultIDToken, GroupsClaim: "groups", - ExpectedUser: defaultIDToken.Subject, - ExpectedEmail: defaultIDToken.Email, + ExpectedUser: "123456789", + ExpectedEmail: "janed@me.com", ExpectedGroups: []string{"test:a", "test:b"}, }, "Minimal IDToken with no email claim": { IDToken: minimalIDToken, GroupsClaim: "groups", - ExpectedUser: minimalIDToken.Subject, - ExpectedEmail: minimalIDToken.Subject, + ExpectedUser: "123456789", + ExpectedEmail: "123456789", ExpectedGroups: []string{}, }, "Custom Groups Claim": { IDToken: defaultIDToken, - GroupsClaim: "other_groups", - ExpectedUser: defaultIDToken.Subject, - ExpectedEmail: defaultIDToken.Email, + GroupsClaim: "roles", + ExpectedUser: "123456789", + ExpectedEmail: "janed@me.com", ExpectedGroups: []string{"test:c", "test:d"}, }, - "Custom Groups Claim2": { - IDToken: customGroupClaimIDToken, + "Complex Groups Claim": { + IDToken: complexGroupsIDToken, GroupsClaim: "groups", - ExpectedUser: customGroupClaimIDToken.Subject, - ExpectedEmail: customGroupClaimIDToken.Email, + ExpectedUser: "123456789", + ExpectedEmail: "complex@claims.com", ExpectedGroups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"}, }, } for testName, tc := range testCases { t.Run(testName, func(t *testing.T) { - jsonResp := []byte(fmt.Sprintf(`{"email":"%s"}`, profileURLEmail)) - server, provider := newTestSetup(jsonResp) + server, provider := newTestSetup([]byte(`{}`)) provider.GroupsClaim = tc.GroupsClaim defer server.Close() @@ -610,40 +485,3 @@ func TestOIDCProviderCreateSessionFromToken(t *testing.T) { }) } } - -func TestOIDCProvider_findVerifiedIDToken(t *testing.T) { - - server, provider := newTestSetup([]byte("")) - - defer server.Close() - - token := newOauth2Token() - signedIDToken, _ := newSignedTestIDToken(defaultIDToken) - tokenWithIDToken := token.WithExtra(map[string]interface{}{ - "id_token": signedIDToken, - }) - - verifiedIDToken, err := provider.findVerifiedIDToken(context.Background(), tokenWithIDToken) - assert.Equal(t, true, err == nil) - if verifiedIDToken == nil { - t.Fatal("verifiedIDToken is nil") - } - assert.Equal(t, defaultIDToken.Issuer, verifiedIDToken.Issuer) - assert.Equal(t, defaultIDToken.Subject, verifiedIDToken.Subject) - - // When the validation fails the response should be nil - defaultIDToken.Id = "this-id-fails-validation" - signedIDToken, _ = newSignedTestIDToken(defaultIDToken) - tokenWithIDToken = token.WithExtra(map[string]interface{}{ - "id_token": signedIDToken, - }) - - verifiedIDToken, err = provider.findVerifiedIDToken(context.Background(), tokenWithIDToken) - assert.Equal(t, errors.New("failed to verify signature: the validation failed for subject [123456789]"), err) - assert.Equal(t, true, verifiedIDToken == nil) - - // When there is no id token in the oauth token - verifiedIDToken, err = provider.findVerifiedIDToken(context.Background(), newOauth2Token()) - assert.Equal(t, nil, err) - assert.Equal(t, true, verifiedIDToken == nil) -} diff --git a/providers/provider_data.go b/providers/provider_data.go index 330df7ca..09eadd25 100644 --- a/providers/provider_data.go +++ b/providers/provider_data.go @@ -1,12 +1,18 @@ package providers import ( + "context" "errors" + "fmt" "io/ioutil" "net/url" + "reflect" + "strings" "github.com/coreos/go-oidc" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + "golang.org/x/oauth2" ) // ProviderData contains information required to configure all implementations @@ -27,7 +33,12 @@ type ProviderData struct { ClientSecretFile string Scope string Prompt string - Verifier *oidc.IDTokenVerifier + + // Common OIDC options for any OIDC-based providers to consume + AllowUnverifiedEmail bool + EmailClaim string + GroupsClaim string + Verifier *oidc.IDTokenVerifier // Universal Group authorization data structure // any provider can set to consume @@ -94,3 +105,99 @@ func defaultURL(u *url.URL, d *url.URL) *url.URL { } return &url.URL{} } + +// **************************************************************************** +// These private OIDC helper methods are available to any providers that are +// OIDC compliant +// **************************************************************************** + +// OIDCClaims is a struct to unmarshal the OIDC claims from an ID Token payload +type OIDCClaims struct { + Subject string `json:"sub"` + Email string `json:"-"` + Groups []string `json:"-"` + Verified *bool `json:"email_verified"` + + raw map[string]interface{} +} + +func (p *ProviderData) verifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) { + rawIDToken := getIDToken(token) + if strings.TrimSpace(rawIDToken) != "" { + return p.Verifier.Verify(ctx, rawIDToken) + } + return nil, nil +} + +// buildSessionFromClaims uses IDToken claims to populate a fresh SessionState +// with non-Token related fields. +func (p *ProviderData) buildSessionFromClaims(idToken *oidc.IDToken) (*sessions.SessionState, error) { + ss := &sessions.SessionState{} + + if idToken == nil { + return ss, nil + } + + claims, err := p.getClaims(idToken) + if err != nil { + return nil, fmt.Errorf("couldn't extract claims from id_token (%v)", err) + } + + ss.User = claims.Subject + ss.Email = claims.Email + ss.Groups = claims.Groups + + // TODO (@NickMeves) Deprecate for dynamic claim to session mapping + if pref, ok := claims.raw["preferred_username"].(string); ok { + ss.PreferredUsername = pref + } + + // `email_verified` must be present and explicitly set to `false` to be + // considered unverified. + verifyEmail := (p.EmailClaim == emailClaim) && !p.AllowUnverifiedEmail + if verifyEmail && claims.Verified != nil && !*claims.Verified { + return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email) + } + + return ss, nil +} + +// getClaims extracts IDToken claims into an OIDCClaims +func (p *ProviderData) getClaims(idToken *oidc.IDToken) (*OIDCClaims, error) { + claims := &OIDCClaims{} + + // Extract default claims. + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("failed to parse default id_token claims: %v", err) + } + // Extract custom claims. + if err := idToken.Claims(&claims.raw); err != nil { + return nil, fmt.Errorf("failed to parse all id_token claims: %v", err) + } + + email := claims.raw[p.EmailClaim] + if email != nil { + claims.Email = fmt.Sprint(email) + } + claims.Groups = p.extractGroups(claims.raw) + + return claims, nil +} + +// extractGroups extracts groups from a claim to a list in a type safe manner +func (p *ProviderData) extractGroups(claims map[string]interface{}) []string { + groups := []string{} + rawGroups, ok := claims[p.GroupsClaim].([]interface{}) + if rawGroups != nil && ok { + for _, rawGroup := range rawGroups { + formattedGroup, err := formatGroup(rawGroup) + if err != nil { + logger.Errorf("Warning: unable to format group of type %s with error %s", + reflect.TypeOf(rawGroup), err) + continue + } + groups = append(groups, formattedGroup) + } + } + return groups +} diff --git a/providers/provider_data_test.go b/providers/provider_data_test.go new file mode 100644 index 00000000..4aed73eb --- /dev/null +++ b/providers/provider_data_test.go @@ -0,0 +1,414 @@ +package providers + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/coreos/go-oidc" + "github.com/dgrijalva/jwt-go" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + . "github.com/onsi/gomega" + "golang.org/x/oauth2" +) + +const ( + idToken = "eyJfoobar123.eyJbaz987.IDToken" + accessToken = "eyJfoobar123.eyJbaz987.AccessToken" + refreshToken = "eyJfoobar123.eyJbaz987.RefreshToken" + + oidcIssuer = "https://issuer.example.com" + oidcClientID = "https://test.myapp.com" + oidcSecret = "SuperSecret123456789" + + failureTokenID = "this-id-fails-verification" +) + +var ( + verified = true + unverified = false + + standardClaims = jwt.StandardClaims{ + Audience: oidcClientID, + ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(), + Id: "id-some-id", + IssuedAt: time.Now().Unix(), + Issuer: oidcIssuer, + NotBefore: 0, + Subject: "123456789", + } + + defaultIDToken = idTokenClaims{ + Name: "Jane Dobbs", + Email: "janed@me.com", + Phone: "+4798765432", + Picture: "http://mugbook.com/janed/me.jpg", + Groups: []string{"test:a", "test:b"}, + Roles: []string{"test:c", "test:d"}, + Verified: &verified, + StandardClaims: standardClaims, + } + + complexGroupsIDToken = idTokenClaims{ + Name: "Complex Claim", + Email: "complex@claims.com", + Phone: "+5439871234", + Picture: "http://mugbook.com/complex/claims.jpg", + Groups: []map[string]interface{}{ + { + "groupId": "Admin Group Id", + "roles": []string{"Admin"}, + }, + }, + Roles: []string{"test:simple", "test:roles"}, + Verified: &verified, + StandardClaims: standardClaims, + } + + unverifiedIDToken = idTokenClaims{ + Name: "Mystery Man", + Email: "unverified@email.com", + Phone: "+4025205729", + Picture: "http://mugbook.com/unverified/email.jpg", + Groups: []string{"test:a", "test:b"}, + Roles: []string{"test:c", "test:d"}, + Verified: &unverified, + StandardClaims: standardClaims, + } + + minimalIDToken = idTokenClaims{ + StandardClaims: standardClaims, + } +) + +type idTokenClaims struct { + Name string `json:"preferred_username,omitempty"` + Email string `json:"email,omitempty"` + Phone string `json:"phone_number,omitempty"` + Picture string `json:"picture,omitempty"` + Groups interface{} `json:"groups,omitempty"` + Roles interface{} `json:"roles,omitempty"` + Verified *bool `json:"email_verified,omitempty"` + jwt.StandardClaims +} + +type mockJWKS struct{} + +func (mockJWKS) VerifySignature(_ context.Context, jwt string) ([]byte, error) { + decoded, err := base64.RawURLEncoding.DecodeString(strings.Split(jwt, ".")[1]) + if err != nil { + return nil, err + } + + tokenClaims := &idTokenClaims{} + err = json.Unmarshal(decoded, tokenClaims) + if err != nil || tokenClaims.Id == failureTokenID { + return nil, fmt.Errorf("the validation failed for subject [%v]", tokenClaims.Subject) + } + + return decoded, nil +} + +func newSignedTestIDToken(tokenClaims idTokenClaims) (string, error) { + key, _ := rsa.GenerateKey(rand.Reader, 2048) + standardClaims := jwt.NewWithClaims(jwt.SigningMethodRS256, tokenClaims) + return standardClaims.SignedString(key) +} + +func newTestOauth2Token() *oauth2.Token { + return &oauth2.Token{ + AccessToken: accessToken, + TokenType: "Bearer", + RefreshToken: refreshToken, + Expiry: time.Time{}.Add(time.Duration(5) * time.Second), + } +} + +func TestProviderData_verifyIDToken(t *testing.T) { + failureIDToken := defaultIDToken + failureIDToken.Id = failureTokenID + + testCases := map[string]struct { + IDToken *idTokenClaims + ExpectIDToken bool + ExpectedError error + }{ + "Valid ID Token": { + IDToken: &defaultIDToken, + ExpectIDToken: true, + ExpectedError: nil, + }, + "Invalid ID Token": { + IDToken: &failureIDToken, + ExpectIDToken: false, + ExpectedError: errors.New("failed to verify signature: the validation failed for subject [123456789]"), + }, + "Missing ID Token": { + IDToken: nil, + ExpectIDToken: false, + ExpectedError: nil, + }, + } + + for testName, tc := range testCases { + t.Run(testName, func(t *testing.T) { + g := NewWithT(t) + + token := newTestOauth2Token() + if tc.IDToken != nil { + idToken, err := newSignedTestIDToken(*tc.IDToken) + g.Expect(err).ToNot(HaveOccurred()) + token = token.WithExtra(map[string]interface{}{ + "id_token": idToken, + }) + } + + provider := &ProviderData{ + Verifier: oidc.NewVerifier( + oidcIssuer, + mockJWKS{}, + &oidc.Config{ClientID: oidcClientID}, + ), + } + verified, err := provider.verifyIDToken(context.Background(), token) + if err != nil { + g.Expect(err).To(Equal(tc.ExpectedError)) + } + + if tc.ExpectIDToken { + g.Expect(verified).ToNot(BeNil()) + g.Expect(*verified).To(BeAssignableToTypeOf(oidc.IDToken{})) + } else { + g.Expect(verified).To(BeNil()) + } + }) + } +} + +func TestProviderData_buildSessionFromClaims(t *testing.T) { + testCases := map[string]struct { + IDToken idTokenClaims + AllowUnverified bool + EmailClaim string + GroupsClaim string + ExpectedError error + ExpectedSession *sessions.SessionState + }{ + "Standard": { + IDToken: defaultIDToken, + AllowUnverified: false, + EmailClaim: "email", + GroupsClaim: "groups", + ExpectedSession: &sessions.SessionState{ + User: "123456789", + Email: "janed@me.com", + Groups: []string{"test:a", "test:b"}, + PreferredUsername: "Jane Dobbs", + }, + }, + "Unverified Denied": { + IDToken: unverifiedIDToken, + AllowUnverified: false, + EmailClaim: "email", + GroupsClaim: "groups", + ExpectedError: errors.New("email in id_token (unverified@email.com) isn't verified"), + }, + "Unverified Allowed": { + IDToken: unverifiedIDToken, + AllowUnverified: true, + EmailClaim: "email", + GroupsClaim: "groups", + ExpectedSession: &sessions.SessionState{ + User: "123456789", + Email: "unverified@email.com", + Groups: []string{"test:a", "test:b"}, + PreferredUsername: "Mystery Man", + }, + }, + "Complex Groups": { + IDToken: complexGroupsIDToken, + AllowUnverified: true, + EmailClaim: "email", + GroupsClaim: "groups", + ExpectedSession: &sessions.SessionState{ + User: "123456789", + Email: "complex@claims.com", + Groups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"}, + PreferredUsername: "Complex Claim", + }, + }, + "Email Claim Switched": { + IDToken: unverifiedIDToken, + AllowUnverified: true, + EmailClaim: "phone_number", + GroupsClaim: "groups", + ExpectedSession: &sessions.SessionState{ + User: "123456789", + Email: "+4025205729", + Groups: []string{"test:a", "test:b"}, + PreferredUsername: "Mystery Man", + }, + }, + "Email Claim Switched to Non String": { + IDToken: unverifiedIDToken, + AllowUnverified: true, + EmailClaim: "roles", + GroupsClaim: "groups", + ExpectedSession: &sessions.SessionState{ + User: "123456789", + Email: "[test:c test:d]", + Groups: []string{"test:a", "test:b"}, + PreferredUsername: "Mystery Man", + }, + }, + "Email Claim Non Existent": { + IDToken: unverifiedIDToken, + AllowUnverified: true, + EmailClaim: "aksjdfhjksadh", + GroupsClaim: "groups", + ExpectedSession: &sessions.SessionState{ + User: "123456789", + Email: "", + Groups: []string{"test:a", "test:b"}, + PreferredUsername: "Mystery Man", + }, + }, + "Groups Claim Switched": { + IDToken: defaultIDToken, + AllowUnverified: false, + EmailClaim: "email", + GroupsClaim: "roles", + ExpectedSession: &sessions.SessionState{ + User: "123456789", + Email: "janed@me.com", + Groups: []string{"test:c", "test:d"}, + PreferredUsername: "Jane Dobbs", + }, + }, + "Groups Claim Non Existent": { + IDToken: defaultIDToken, + AllowUnverified: false, + EmailClaim: "email", + GroupsClaim: "alskdjfsalkdjf", + ExpectedSession: &sessions.SessionState{ + User: "123456789", + Email: "janed@me.com", + Groups: []string{}, + PreferredUsername: "Jane Dobbs", + }, + }, + } + for testName, tc := range testCases { + t.Run(testName, func(t *testing.T) { + g := NewWithT(t) + + provider := &ProviderData{ + Verifier: oidc.NewVerifier( + oidcIssuer, + mockJWKS{}, + &oidc.Config{ClientID: oidcClientID}, + ), + } + provider.AllowUnverifiedEmail = tc.AllowUnverified + provider.EmailClaim = tc.EmailClaim + provider.GroupsClaim = tc.GroupsClaim + + rawIDToken, err := newSignedTestIDToken(tc.IDToken) + g.Expect(err).ToNot(HaveOccurred()) + + idToken, err := provider.Verifier.Verify(context.Background(), rawIDToken) + g.Expect(err).ToNot(HaveOccurred()) + + ss, err := provider.buildSessionFromClaims(idToken) + if err != nil { + g.Expect(err).To(Equal(tc.ExpectedError)) + } + if ss != nil { + g.Expect(ss).To(Equal(tc.ExpectedSession)) + } + }) + } +} + +func TestProviderData_extractGroups(t *testing.T) { + testCases := map[string]struct { + Claims map[string]interface{} + GroupsClaim string + ExpectedGroups []string + }{ + "Standard String Groups": { + Claims: map[string]interface{}{ + "email": "this@does.not.matter.com", + "groups": []interface{}{"three", "string", "groups"}, + }, + GroupsClaim: "groups", + ExpectedGroups: []string{"three", "string", "groups"}, + }, + "Different Claim Name": { + Claims: map[string]interface{}{ + "email": "this@does.not.matter.com", + "roles": []interface{}{"three", "string", "roles"}, + }, + GroupsClaim: "roles", + ExpectedGroups: []string{"three", "string", "roles"}, + }, + "Numeric Groups": { + Claims: map[string]interface{}{ + "email": "this@does.not.matter.com", + "groups": []interface{}{1, 2, 3}, + }, + GroupsClaim: "groups", + ExpectedGroups: []string{"1", "2", "3"}, + }, + "Complex Groups": { + Claims: map[string]interface{}{ + "email": "this@does.not.matter.com", + "groups": []interface{}{ + map[string]interface{}{ + "groupId": "Admin Group Id", + "roles": []string{"Admin"}, + }, + 12345, + "Just::A::String", + }, + }, + GroupsClaim: "groups", + ExpectedGroups: []string{ + "{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}", + "12345", + "Just::A::String", + }, + }, + "Missing Groups": { + Claims: map[string]interface{}{ + "email": "this@does.not.matter.com", + }, + GroupsClaim: "groups", + ExpectedGroups: []string{}, + }, + } + for testName, tc := range testCases { + t.Run(testName, func(t *testing.T) { + g := NewWithT(t) + + provider := &ProviderData{ + Verifier: oidc.NewVerifier( + oidcIssuer, + mockJWKS{}, + &oidc.Config{ClientID: oidcClientID}, + ), + } + provider.GroupsClaim = tc.GroupsClaim + + groups := provider.extractGroups(tc.Claims) + g.Expect(groups).To(Equal(tc.ExpectedGroups)) + }) + } +} diff --git a/providers/provider_default.go b/providers/provider_default.go index 012a538c..69dddb06 100644 --- a/providers/provider_default.go +++ b/providers/provider_default.go @@ -13,6 +13,8 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" ) +const emailClaim = "email" + var ( // ErrNotImplemented is returned when a provider did not override a default // implementation method that doesn't have sensible defaults