git.iko.soy/typeable/oauth2-proxy
on master help sign in
Refactor OIDC to EnrichSession
commita1877434b21984b1a57839c2c31dca505bb7884e authorNick Meves <nick.meves@greenhouse.io> dateThu 26 Nov 2020, 19:00 (6 years ago) parent4fda907
5 files changed+478−186
pkg/validation/options.go+1−1 providers/oidc.go+153−153 providers/oidc_test.go+253−31 providers/util.go+23 providers/util_test.go+48−1
pkg/validation/options.go+1−1
@@ -274,7 +274,7 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
274274 p.SetRepository(o.BitbucketRepository)
275275 case *providers.OIDCProvider:
276276 p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
277− p.UserIDClaim = o.UserIDClaim
277+ p.EmailClaim = o.UserIDClaim
278278 p.GroupsClaim = o.OIDCGroupsClaim
279279 if p.Verifier == nil {
280280 msgs = append(msgs, "oidc provider requires an oidc issuer URL")
providers/oidc.go+153−153
@@ -2,18 +2,17 @@ package providers
22
33import (
44 "context"
5− "encoding/json"
5+ "errors"
66 "fmt"
77 "reflect"
88 "strings"
99 "time"
1010
11− oidc "github.com/coreos/go-oidc"
12− "golang.org/x/oauth2"
13−
11+ "github.com/coreos/go-oidc"
1412 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
1513 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
1614 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
15+ "golang.org/x/oauth2"
1716)
1817
1918const emailClaim = "email"
@@ -23,7 +22,7 @@ type OIDCProvider struct {
2322 *ProviderData
2423
2524 AllowUnverifiedEmail bool
26− UserIDClaim string
25+ EmailClaim string
2726 GroupsClaim string
2827}
2928
@@ -36,10 +35,10 @@ func NewOIDCProvider(p *ProviderData) *OIDCProvider {
3635var _ Provider = (*OIDCProvider)(nil)
3736
3837// Redeem exchanges the OAuth2 authentication token for an ID token
39−func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code string) (s *sessions.SessionState, err error) {
38+func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code string) (*sessions.SessionState, error) {
4039 clientSecret, err := p.GetClientSecret()
4140 if err != nil {
42− return
41+ return nil, err
4342 }
4443
4544 c := oauth2.Config{
@@ -52,23 +51,74 @@ func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code string) (s
5251 }
5352 token, err := c.Exchange(ctx, code)
5453 if err != nil {
55− return nil, fmt.Errorf("token exchange: %v", err)
54+ return nil, fmt.Errorf("token exchange failure: %v", err)
5655 }
5756
58− // in the initial exchange the id token is mandatory
59− idToken, err := p.findVerifiedIDToken(ctx, token)
60− if err != nil {
61− return nil, fmt.Errorf("could not verify id_token: %v", err)
62− } else if idToken == nil {
63− return nil, fmt.Errorf("token response did not contain an id_token")
57+ return p.createSession(ctx, token, false)
58+}
59+
60+// EnrichSessionState is called after Redeem to allow providers to enrich session fields
61+// such as User, Email, Groups with provider specific API calls.
62+func (p *OIDCProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
63+ if p.ProfileURL.String() == "" {
64+ if s.Email == "" {
65+ return errors.New("id_token did not contain an email and profileURL is not defined")
66+ }
67+ return nil
68+ }
69+
70+ // Try to get missing emails or groups from a profileURL
71+ if s.Email == "" || len(s.Groups) == 0 {
72+ err := p.callProfileURL(ctx, s)
73+ if err != nil {
74+ logger.Errorf("Warning: Profile URL request failed: %v", err)
75+ }
76+ }
77+
78+ // If a mandatory email wasn't set, error at this point.
79+ if s.Email == "" {
80+ return errors.New("neither the id_token nor the profileURL set an email")
6481 }
82+ return nil
83+}
6584
66− s, err = p.createSessionState(ctx, token, idToken)
85+// callProfileURL enriches a session's Email & Groups via the JSON response of
86+// an OIDC profile URL
87+func (p *OIDCProvider) callProfileURL(ctx context.Context, s *sessions.SessionState) error {
88+ respJSON, err := requests.New(p.ProfileURL.String()).
89+ WithContext(ctx).
90+ WithHeaders(makeOIDCHeader(s.AccessToken)).
91+ Do().
92+ UnmarshalJSON()
6793 if err != nil {
68− return nil, fmt.Errorf("unable to update session: %v", err)
94+ return err
95+ }
96+
97+ email, err := respJSON.Get(p.EmailClaim).String()
98+ if err == nil && s.Email == "" {
99+ s.Email = email
100+ }
101+
102+ // Handle array & singleton groups cases
103+ if len(s.Groups) == 0 {
104+ groups, err := respJSON.Get(p.GroupsClaim).StringArray()
105+ if err == nil {
106+ s.Groups = groups
107+ } else {
108+ group, err := respJSON.Get(p.GroupsClaim).String()
109+ if err == nil {
110+ s.Groups = []string{group}
111+ }
112+ }
69113 }
70114
71− return
115+ return nil
116+}
117+
118+// ValidateSessionState checks that the session's IDToken is still valid
119+func (p *OIDCProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
120+ _, err := p.Verifier.Verify(ctx, s.IDToken)
121+ return err == nil
72122}
73123
74124// RefreshSessionIfNeeded checks if the session has expired and uses the
@@ -83,14 +133,16 @@ func (p *OIDCProvider) RefreshSessionIfNeeded(ctx context.Context, s *sessions.S
83133 return false, fmt.Errorf("unable to redeem refresh token: %v", err)
84134 }
85135
86− fmt.Printf("refreshed access token %s (expired on %s)\n", s, s.ExpiresOn)
136+ logger.Printf("refreshed session: %s", s)
87137 return true, nil
88138}
89139
90−func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) (err error) {
140+// redeemRefreshToken uses a RefreshToken with the RedeemURL to refresh the
141+// Access Token and (probably) the ID Token.
142+func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) error {
91143 clientSecret, err := p.GetClientSecret()
92144 if err != nil {
93− return
145+ return err
94146 }
95147
96148 c := oauth2.Config{
@@ -109,19 +161,14 @@ func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sessi
109161 return fmt.Errorf("failed to get token: %v", err)
110162 }
111163
112− // in the token refresh response the id_token is optional
113− idToken, err := p.findVerifiedIDToken(ctx, token)
114− if err != nil {
115− return fmt.Errorf("unable to extract id_token from response: %v", err)
116− }
117−
118− newSession, err := p.createSessionState(ctx, token, idToken)
164+ newSession, err := p.createSession(ctx, token, true)
119165 if err != nil {
120166 return fmt.Errorf("unable create new session state from response: %v", err)
121167 }
122168
123− // It's possible that if the refresh token isn't in the token response the session will not contain an id token
124− // if it doesn't it's probably better to retain the old one
169+ // It's possible that if the refresh token isn't in the token response the
170+ // session will not contain an id token.
171+ // If it doesn't it's probably better to retain the old one
125172 if newSession.IDToken != "" {
126173 s.IDToken = newSession.IDToken
127174 s.Email = newSession.Email
@@ -135,102 +182,113 @@ func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sessi
135182 s.CreatedAt = newSession.CreatedAt
136183 s.ExpiresOn = newSession.ExpiresOn
137184
138− return
185+ return nil
139186}
140187
141−func (p *OIDCProvider) findVerifiedIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
142−
143− getIDToken := func() (string, bool) {
144− rawIDToken, _ := token.Extra("id_token").(string)
145− return rawIDToken, len(strings.TrimSpace(rawIDToken)) > 0
188+// CreateSessionFromToken converts Bearer IDTokens into sessions
189+func (p *OIDCProvider) CreateSessionFromToken(ctx context.Context, token string) (*sessions.SessionState, error) {
190+ idToken, err := p.Verifier.Verify(ctx, token)
191+ if err != nil {
192+ return nil, err
146193 }
147194
148− if rawIDToken, present := getIDToken(); present {
149− verifiedIDToken, err := p.Verifier.Verify(ctx, rawIDToken)
150− return verifiedIDToken, err
195+ ss, err := p.buildSessionFromClaims(idToken)
196+ if err != nil {
197+ return nil, err
151198 }
152− return nil, nil
153−}
154−
155−func (p *OIDCProvider) createSessionState(ctx context.Context, token *oauth2.Token, idToken *oidc.IDToken) (*sessions.SessionState, error) {
156−
157− var newSession *sessions.SessionState
158199
159− if idToken == nil {
160− newSession = &sessions.SessionState{}
161− } else {
162− var err error
163− newSession, err = p.createSessionStateInternal(ctx, idToken, token)
164− if err != nil {
165− return nil, err
166− }
200+ // Allow empty Email in Bearer case since we can't hit the ProfileURL
201+ if ss.Email == "" {
202+ ss.Email = ss.User
167203 }
168204
169− created := time.Now()
170− newSession.AccessToken = token.AccessToken
171− newSession.RefreshToken = token.RefreshToken
172− newSession.CreatedAt = &created
173− newSession.ExpiresOn = &token.Expiry
174− return newSession, nil
205+ ss.AccessToken = token
206+ ss.IDToken = token
207+ ss.RefreshToken = ""
208+ ss.ExpiresOn = &idToken.Expiry
209+
210+ return ss, nil
175211}
176212
177−func (p *OIDCProvider) CreateSessionFromToken(ctx context.Context, token string) (*sessions.SessionState, error) {
178− idToken, err := p.Verifier.Verify(ctx, token)
213+// createSession takes an oauth2.Token and creates a SessionState from it.
214+// It alters behavior if called from Redeem vs Refresh
215+func (p *OIDCProvider) createSession(ctx context.Context, token *oauth2.Token, refresh bool) (*sessions.SessionState, error) {
216+ idToken, err := p.findVerifiedIDToken(ctx, token)
179217 if err != nil {
180− return nil, err
218+ return nil, fmt.Errorf("could not verify id_token: %v", err)
219+ }
220+
221+ // IDToken is mandatory in Redeem but optional in Refresh
222+ if idToken == nil && !refresh {
223+ return nil, errors.New("token response did not contain an id_token")
181224 }
182225
183− newSession, err := p.createSessionStateInternal(ctx, idToken, nil)
226+ ss, err := p.buildSessionFromClaims(idToken)
184227 if err != nil {
185228 return nil, err
186229 }
187230
188− newSession.AccessToken = token
189− newSession.IDToken = token
190− newSession.RefreshToken = ""
191− newSession.ExpiresOn = &idToken.Expiry
231+ ss.AccessToken = token.AccessToken
232+ ss.RefreshToken = token.RefreshToken
233+ ss.IDToken = getIDToken(token)
192234
193− return newSession, nil
235+ created := time.Now()
236+ ss.CreatedAt = &created
237+ ss.ExpiresOn = &token.Expiry
238+
239+ return ss, nil
194240}
195241
196−func (p *OIDCProvider) createSessionStateInternal(ctx context.Context, idToken *oidc.IDToken, token *oauth2.Token) (*sessions.SessionState, error) {
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+}
197249
198− newSession := &sessions.SessionState{}
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{}
199254
200255 if idToken == nil {
201− return newSession, nil
256+ return ss, nil
202257 }
203258
204− claims, err := p.findClaimsFromIDToken(ctx, idToken, token)
259+ claims, err := p.getClaims(idToken)
205260 if err != nil {
206261 return nil, fmt.Errorf("couldn't extract claims from id_token (%v)", err)
207262 }
208263
209− if token != nil {
210− newSession.IDToken = token.Extra("id_token").(string)
211− }
212−
213− newSession.Email = claims.UserID // TODO Rename SessionState.Email to .UserID in the near future
264+ ss.User = claims.Subject
265+ ss.Email = claims.Email
266+ ss.Groups = claims.Groups
214267
215− newSession.User = claims.Subject
216− newSession.Groups = claims.Groups
217− newSession.PreferredUsername = claims.PreferredUsername
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+ }
218272
219− verifyEmail := (p.UserIDClaim == emailClaim) && !p.AllowUnverifiedEmail
273+ verifyEmail := (p.EmailClaim == emailClaim) && !p.AllowUnverifiedEmail
220274 if verifyEmail && claims.Verified != nil && !*claims.Verified {
221− return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.UserID)
275+ return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email)
222276 }
223277
224− return newSession, nil
278+ return ss, nil
225279}
226280
227−// ValidateSessionState checks that the session's IDToken is still valid
228−func (p *OIDCProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
229− _, err := p.Verifier.Verify(ctx, s.IDToken)
230− return err == nil
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{}
231288}
232289
233−func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.IDToken, token *oauth2.Token) (*OIDCClaims, error) {
290+// getClaims extracts IDToken claims into an OIDCClaims
291+func (p *OIDCProvider) getClaims(idToken *oidc.IDToken) (*OIDCClaims, error) {
234292 claims := &OIDCClaims{}
235293
236294 // Extract default claims.
@@ -242,86 +300,28 @@ func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.
242300 return nil, fmt.Errorf("failed to parse all id_token claims: %v", err)
243301 }
244302
245− userID := claims.rawClaims[p.UserIDClaim]
246− if userID != nil {
247− claims.UserID = fmt.Sprint(userID)
248− }
249−
250− claims.Groups = p.extractGroupsFromRawClaims(claims.rawClaims)
251−
252− // userID claim was not present or was empty in the ID Token
253− if claims.UserID == "" {
254− // BearerToken case, allow empty UserID
255− // ProfileURL checks below won't work since we don't have an access token
256− if token == nil {
257− claims.UserID = claims.Subject
258− return claims, nil
259− }
260−
261− profileURL := p.ProfileURL.String()
262− if profileURL == "" || token.AccessToken == "" {
263− return nil, fmt.Errorf("id_token did not contain user ID claim (%q)", p.UserIDClaim)
264− }
265−
266− // If the userinfo endpoint profileURL is defined, then there is a chance the userinfo
267− // contents at the profileURL contains the email.
268− // Make a query to the userinfo endpoint, and attempt to locate the email from there.
269− respJSON, err := requests.New(profileURL).
270− WithContext(ctx).
271− WithHeaders(makeOIDCHeader(token.AccessToken)).
272− Do().
273− UnmarshalJSON()
274− if err != nil {
275− return nil, err
276− }
277−
278− userID, err := respJSON.Get(p.UserIDClaim).String()
279− if err != nil {
280− return nil, fmt.Errorf("neither id_token nor userinfo endpoint contained user ID claim (%q)", p.UserIDClaim)
281− }
282−
283− claims.UserID = userID
303+ email := claims.rawClaims[p.EmailClaim]
304+ if email != nil {
305+ claims.Email = fmt.Sprint(email)
284306 }
307+ claims.Groups = p.extractGroups(claims.rawClaims)
285308
286309 return claims, nil
287310}
288311
289−func (p *OIDCProvider) extractGroupsFromRawClaims(rawClaims map[string]interface{}) []string {
312+func (p *OIDCProvider) extractGroups(claims map[string]interface{}) []string {
290313 groups := []string{}
291−
292− rawGroups, ok := rawClaims[p.GroupsClaim].([]interface{})
314+ rawGroups, ok := claims[p.GroupsClaim].([]interface{})
293315 if rawGroups != nil && ok {
294316 for _, rawGroup := range rawGroups {
295317 formattedGroup, err := formatGroup(rawGroup)
296318 if err != nil {
297− logger.Errorf("unable to format group of type %s with error %s", reflect.TypeOf(rawGroup), err)
319+ logger.Errorf("Warning: unable to format group of type %s with error %s",
320+ reflect.TypeOf(rawGroup), err)
298321 continue
299322 }
300323 groups = append(groups, formattedGroup)
301324 }
302325 }
303−
304326 return groups
305−
306−}
307−
308−func formatGroup(rawGroup interface{}) (string, error) {
309− group, ok := rawGroup.(string)
310− if !ok {
311− jsonGroup, err := json.Marshal(rawGroup)
312− if err != nil {
313− return "", err
314− }
315− group = string(jsonGroup)
316− }
317− return group, nil
318−}
319−
320−type OIDCClaims struct {
321− rawClaims map[string]interface{}
322− UserID string
323− Subject string `json:"sub"`
324− Verified *bool `json:"email_verified"`
325− PreferredUsername string `json:"preferred_username"`
326− Groups []string `json:"-"`
327327}
providers/oidc_test.go+253−31
@@ -154,7 +154,7 @@ func newOIDCProvider(serverURL *url.URL) *OIDCProvider {
154154
155155 p := &OIDCProvider{
156156 ProviderData: providerData,
157− UserIDClaim: "email",
157+ EmailClaim: "email",
158158 }
159159
160160 return p
@@ -225,7 +225,7 @@ func TestOIDCProviderRedeem_custom_userid(t *testing.T) {
225225 })
226226
227227 server, provider := newTestSetup(body)
228− provider.UserIDClaim = "phone_number"
228+ provider.EmailClaim = "phone_number"
229229 defer server.Close()
230230
231231 session, err := provider.Redeem(context.Background(), provider.RedeemURL.String(), "code1234")
@@ -233,6 +233,256 @@ func TestOIDCProviderRedeem_custom_userid(t *testing.T) {
233233 assert.Equal(t, defaultIDToken.Phone, session.Email)
234234}
235235
236+func TestOIDCProvider_EnrichSession(t *testing.T) {
237+ const (
238+ idToken = "Unchanged ID Token"
239+ accessToken = "Unchanged Access Token"
240+ refreshToken = "Unchanged Refresh Token"
241+ )
242+
243+ testCases := map[string]struct {
244+ ExistingSession *sessions.SessionState
245+ EmailClaim string
246+ GroupsClaim string
247+ ProfileJSON map[string]interface{}
248+ ExpectedError error
249+ ExpectedSession *sessions.SessionState
250+ }{
251+ "Already Populated": {
252+ ExistingSession: &sessions.SessionState{
253+ User: "already",
254+ Email: "already@populated.com",
255+ Groups: []string{"already", "populated"},
256+ IDToken: idToken,
257+ AccessToken: accessToken,
258+ RefreshToken: refreshToken,
259+ },
260+ EmailClaim: "email",
261+ GroupsClaim: "groups",
262+ ProfileJSON: map[string]interface{}{
263+ "email": "new@thing.com",
264+ "groups": []string{"new", "thing"},
265+ },
266+ ExpectedError: nil,
267+ ExpectedSession: &sessions.SessionState{
268+ User: "already",
269+ Email: "already@populated.com",
270+ Groups: []string{"already", "populated"},
271+ IDToken: idToken,
272+ AccessToken: accessToken,
273+ RefreshToken: refreshToken,
274+ },
275+ },
276+ "Missing Email": {
277+ ExistingSession: &sessions.SessionState{
278+ User: "missing.email",
279+ Groups: []string{"already", "populated"},
280+ IDToken: idToken,
281+ AccessToken: accessToken,
282+ RefreshToken: refreshToken,
283+ },
284+ EmailClaim: "email",
285+ GroupsClaim: "groups",
286+ ProfileJSON: map[string]interface{}{
287+ "email": "found@email.com",
288+ "groups": []string{"new", "thing"},
289+ },
290+ ExpectedError: nil,
291+ ExpectedSession: &sessions.SessionState{
292+ User: "missing.email",
293+ Email: "found@email.com",
294+ Groups: []string{"already", "populated"},
295+ IDToken: idToken,
296+ AccessToken: accessToken,
297+ RefreshToken: refreshToken,
298+ },
299+ },
300+
301+ "Missing Email Only in Profile URL": {
302+ ExistingSession: &sessions.SessionState{
303+ User: "missing.email",
304+ IDToken: idToken,
305+ AccessToken: accessToken,
306+ RefreshToken: refreshToken,
307+ },
308+ EmailClaim: "email",
309+ GroupsClaim: "groups",
310+ ProfileJSON: map[string]interface{}{
311+ "email": "found@email.com",
312+ },
313+ ExpectedError: nil,
314+ ExpectedSession: &sessions.SessionState{
315+ User: "missing.email",
316+ Email: "found@email.com",
317+ IDToken: idToken,
318+ AccessToken: accessToken,
319+ RefreshToken: refreshToken,
320+ },
321+ },
322+ "Missing Email with Custom Claim": {
323+ ExistingSession: &sessions.SessionState{
324+ User: "missing.email",
325+ Groups: []string{"already", "populated"},
326+ IDToken: idToken,
327+ AccessToken: accessToken,
328+ RefreshToken: refreshToken,
329+ },
330+ EmailClaim: "weird",
331+ GroupsClaim: "groups",
332+ ProfileJSON: map[string]interface{}{
333+ "weird": "weird@claim.com",
334+ "groups": []string{"new", "thing"},
335+ },
336+ ExpectedError: nil,
337+ ExpectedSession: &sessions.SessionState{
338+ User: "missing.email",
339+ Email: "weird@claim.com",
340+ Groups: []string{"already", "populated"},
341+ IDToken: idToken,
342+ AccessToken: accessToken,
343+ RefreshToken: refreshToken,
344+ },
345+ },
346+ "Missing Email not in Profile URL": {
347+ ExistingSession: &sessions.SessionState{
348+ User: "missing.email",
349+ Groups: []string{"already", "populated"},
350+ IDToken: idToken,
351+ AccessToken: accessToken,
352+ RefreshToken: refreshToken,
353+ },
354+ EmailClaim: "email",
355+ GroupsClaim: "groups",
356+ ProfileJSON: map[string]interface{}{
357+ "groups": []string{"new", "thing"},
358+ },
359+ ExpectedError: errors.New("neither the id_token nor the profileURL set an email"),
360+ ExpectedSession: &sessions.SessionState{
361+ User: "missing.email",
362+ Groups: []string{"already", "populated"},
363+ IDToken: idToken,
364+ AccessToken: accessToken,
365+ RefreshToken: refreshToken,
366+ },
367+ },
368+ "Missing Groups": {
369+ ExistingSession: &sessions.SessionState{
370+ User: "already",
371+ Email: "already@populated.com",
372+ Groups: []string{},
373+ IDToken: idToken,
374+ AccessToken: accessToken,
375+ RefreshToken: refreshToken,
376+ },
377+ EmailClaim: "email",
378+ GroupsClaim: "groups",
379+ ProfileJSON: map[string]interface{}{
380+ "email": "new@thing.com",
381+ "groups": []string{"new", "thing"},
382+ },
383+ ExpectedError: nil,
384+ ExpectedSession: &sessions.SessionState{
385+ User: "already",
386+ Email: "already@populated.com",
387+ Groups: []string{"new", "thing"},
388+ IDToken: idToken,
389+ AccessToken: accessToken,
390+ RefreshToken: refreshToken,
391+ },
392+ },
393+ "Missing Groups with Custom Claim": {
394+ ExistingSession: &sessions.SessionState{
395+ User: "already",
396+ Email: "already@populated.com",
397+ Groups: nil,
398+ IDToken: idToken,
399+ AccessToken: accessToken,
400+ RefreshToken: refreshToken,
401+ },
402+ EmailClaim: "email",
403+ GroupsClaim: "roles",
404+ ProfileJSON: map[string]interface{}{
405+ "email": "new@thing.com",
406+ "roles": []string{"new", "thing", "roles"},
407+ },
408+ ExpectedError: nil,
409+ ExpectedSession: &sessions.SessionState{
410+ User: "already",
411+ Email: "already@populated.com",
412+ Groups: []string{"new", "thing", "roles"},
413+ IDToken: idToken,
414+ AccessToken: accessToken,
415+ RefreshToken: refreshToken,
416+ },
417+ },
418+ "Missing Groups String Profile URL Response": {
419+ ExistingSession: &sessions.SessionState{
420+ User: "already",
421+ Email: "already@populated.com",
422+ Groups: []string{},
423+ IDToken: idToken,
424+ AccessToken: accessToken,
425+ RefreshToken: refreshToken,
426+ },
427+ EmailClaim: "email",
428+ GroupsClaim: "groups",
429+ ProfileJSON: map[string]interface{}{
430+ "email": "new@thing.com",
431+ "groups": "singleton",
432+ },
433+ ExpectedError: nil,
434+ ExpectedSession: &sessions.SessionState{
435+ User: "already",
436+ Email: "already@populated.com",
437+ Groups: []string{"singleton"},
438+ IDToken: idToken,
439+ AccessToken: accessToken,
440+ RefreshToken: refreshToken,
441+ },
442+ },
443+ "Missing Groups in both Claims and Profile URL": {
444+ ExistingSession: &sessions.SessionState{
445+ User: "already",
446+ Email: "already@populated.com",
447+ IDToken: idToken,
448+ AccessToken: accessToken,
449+ RefreshToken: refreshToken,
450+ },
451+ EmailClaim: "email",
452+ GroupsClaim: "groups",
453+ ProfileJSON: map[string]interface{}{
454+ "email": "new@thing.com",
455+ },
456+ ExpectedError: nil,
457+ ExpectedSession: &sessions.SessionState{
458+ User: "already",
459+ Email: "already@populated.com",
460+ IDToken: idToken,
461+ AccessToken: accessToken,
462+ RefreshToken: refreshToken,
463+ },
464+ },
465+ }
466+ for testName, tc := range testCases {
467+ t.Run(testName, func(t *testing.T) {
468+ jsonResp, err := json.Marshal(tc.ProfileJSON)
469+ assert.NoError(t, err)
470+
471+ server, provider := newTestSetup(jsonResp)
472+ provider.ProfileURL, err = url.Parse(server.URL)
473+ assert.NoError(t, err)
474+
475+ provider.EmailClaim = tc.EmailClaim
476+ provider.GroupsClaim = tc.GroupsClaim
477+ defer server.Close()
478+
479+ err = provider.EnrichSession(context.Background(), tc.ExistingSession)
480+ assert.Equal(t, tc.ExpectedError, err)
481+ assert.Equal(t, *tc.ExpectedSession, *tc.ExistingSession)
482+ })
483+ }
484+}
485+
236486func TestOIDCProviderRefreshSessionIfNeededWithoutIdToken(t *testing.T) {
237487
238488 idToken, _ := newSignedTestIDToken(defaultIDToken)
@@ -361,7 +611,7 @@ func TestOIDCProviderCreateSessionFromToken(t *testing.T) {
361611 }
362612}
363613
364−func TestOIDCProvider_findVerifiedIdToken(t *testing.T) {
614+func TestOIDCProvider_findVerifiedIDToken(t *testing.T) {
365615
366616 server, provider := newTestSetup([]byte(""))
367617
@@ -397,31 +647,3 @@ func TestOIDCProvider_findVerifiedIdToken(t *testing.T) {
397647 assert.Equal(t, nil, err)
398648 assert.Equal(t, true, verifiedIDToken == nil)
399649}
400−
401−func Test_formatGroup(t *testing.T) {
402− testCases := map[string]struct {
403− RawGroup interface{}
404− ExpectedFormattedGroupValue string
405− }{
406− "String Group": {
407− RawGroup: "group",
408− ExpectedFormattedGroupValue: "group",
409− },
410− "Map Group": {
411− RawGroup: map[string]string{"id": "1", "name": "Test"},
412− ExpectedFormattedGroupValue: "{\"id\":\"1\",\"name\":\"Test\"}",
413− },
414− "List Group": {
415− RawGroup: []string{"First", "Second"},
416− ExpectedFormattedGroupValue: "[\"First\",\"Second\"]",
417− },
418− }
419−
420− for testName, tc := range testCases {
421− t.Run(testName, func(t *testing.T) {
422− formattedGroup, err := formatGroup(tc.RawGroup)
423− assert.Nil(t, err)
424− assert.Equal(t, tc.ExpectedFormattedGroupValue, formattedGroup)
425− })
426− }
427−}
providers/util.go+23
@@ -1,9 +1,12 @@
11package providers
22
33import (
4+ "encoding/json"
45 "fmt"
56 "net/http"
67 "net/url"
8+
9+ "golang.org/x/oauth2"
710)
811
912const (
@@ -55,3 +58,23 @@ func makeLoginURL(p *ProviderData, redirectURI, state string, extraParams url.Va
5558 a.RawQuery = params.Encode()
5659 return a
5760}
61+
62+func getIDToken(token *oauth2.Token) string {
63+ idToken, ok := token.Extra("id_token").(string)
64+ if !ok {
65+ return ""
66+ }
67+ return idToken
68+}
69+
70+func formatGroup(rawGroup interface{}) (string, error) {
71+ group, ok := rawGroup.(string)
72+ if !ok {
73+ jsonGroup, err := json.Marshal(rawGroup)
74+ if err != nil {
75+ return "", err
76+ }
77+ group = string(jsonGroup)
78+ }
79+ return group, nil
80+}
providers/util_test.go+48−1
@@ -5,9 +5,10 @@ import (
55 "testing"
66
77 . "github.com/onsi/gomega"
8+ "golang.org/x/oauth2"
89)
910
10−func TestMakeAuhtorizationHeader(t *testing.T) {
11+func Test_makeAuthorizationHeader(t *testing.T) {
1112 testCases := []struct {
1213 name string
1314 prefix string
@@ -64,3 +65,49 @@ func TestMakeAuhtorizationHeader(t *testing.T) {
6465 })
6566 }
6667}
68+
69+func Test_getIDToken(t *testing.T) {
70+ const idToken = "eyJfoobar.eyJfoobar.12345asdf"
71+ g := NewWithT(t)
72+
73+ token := &oauth2.Token{}
74+ g.Expect(getIDToken(token)).To(Equal(""))
75+
76+ extraToken := token.WithExtra(map[string]interface{}{
77+ "id_token": idToken,
78+ })
79+ g.Expect(getIDToken(extraToken)).To(Equal(idToken))
80+}
81+
82+func Test_formatGroup(t *testing.T) {
83+ testCases := map[string]struct {
84+ rawGroup interface{}
85+ expected string
86+ }{
87+ "String Group": {
88+ rawGroup: "group",
89+ expected: "group",
90+ },
91+ "Numeric Group": {
92+ rawGroup: 123,
93+ expected: "123",
94+ },
95+ "Map Group": {
96+ rawGroup: map[string]string{"id": "1", "name": "Test"},
97+ expected: "{\"id\":\"1\",\"name\":\"Test\"}",
98+ },
99+ "List Group": {
100+ rawGroup: []string{"First", "Second"},
101+ expected: "[\"First\",\"Second\"]",
102+ },
103+ }
104+
105+ for testName, tc := range testCases {
106+ t.Run(testName, func(t *testing.T) {
107+ g := NewWithT(t)
108+ formattedGroup, err := formatGroup(tc.rawGroup)
109+ g.Expect(err).ToNot(HaveOccurred())
110+ g.Expect(formattedGroup).To(Equal(tc.expected))
111+ })
112+ }
113+}