git.iko.soy/typeable/oauth2-proxy
on master help sign in
Move generic OIDC functionality to be available to all providers
commit74ac4274c6643cb205336a86f6ef40d16805c59a authorNick Meves <nick.meves@greenhouse.io> dateFri 27 Nov 2020, 16:17 (6 years ago) parenta187743
6 files changed+551−283
pkg/validation/options.go+4−1 providers/oidc.go+1−97 providers/oidc_test.go+22−184 providers/provider_data.go+108−1 providers/provider_data_test.go+414 providers/provider_default.go+2
pkg/validation/options.go+4−1
@@ -233,7 +233,10 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
233233 p.ValidateURL, msgs = parseURL(o.ValidateURL, "validate", msgs)
234234 p.ProtectedResource, msgs = parseURL(o.ProtectedResource, "resource", msgs)
235235
236− // Make the OIDC Verifier accessible to all providers that can support it
236+ // Make the OIDC options available to all providers that support it
237+ p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
238+ p.EmailClaim = o.UserIDClaim
239+ p.GroupsClaim = o.OIDCGroupsClaim
237240 p.Verifier = o.GetOIDCVerifier()
238241
239242 p.SetAllowedGroups(o.AllowedGroups)
providers/oidc.go+1−97
@@ -4,26 +4,17 @@ import (
44 "context"
55 "errors"
66 "fmt"
7− "reflect"
8− "strings"
97 "time"
108
11− "github.com/coreos/go-oidc"
129 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
1310 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
1411 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
1512 "golang.org/x/oauth2"
1613)
1714
18−const emailClaim = "email"
19−
2015// OIDCProvider represents an OIDC based Identity Provider
2116type OIDCProvider struct {
2217 *ProviderData
23−
24− AllowUnverifiedEmail bool
25− EmailClaim string
26− GroupsClaim string
2718}
2819
2920// NewOIDCProvider initiates a new OIDCProvider
@@ -213,7 +204,7 @@ func (p *OIDCProvider) CreateSessionFromToken(ctx context.Context, token string)
213204// createSession takes an oauth2.Token and creates a SessionState from it.
214205// It alters behavior if called from Redeem vs Refresh
215206func (p *OIDCProvider) createSession(ctx context.Context, token *oauth2.Token, refresh bool) (*sessions.SessionState, error) {
216− idToken, err := p.findVerifiedIDToken(ctx, token)
207+ idToken, err := p.verifyIDToken(ctx, token)
217208 if err != nil {
218209 return nil, fmt.Errorf("could not verify id_token: %v", err)
219210 }
@@ -238,90 +229,3 @@ func (p *OIDCProvider) createSession(ctx context.Context, token *oauth2.Token, r
238229
239230 return ss, nil
240231}
241−
242−func (p *OIDCProvider) findVerifiedIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
243− rawIDToken := getIDToken(token)
244− if strings.TrimSpace(rawIDToken) != "" {
245− return p.Verifier.Verify(ctx, rawIDToken)
246− }
247− return nil, nil
248−}
249−
250−// buildSessionFromClaims uses IDToken claims to populate a fresh SessionState
251−// with non-Token related fields.
252−func (p *OIDCProvider) buildSessionFromClaims(idToken *oidc.IDToken) (*sessions.SessionState, error) {
253− ss := &sessions.SessionState{}
254−
255− if idToken == nil {
256− return ss, nil
257− }
258−
259− claims, err := p.getClaims(idToken)
260− if err != nil {
261− return nil, fmt.Errorf("couldn't extract claims from id_token (%v)", err)
262− }
263−
264− ss.User = claims.Subject
265− ss.Email = claims.Email
266− ss.Groups = claims.Groups
267−
268− // TODO (@NickMeves) Deprecate for dynamic claim to session mapping
269− if pref, ok := claims.rawClaims["preferred_username"].(string); ok {
270− ss.PreferredUsername = pref
271− }
272−
273− verifyEmail := (p.EmailClaim == emailClaim) && !p.AllowUnverifiedEmail
274− if verifyEmail && claims.Verified != nil && !*claims.Verified {
275− return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email)
276− }
277−
278− return ss, nil
279−}
280−
281−type OIDCClaims struct {
282− Subject string `json:"sub"`
283− Email string `json:"-"`
284− Groups []string `json:"-"`
285− Verified *bool `json:"email_verified"`
286−
287− rawClaims map[string]interface{}
288−}
289−
290−// getClaims extracts IDToken claims into an OIDCClaims
291−func (p *OIDCProvider) getClaims(idToken *oidc.IDToken) (*OIDCClaims, error) {
292− claims := &OIDCClaims{}
293−
294− // Extract default claims.
295− if err := idToken.Claims(&claims); err != nil {
296− return nil, fmt.Errorf("failed to parse default id_token claims: %v", err)
297− }
298− // Extract custom claims.
299− if err := idToken.Claims(&claims.rawClaims); err != nil {
300− return nil, fmt.Errorf("failed to parse all id_token claims: %v", err)
301− }
302−
303− email := claims.rawClaims[p.EmailClaim]
304− if email != nil {
305− claims.Email = fmt.Sprint(email)
306− }
307− claims.Groups = p.extractGroups(claims.rawClaims)
308−
309− return claims, nil
310−}
311−
312−func (p *OIDCProvider) extractGroups(claims map[string]interface{}) []string {
313− groups := []string{}
314− rawGroups, ok := claims[p.GroupsClaim].([]interface{})
315− if rawGroups != nil && ok {
316− for _, rawGroup := range rawGroups {
317− formattedGroup, err := formatGroup(rawGroup)
318− if err != nil {
319− logger.Errorf("Warning: unable to format group of type %s with error %s",
320− reflect.TypeOf(rawGroup), err)
321− continue
322− }
323− groups = append(groups, formattedGroup)
324− }
325− }
326− return groups
327−}
providers/oidc_test.go+22−184
@@ -2,42 +2,18 @@ package providers
22
33import (
44 "context"
5− "crypto/rand"
6− "crypto/rsa"
7− "encoding/base64"
85 "encoding/json"
96 "errors"
10− "fmt"
117 "net/http"
128 "net/http/httptest"
139 "net/url"
14− "strings"
1510 "testing"
16− "time"
1711
1812 "github.com/coreos/go-oidc"
19− "github.com/dgrijalva/jwt-go"
20− "github.com/stretchr/testify/assert"
21− "golang.org/x/oauth2"
22−
2313 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
14+ "github.com/stretchr/testify/assert"
2415)
2516
26−const accessToken = "access_token"
27−const refreshToken = "refresh_token"
28−const clientID = "https://test.myapp.com"
29−const secret = "secret"
30−
31−type idTokenClaims struct {
32− Name string `json:"name,omitempty"`
33− Email string `json:"email,omitempty"`
34− Phone string `json:"phone_number,omitempty"`
35− Picture string `json:"picture,omitempty"`
36− Groups interface{} `json:"groups,omitempty"`
37− OtherGroups interface{} `json:"other_groups,omitempty"`
38− jwt.StandardClaims
39−}
40−
4117type redeemTokenResponse struct {
4218 AccessToken string `json:"access_token"`
4319 RefreshToken string `json:"refresh_token"`
@@ -46,88 +22,12 @@ type redeemTokenResponse struct {
4622 IDToken string `json:"id_token,omitempty"`
4723}
4824
49−var defaultIDToken idTokenClaims = idTokenClaims{
50− "Jane Dobbs",
51− "janed@me.com",
52− "+4798765432",
53− "http://mugbook.com/janed/me.jpg",
54− []string{"test:a", "test:b"},
55− []string{"test:c", "test:d"},
56− jwt.StandardClaims{
57− Audience: "https://test.myapp.com",
58− ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
59− Id: "id-some-id",
60− IssuedAt: time.Now().Unix(),
61− Issuer: "https://issuer.example.com",
62− NotBefore: 0,
63− Subject: "123456789",
64− },
65−}
66−
67−var customGroupClaimIDToken idTokenClaims = idTokenClaims{
68− "Jane Dobbs",
69− "janed@me.com",
70− "+4798765432",
71− "http://mugbook.com/janed/me.jpg",
72− []map[string]interface{}{
73− {
74− "groupId": "Admin Group Id",
75− "roles": []string{"Admin"},
76− },
77− },
78− []string{"test:c", "test:d"},
79− jwt.StandardClaims{
80− Audience: "https://test.myapp.com",
81− ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
82− Id: "id-some-id",
83− IssuedAt: time.Now().Unix(),
84− Issuer: "https://issuer.example.com",
85− NotBefore: 0,
86− Subject: "123456789",
87− },
88−}
89−
90−var minimalIDToken idTokenClaims = idTokenClaims{
91− "",
92− "",
93− "",
94− "",
95− []string{},
96− []string{},
97− jwt.StandardClaims{
98− Audience: "https://test.myapp.com",
99− ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
100− Id: "id-some-id",
101− IssuedAt: time.Now().Unix(),
102− Issuer: "https://issuer.example.com",
103− NotBefore: 0,
104− Subject: "minimal",
105− },
106−}
107−
108−type fakeKeySetStub struct{}
109−
110−func (fakeKeySetStub) VerifySignature(_ context.Context, jwt string) (payload []byte, err error) {
111− decodeString, err := base64.RawURLEncoding.DecodeString(strings.Split(jwt, ".")[1])
112− if err != nil {
113− return nil, err
114− }
115− tokenClaims := &idTokenClaims{}
116− err = json.Unmarshal(decodeString, tokenClaims)
117−
118− if err != nil || tokenClaims.Id == "this-id-fails-validation" {
119− return nil, fmt.Errorf("the validation failed for subject [%v]", tokenClaims.Subject)
120− }
121−
122− return decodeString, err
123−}
124−
12525func newOIDCProvider(serverURL *url.URL) *OIDCProvider {
12626
12727 providerData := &ProviderData{
12828 ProviderName: "oidc",
129− ClientID: clientID,
130− ClientSecret: secret,
29+ ClientID: oidcClientID,
30+ ClientSecret: oidcSecret,
13131 LoginURL: &url.URL{
13232 Scheme: serverURL.Scheme,
13333 Host: serverURL.Host,
@@ -144,18 +44,17 @@ func newOIDCProvider(serverURL *url.URL) *OIDCProvider {
14444 Scheme: serverURL.Scheme,
14545 Host: serverURL.Host,
14646 Path: "/api"},
147− Scope: "openid profile offline_access",
47+ Scope: "openid profile offline_access",
48+ EmailClaim: "email",
49+ GroupsClaim: "groups",
14850 Verifier: oidc.NewVerifier(
149− "https://issuer.example.com",
150− fakeKeySetStub{},
151− &oidc.Config{ClientID: clientID},
51+ oidcIssuer,
52+ mockJWKS{},
53+ &oidc.Config{ClientID: oidcClientID},
15254 ),
15355 }
15456
155− p := &OIDCProvider{
156− ProviderData: providerData,
157− EmailClaim: "email",
158− }
57+ p := &OIDCProvider{ProviderData: providerData}
15958
16059 return p
16160}
@@ -169,21 +68,6 @@ func newOIDCServer(body []byte) (*url.URL, *httptest.Server) {
16968 return u, s
17069}
17170
172−func newSignedTestIDToken(tokenClaims idTokenClaims) (string, error) {
173− key, _ := rsa.GenerateKey(rand.Reader, 2048)
174− standardClaims := jwt.NewWithClaims(jwt.SigningMethodRS256, tokenClaims)
175− return standardClaims.SignedString(key)
176−}
177−
178−func newOauth2Token() *oauth2.Token {
179− return &oauth2.Token{
180− AccessToken: accessToken,
181− TokenType: "Bearer",
182− RefreshToken: refreshToken,
183− Expiry: time.Time{}.Add(time.Duration(5) * time.Second),
184− }
185−}
186−
18771func newTestSetup(body []byte) (*httptest.Server, *OIDCProvider) {
18872 redeemURL, server := newOIDCServer(body)
18973 provider := newOIDCProvider(redeemURL)
@@ -234,12 +118,6 @@ func TestOIDCProviderRedeem_custom_userid(t *testing.T) {
234118}
235119
236120func TestOIDCProvider_EnrichSession(t *testing.T) {
237− const (
238− idToken = "Unchanged ID Token"
239− accessToken = "Unchanged Access Token"
240− refreshToken = "Unchanged Refresh Token"
241− )
242−
243121 testCases := map[string]struct {
244122 ExistingSession *sessions.SessionState
245123 EmailClaim string
@@ -550,8 +428,6 @@ func TestOIDCProviderRefreshSessionIfNeededWithIdToken(t *testing.T) {
550428}
551429
552430func TestOIDCProviderCreateSessionFromToken(t *testing.T) {
553− const profileURLEmail = "janed@me.com"
554−
555431 testCases := map[string]struct {
556432 IDToken idTokenClaims
557433 GroupsClaim string
@@ -562,36 +438,35 @@ func TestOIDCProviderCreateSessionFromToken(t *testing.T) {
562438 "Default IDToken": {
563439 IDToken: defaultIDToken,
564440 GroupsClaim: "groups",
565− ExpectedUser: defaultIDToken.Subject,
566− ExpectedEmail: defaultIDToken.Email,
441+ ExpectedUser: "123456789",
442+ ExpectedEmail: "janed@me.com",
567443 ExpectedGroups: []string{"test:a", "test:b"},
568444 },
569445 "Minimal IDToken with no email claim": {
570446 IDToken: minimalIDToken,
571447 GroupsClaim: "groups",
572− ExpectedUser: minimalIDToken.Subject,
573− ExpectedEmail: minimalIDToken.Subject,
448+ ExpectedUser: "123456789",
449+ ExpectedEmail: "123456789",
574450 ExpectedGroups: []string{},
575451 },
576452 "Custom Groups Claim": {
577453 IDToken: defaultIDToken,
578− GroupsClaim: "other_groups",
579− ExpectedUser: defaultIDToken.Subject,
580− ExpectedEmail: defaultIDToken.Email,
454+ GroupsClaim: "roles",
455+ ExpectedUser: "123456789",
456+ ExpectedEmail: "janed@me.com",
581457 ExpectedGroups: []string{"test:c", "test:d"},
582458 },
583− "Custom Groups Claim2": {
584− IDToken: customGroupClaimIDToken,
459+ "Complex Groups Claim": {
460+ IDToken: complexGroupsIDToken,
585461 GroupsClaim: "groups",
586− ExpectedUser: customGroupClaimIDToken.Subject,
587− ExpectedEmail: customGroupClaimIDToken.Email,
462+ ExpectedUser: "123456789",
463+ ExpectedEmail: "complex@claims.com",
588464 ExpectedGroups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"},
589465 },
590466 }
591467 for testName, tc := range testCases {
592468 t.Run(testName, func(t *testing.T) {
593− jsonResp := []byte(fmt.Sprintf(`{"email":"%s"}`, profileURLEmail))
594− server, provider := newTestSetup(jsonResp)
469+ server, provider := newTestSetup([]byte(`{}`))
595470 provider.GroupsClaim = tc.GroupsClaim
596471 defer server.Close()
597472
@@ -610,40 +485,3 @@ func TestOIDCProviderCreateSessionFromToken(t *testing.T) {
610485 })
611486 }
612487}
613−
614−func TestOIDCProvider_findVerifiedIDToken(t *testing.T) {
615−
616− server, provider := newTestSetup([]byte(""))
617−
618− defer server.Close()
619−
620− token := newOauth2Token()
621− signedIDToken, _ := newSignedTestIDToken(defaultIDToken)
622− tokenWithIDToken := token.WithExtra(map[string]interface{}{
623− "id_token": signedIDToken,
624− })
625−
626− verifiedIDToken, err := provider.findVerifiedIDToken(context.Background(), tokenWithIDToken)
627− assert.Equal(t, true, err == nil)
628− if verifiedIDToken == nil {
629− t.Fatal("verifiedIDToken is nil")
630− }
631− assert.Equal(t, defaultIDToken.Issuer, verifiedIDToken.Issuer)
632− assert.Equal(t, defaultIDToken.Subject, verifiedIDToken.Subject)
633−
634− // When the validation fails the response should be nil
635− defaultIDToken.Id = "this-id-fails-validation"
636− signedIDToken, _ = newSignedTestIDToken(defaultIDToken)
637− tokenWithIDToken = token.WithExtra(map[string]interface{}{
638− "id_token": signedIDToken,
639− })
640−
641− verifiedIDToken, err = provider.findVerifiedIDToken(context.Background(), tokenWithIDToken)
642− assert.Equal(t, errors.New("failed to verify signature: the validation failed for subject [123456789]"), err)
643− assert.Equal(t, true, verifiedIDToken == nil)
644−
645− // When there is no id token in the oauth token
646− verifiedIDToken, err = provider.findVerifiedIDToken(context.Background(), newOauth2Token())
647− assert.Equal(t, nil, err)
648− assert.Equal(t, true, verifiedIDToken == nil)
649−}
providers/provider_data.go+108−1
@@ -1,12 +1,18 @@
11package providers
22
33import (
4+ "context"
45 "errors"
6+ "fmt"
57 "io/ioutil"
68 "net/url"
9+ "reflect"
10+ "strings"
711
812 "github.com/coreos/go-oidc"
13+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
914 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
15+ "golang.org/x/oauth2"
1016)
1117
1218// ProviderData contains information required to configure all implementations
@@ -27,7 +33,12 @@ type ProviderData struct {
2733 ClientSecretFile string
2834 Scope string
2935 Prompt string
30− Verifier *oidc.IDTokenVerifier
36+
37+ // Common OIDC options for any OIDC-based providers to consume
38+ AllowUnverifiedEmail bool
39+ EmailClaim string
40+ GroupsClaim string
41+ Verifier *oidc.IDTokenVerifier
3142
3243 // Universal Group authorization data structure
3344 // any provider can set to consume
@@ -94,3 +105,99 @@ func defaultURL(u *url.URL, d *url.URL) *url.URL {
94105 }
95106 return &url.URL{}
96107}
108+
109+// ****************************************************************************
110+// These private OIDC helper methods are available to any providers that are
111+// OIDC compliant
112+// ****************************************************************************
113+
114+// OIDCClaims is a struct to unmarshal the OIDC claims from an ID Token payload
115+type OIDCClaims struct {
116+ Subject string `json:"sub"`
117+ Email string `json:"-"`
118+ Groups []string `json:"-"`
119+ Verified *bool `json:"email_verified"`
120+
121+ raw map[string]interface{}
122+}
123+
124+func (p *ProviderData) verifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
125+ rawIDToken := getIDToken(token)
126+ if strings.TrimSpace(rawIDToken) != "" {
127+ return p.Verifier.Verify(ctx, rawIDToken)
128+ }
129+ return nil, nil
130+}
131+
132+// buildSessionFromClaims uses IDToken claims to populate a fresh SessionState
133+// with non-Token related fields.
134+func (p *ProviderData) buildSessionFromClaims(idToken *oidc.IDToken) (*sessions.SessionState, error) {
135+ ss := &sessions.SessionState{}
136+
137+ if idToken == nil {
138+ return ss, nil
139+ }
140+
141+ claims, err := p.getClaims(idToken)
142+ if err != nil {
143+ return nil, fmt.Errorf("couldn't extract claims from id_token (%v)", err)
144+ }
145+
146+ ss.User = claims.Subject
147+ ss.Email = claims.Email
148+ ss.Groups = claims.Groups
149+
150+ // TODO (@NickMeves) Deprecate for dynamic claim to session mapping
151+ if pref, ok := claims.raw["preferred_username"].(string); ok {
152+ ss.PreferredUsername = pref
153+ }
154+
155+ // `email_verified` must be present and explicitly set to `false` to be
156+ // considered unverified.
157+ verifyEmail := (p.EmailClaim == emailClaim) && !p.AllowUnverifiedEmail
158+ if verifyEmail && claims.Verified != nil && !*claims.Verified {
159+ return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email)
160+ }
161+
162+ return ss, nil
163+}
164+
165+// getClaims extracts IDToken claims into an OIDCClaims
166+func (p *ProviderData) getClaims(idToken *oidc.IDToken) (*OIDCClaims, error) {
167+ claims := &OIDCClaims{}
168+
169+ // Extract default claims.
170+ if err := idToken.Claims(&claims); err != nil {
171+ return nil, fmt.Errorf("failed to parse default id_token claims: %v", err)
172+ }
173+ // Extract custom claims.
174+ if err := idToken.Claims(&claims.raw); err != nil {
175+ return nil, fmt.Errorf("failed to parse all id_token claims: %v", err)
176+ }
177+
178+ email := claims.raw[p.EmailClaim]
179+ if email != nil {
180+ claims.Email = fmt.Sprint(email)
181+ }
182+ claims.Groups = p.extractGroups(claims.raw)
183+
184+ return claims, nil
185+}
186+
187+// extractGroups extracts groups from a claim to a list in a type safe manner
188+func (p *ProviderData) extractGroups(claims map[string]interface{}) []string {
189+ groups := []string{}
190+ rawGroups, ok := claims[p.GroupsClaim].([]interface{})
191+ if rawGroups != nil && ok {
192+ for _, rawGroup := range rawGroups {
193+ formattedGroup, err := formatGroup(rawGroup)
194+ if err != nil {
195+ logger.Errorf("Warning: unable to format group of type %s with error %s",
196+ reflect.TypeOf(rawGroup), err)
197+ continue
198+ }
199+ groups = append(groups, formattedGroup)
200+ }
201+ }
202+ return groups
203+}
providers/provider_data_test.gonew file+414
@@ -0,0 +1,414 @@
1+package providers
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "crypto/rsa"
7+ "encoding/base64"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/coreos/go-oidc"
16+ "github.com/dgrijalva/jwt-go"
17+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
18+ . "github.com/onsi/gomega"
19+ "golang.org/x/oauth2"
20+)
21+
22+const (
23+ idToken = "eyJfoobar123.eyJbaz987.IDToken"
24+ accessToken = "eyJfoobar123.eyJbaz987.AccessToken"
25+ refreshToken = "eyJfoobar123.eyJbaz987.RefreshToken"
26+
27+ oidcIssuer = "https://issuer.example.com"
28+ oidcClientID = "https://test.myapp.com"
29+ oidcSecret = "SuperSecret123456789"
30+
31+ failureTokenID = "this-id-fails-verification"
32+)
33+
34+var (
35+ verified = true
36+ unverified = false
37+
38+ standardClaims = jwt.StandardClaims{
39+ Audience: oidcClientID,
40+ ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
41+ Id: "id-some-id",
42+ IssuedAt: time.Now().Unix(),
43+ Issuer: oidcIssuer,
44+ NotBefore: 0,
45+ Subject: "123456789",
46+ }
47+
48+ defaultIDToken = idTokenClaims{
49+ Name: "Jane Dobbs",
50+ Email: "janed@me.com",
51+ Phone: "+4798765432",
52+ Picture: "http://mugbook.com/janed/me.jpg",
53+ Groups: []string{"test:a", "test:b"},
54+ Roles: []string{"test:c", "test:d"},
55+ Verified: &verified,
56+ StandardClaims: standardClaims,
57+ }
58+
59+ complexGroupsIDToken = idTokenClaims{
60+ Name: "Complex Claim",
61+ Email: "complex@claims.com",
62+ Phone: "+5439871234",
63+ Picture: "http://mugbook.com/complex/claims.jpg",
64+ Groups: []map[string]interface{}{
65+ {
66+ "groupId": "Admin Group Id",
67+ "roles": []string{"Admin"},
68+ },
69+ },
70+ Roles: []string{"test:simple", "test:roles"},
71+ Verified: &verified,
72+ StandardClaims: standardClaims,
73+ }
74+
75+ unverifiedIDToken = idTokenClaims{
76+ Name: "Mystery Man",
77+ Email: "unverified@email.com",
78+ Phone: "+4025205729",
79+ Picture: "http://mugbook.com/unverified/email.jpg",
80+ Groups: []string{"test:a", "test:b"},
81+ Roles: []string{"test:c", "test:d"},
82+ Verified: &unverified,
83+ StandardClaims: standardClaims,
84+ }
85+
86+ minimalIDToken = idTokenClaims{
87+ StandardClaims: standardClaims,
88+ }
89+)
90+
91+type idTokenClaims struct {
92+ Name string `json:"preferred_username,omitempty"`
93+ Email string `json:"email,omitempty"`
94+ Phone string `json:"phone_number,omitempty"`
95+ Picture string `json:"picture,omitempty"`
96+ Groups interface{} `json:"groups,omitempty"`
97+ Roles interface{} `json:"roles,omitempty"`
98+ Verified *bool `json:"email_verified,omitempty"`
99+ jwt.StandardClaims
100+}
101+
102+type mockJWKS struct{}
103+
104+func (mockJWKS) VerifySignature(_ context.Context, jwt string) ([]byte, error) {
105+ decoded, err := base64.RawURLEncoding.DecodeString(strings.Split(jwt, ".")[1])
106+ if err != nil {
107+ return nil, err
108+ }
109+
110+ tokenClaims := &idTokenClaims{}
111+ err = json.Unmarshal(decoded, tokenClaims)
112+ if err != nil || tokenClaims.Id == failureTokenID {
113+ return nil, fmt.Errorf("the validation failed for subject [%v]", tokenClaims.Subject)
114+ }
115+
116+ return decoded, nil
117+}
118+
119+func newSignedTestIDToken(tokenClaims idTokenClaims) (string, error) {
120+ key, _ := rsa.GenerateKey(rand.Reader, 2048)
121+ standardClaims := jwt.NewWithClaims(jwt.SigningMethodRS256, tokenClaims)
122+ return standardClaims.SignedString(key)
123+}
124+
125+func newTestOauth2Token() *oauth2.Token {
126+ return &oauth2.Token{
127+ AccessToken: accessToken,
128+ TokenType: "Bearer",
129+ RefreshToken: refreshToken,
130+ Expiry: time.Time{}.Add(time.Duration(5) * time.Second),
131+ }
132+}
133+
134+func TestProviderData_verifyIDToken(t *testing.T) {
135+ failureIDToken := defaultIDToken
136+ failureIDToken.Id = failureTokenID
137+
138+ testCases := map[string]struct {
139+ IDToken *idTokenClaims
140+ ExpectIDToken bool
141+ ExpectedError error
142+ }{
143+ "Valid ID Token": {
144+ IDToken: &defaultIDToken,
145+ ExpectIDToken: true,
146+ ExpectedError: nil,
147+ },
148+ "Invalid ID Token": {
149+ IDToken: &failureIDToken,
150+ ExpectIDToken: false,
151+ ExpectedError: errors.New("failed to verify signature: the validation failed for subject [123456789]"),
152+ },
153+ "Missing ID Token": {
154+ IDToken: nil,
155+ ExpectIDToken: false,
156+ ExpectedError: nil,
157+ },
158+ }
159+
160+ for testName, tc := range testCases {
161+ t.Run(testName, func(t *testing.T) {
162+ g := NewWithT(t)
163+
164+ token := newTestOauth2Token()
165+ if tc.IDToken != nil {
166+ idToken, err := newSignedTestIDToken(*tc.IDToken)
167+ g.Expect(err).ToNot(HaveOccurred())
168+ token = token.WithExtra(map[string]interface{}{
169+ "id_token": idToken,
170+ })
171+ }
172+
173+ provider := &ProviderData{
174+ Verifier: oidc.NewVerifier(
175+ oidcIssuer,
176+ mockJWKS{},
177+ &oidc.Config{ClientID: oidcClientID},
178+ ),
179+ }
180+ verified, err := provider.verifyIDToken(context.Background(), token)
181+ if err != nil {
182+ g.Expect(err).To(Equal(tc.ExpectedError))
183+ }
184+
185+ if tc.ExpectIDToken {
186+ g.Expect(verified).ToNot(BeNil())
187+ g.Expect(*verified).To(BeAssignableToTypeOf(oidc.IDToken{}))
188+ } else {
189+ g.Expect(verified).To(BeNil())
190+ }
191+ })
192+ }
193+}
194+
195+func TestProviderData_buildSessionFromClaims(t *testing.T) {
196+ testCases := map[string]struct {
197+ IDToken idTokenClaims
198+ AllowUnverified bool
199+ EmailClaim string
200+ GroupsClaim string
201+ ExpectedError error
202+ ExpectedSession *sessions.SessionState
203+ }{
204+ "Standard": {
205+ IDToken: defaultIDToken,
206+ AllowUnverified: false,
207+ EmailClaim: "email",
208+ GroupsClaim: "groups",
209+ ExpectedSession: &sessions.SessionState{
210+ User: "123456789",
211+ Email: "janed@me.com",
212+ Groups: []string{"test:a", "test:b"},
213+ PreferredUsername: "Jane Dobbs",
214+ },
215+ },
216+ "Unverified Denied": {
217+ IDToken: unverifiedIDToken,
218+ AllowUnverified: false,
219+ EmailClaim: "email",
220+ GroupsClaim: "groups",
221+ ExpectedError: errors.New("email in id_token (unverified@email.com) isn't verified"),
222+ },
223+ "Unverified Allowed": {
224+ IDToken: unverifiedIDToken,
225+ AllowUnverified: true,
226+ EmailClaim: "email",
227+ GroupsClaim: "groups",
228+ ExpectedSession: &sessions.SessionState{
229+ User: "123456789",
230+ Email: "unverified@email.com",
231+ Groups: []string{"test:a", "test:b"},
232+ PreferredUsername: "Mystery Man",
233+ },
234+ },
235+ "Complex Groups": {
236+ IDToken: complexGroupsIDToken,
237+ AllowUnverified: true,
238+ EmailClaim: "email",
239+ GroupsClaim: "groups",
240+ ExpectedSession: &sessions.SessionState{
241+ User: "123456789",
242+ Email: "complex@claims.com",
243+ Groups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"},
244+ PreferredUsername: "Complex Claim",
245+ },
246+ },
247+ "Email Claim Switched": {
248+ IDToken: unverifiedIDToken,
249+ AllowUnverified: true,
250+ EmailClaim: "phone_number",
251+ GroupsClaim: "groups",
252+ ExpectedSession: &sessions.SessionState{
253+ User: "123456789",
254+ Email: "+4025205729",
255+ Groups: []string{"test:a", "test:b"},
256+ PreferredUsername: "Mystery Man",
257+ },
258+ },
259+ "Email Claim Switched to Non String": {
260+ IDToken: unverifiedIDToken,
261+ AllowUnverified: true,
262+ EmailClaim: "roles",
263+ GroupsClaim: "groups",
264+ ExpectedSession: &sessions.SessionState{
265+ User: "123456789",
266+ Email: "[test:c test:d]",
267+ Groups: []string{"test:a", "test:b"},
268+ PreferredUsername: "Mystery Man",
269+ },
270+ },
271+ "Email Claim Non Existent": {
272+ IDToken: unverifiedIDToken,
273+ AllowUnverified: true,
274+ EmailClaim: "aksjdfhjksadh",
275+ GroupsClaim: "groups",
276+ ExpectedSession: &sessions.SessionState{
277+ User: "123456789",
278+ Email: "",
279+ Groups: []string{"test:a", "test:b"},
280+ PreferredUsername: "Mystery Man",
281+ },
282+ },
283+ "Groups Claim Switched": {
284+ IDToken: defaultIDToken,
285+ AllowUnverified: false,
286+ EmailClaim: "email",
287+ GroupsClaim: "roles",
288+ ExpectedSession: &sessions.SessionState{
289+ User: "123456789",
290+ Email: "janed@me.com",
291+ Groups: []string{"test:c", "test:d"},
292+ PreferredUsername: "Jane Dobbs",
293+ },
294+ },
295+ "Groups Claim Non Existent": {
296+ IDToken: defaultIDToken,
297+ AllowUnverified: false,
298+ EmailClaim: "email",
299+ GroupsClaim: "alskdjfsalkdjf",
300+ ExpectedSession: &sessions.SessionState{
301+ User: "123456789",
302+ Email: "janed@me.com",
303+ Groups: []string{},
304+ PreferredUsername: "Jane Dobbs",
305+ },
306+ },
307+ }
308+ for testName, tc := range testCases {
309+ t.Run(testName, func(t *testing.T) {
310+ g := NewWithT(t)
311+
312+ provider := &ProviderData{
313+ Verifier: oidc.NewVerifier(
314+ oidcIssuer,
315+ mockJWKS{},
316+ &oidc.Config{ClientID: oidcClientID},
317+ ),
318+ }
319+ provider.AllowUnverifiedEmail = tc.AllowUnverified
320+ provider.EmailClaim = tc.EmailClaim
321+ provider.GroupsClaim = tc.GroupsClaim
322+
323+ rawIDToken, err := newSignedTestIDToken(tc.IDToken)
324+ g.Expect(err).ToNot(HaveOccurred())
325+
326+ idToken, err := provider.Verifier.Verify(context.Background(), rawIDToken)
327+ g.Expect(err).ToNot(HaveOccurred())
328+
329+ ss, err := provider.buildSessionFromClaims(idToken)
330+ if err != nil {
331+ g.Expect(err).To(Equal(tc.ExpectedError))
332+ }
333+ if ss != nil {
334+ g.Expect(ss).To(Equal(tc.ExpectedSession))
335+ }
336+ })
337+ }
338+}
339+
340+func TestProviderData_extractGroups(t *testing.T) {
341+ testCases := map[string]struct {
342+ Claims map[string]interface{}
343+ GroupsClaim string
344+ ExpectedGroups []string
345+ }{
346+ "Standard String Groups": {
347+ Claims: map[string]interface{}{
348+ "email": "this@does.not.matter.com",
349+ "groups": []interface{}{"three", "string", "groups"},
350+ },
351+ GroupsClaim: "groups",
352+ ExpectedGroups: []string{"three", "string", "groups"},
353+ },
354+ "Different Claim Name": {
355+ Claims: map[string]interface{}{
356+ "email": "this@does.not.matter.com",
357+ "roles": []interface{}{"three", "string", "roles"},
358+ },
359+ GroupsClaim: "roles",
360+ ExpectedGroups: []string{"three", "string", "roles"},
361+ },
362+ "Numeric Groups": {
363+ Claims: map[string]interface{}{
364+ "email": "this@does.not.matter.com",
365+ "groups": []interface{}{1, 2, 3},
366+ },
367+ GroupsClaim: "groups",
368+ ExpectedGroups: []string{"1", "2", "3"},
369+ },
370+ "Complex Groups": {
371+ Claims: map[string]interface{}{
372+ "email": "this@does.not.matter.com",
373+ "groups": []interface{}{
374+ map[string]interface{}{
375+ "groupId": "Admin Group Id",
376+ "roles": []string{"Admin"},
377+ },
378+ 12345,
379+ "Just::A::String",
380+ },
381+ },
382+ GroupsClaim: "groups",
383+ ExpectedGroups: []string{
384+ "{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}",
385+ "12345",
386+ "Just::A::String",
387+ },
388+ },
389+ "Missing Groups": {
390+ Claims: map[string]interface{}{
391+ "email": "this@does.not.matter.com",
392+ },
393+ GroupsClaim: "groups",
394+ ExpectedGroups: []string{},
395+ },
396+ }
397+ for testName, tc := range testCases {
398+ t.Run(testName, func(t *testing.T) {
399+ g := NewWithT(t)
400+
401+ provider := &ProviderData{
402+ Verifier: oidc.NewVerifier(
403+ oidcIssuer,
404+ mockJWKS{},
405+ &oidc.Config{ClientID: oidcClientID},
406+ ),
407+ }
408+ provider.GroupsClaim = tc.GroupsClaim
409+
410+ groups := provider.extractGroups(tc.Claims)
411+ g.Expect(groups).To(Equal(tc.ExpectedGroups))
412+ })
413+ }
414+}
providers/provider_default.go+2
@@ -13,6 +13,8 @@ import (
1313 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
1414)
1515
16+const emailClaim = "email"
17+
1618var (
1719 // ErrNotImplemented is returned when a provider did not override a default
1820 // implementation method that doesn't have sensible defaults