git.iko.soy/typeable/oauth2-proxy
on master help sign in
Refactor OIDC to EnrichSession
a187743Nick Meves
pkg/validation/options.go+1−1
@@ -274,7 +274,7 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
274 p.SetRepository(o.BitbucketRepository)
274 p.SetRepository(o.BitbucketRepository)
275 case *providers.OIDCProvider:
275 case *providers.OIDCProvider:
276 p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
276 p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
277− p.UserIDClaim = o.UserIDClaim
277+ p.EmailClaim = o.UserIDClaim
278 p.GroupsClaim = o.OIDCGroupsClaim
278 p.GroupsClaim = o.OIDCGroupsClaim
279 if p.Verifier == nil {
279 if p.Verifier == nil {
280 msgs = append(msgs, "oidc provider requires an oidc issuer URL")
280 msgs = append(msgs, "oidc provider requires an oidc issuer URL")
providers/oidc.go+153−153
@@ -2,18 +2,17 @@ package providers
2
2
3import (
3import (
4 "context"
4 "context"
5− "encoding/json"
5+ "errors"
6 "fmt"
6 "fmt"
7 "reflect"
7 "reflect"
8 "strings"
8 "strings"
9 "time"
9 "time"
10
10
11− oidc "github.com/coreos/go-oidc"
11+ "github.com/coreos/go-oidc"
12− "golang.org/x/oauth2"
13−
14 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
12 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
15 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
13 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
16 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
14 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
15+ "golang.org/x/oauth2"
17)
16)
18
17
19const emailClaim = "email"
18const emailClaim = "email"
@@ -23,7 +22,7 @@ type OIDCProvider struct {
23 *ProviderData
22 *ProviderData
24
23
25 AllowUnverifiedEmail bool
24 AllowUnverifiedEmail bool
26− UserIDClaim string
25+ EmailClaim string
27 GroupsClaim string
26 GroupsClaim string
28}
27}
29
28
@@ -36,10 +35,10 @@ func NewOIDCProvider(p *ProviderData) *OIDCProvider {
36var _ Provider = (*OIDCProvider)(nil)
35var _ Provider = (*OIDCProvider)(nil)
37
36
38// Redeem exchanges the OAuth2 authentication token for an ID token
37// 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) {
40 clientSecret, err := p.GetClientSecret()
39 clientSecret, err := p.GetClientSecret()
41 if err != nil {
40 if err != nil {
42− return
41+ return nil, err
43 }
42 }
44
43
45 c := oauth2.Config{
44 c := oauth2.Config{
@@ -52,23 +51,74 @@ func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code string) (s
52 }
51 }
53 token, err := c.Exchange(ctx, code)
52 token, err := c.Exchange(ctx, code)
54 if err != nil {
53 if err != nil {
55− return nil, fmt.Errorf("token exchange: %v", err)
54+ return nil, fmt.Errorf("token exchange failure: %v", err)
56 }
55 }
57
56
58− // in the initial exchange the id token is mandatory
57+ return p.createSession(ctx, token, false)
59− idToken, err := p.findVerifiedIDToken(ctx, token)
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)
60− if err != nil {
73+ if err != nil {
61− return nil, fmt.Errorf("could not verify id_token: %v", err)
74+ logger.Errorf("Warning: Profile URL request failed: %v", err)
62− } else if idToken == nil {
75+ }
63− return nil, fmt.Errorf("token response did not contain an id_token")
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")
64 }
81 }
82+ return nil
83+}
65
84
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()
67 if err != nil {
93 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+ }
69 }
113 }
70
114
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
72}
122}
73
123
74// RefreshSessionIfNeeded checks if the session has expired and uses the
124// RefreshSessionIfNeeded checks if the session has expired and uses the
@@ -83,14 +133,16 @@ func (p *OIDCProvider) RefreshSessionIfNeeded(ctx context.Context, s *sessions.S
83 return false, fmt.Errorf("unable to redeem refresh token: %v", err)
133 return false, fmt.Errorf("unable to redeem refresh token: %v", err)
84 }
134 }
85
135
86− fmt.Printf("refreshed access token %s (expired on %s)\n", s, s.ExpiresOn)
136+ logger.Printf("refreshed session: %s", s)
87 return true, nil
137 return true, nil
88}
138}
89
139
140+// redeemRefreshToken uses a RefreshToken with the RedeemURL to refresh the
141+// Access Token and (probably) the ID Token.
90−func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) (err error) {
142+func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) error {
91 clientSecret, err := p.GetClientSecret()
143 clientSecret, err := p.GetClientSecret()
92 if err != nil {
144 if err != nil {
93− return
145+ return err
94 }
146 }
95
147
96 c := oauth2.Config{
148 c := oauth2.Config{
@@ -109,19 +161,14 @@ func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sessi
109 return fmt.Errorf("failed to get token: %v", err)
161 return fmt.Errorf("failed to get token: %v", err)
110 }
162 }
111
163
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)
119 if err != nil {
165 if err != nil {
120 return fmt.Errorf("unable create new session state from response: %v", err)
166 return fmt.Errorf("unable create new session state from response: %v", err)
121 }
167 }
122
168
123− // It's possible that if the refresh token isn't in the token response the session will not contain an id token
169+ // It's possible that if the refresh token isn't in the token response the
170+ // session will not contain an id token.
124− // if it doesn't it's probably better to retain the old one
171+ // If it doesn't it's probably better to retain the old one
125 if newSession.IDToken != "" {
172 if newSession.IDToken != "" {
126 s.IDToken = newSession.IDToken
173 s.IDToken = newSession.IDToken
127 s.Email = newSession.Email
174 s.Email = newSession.Email
@@ -135,102 +182,113 @@ func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sessi
135 s.CreatedAt = newSession.CreatedAt
182 s.CreatedAt = newSession.CreatedAt
136 s.ExpiresOn = newSession.ExpiresOn
183 s.ExpiresOn = newSession.ExpiresOn
137
184
138− return
185+ return nil
139}
186}
140
187
188+// CreateSessionFromToken converts Bearer IDTokens into sessions
141−func (p *OIDCProvider) findVerifiedIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
189+func (p *OIDCProvider) CreateSessionFromToken(ctx context.Context, token string) (*sessions.SessionState, error) {
142−
190+ idToken, err := p.Verifier.Verify(ctx, token)
143− getIDToken := func() (string, bool) {
191+ if err != nil {
144− rawIDToken, _ := token.Extra("id_token").(string)
192+ return nil, err
145− return rawIDToken, len(strings.TrimSpace(rawIDToken)) > 0
146 }
193 }
147
194
148− if rawIDToken, present := getIDToken(); present {
195+ ss, err := p.buildSessionFromClaims(idToken)
149− verifiedIDToken, err := p.Verifier.Verify(ctx, rawIDToken)
196+ if err != nil {
150− return verifiedIDToken, err
197+ return nil, err
151 }
198 }
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
158
199
159− if idToken == nil {
200+ // Allow empty Email in Bearer case since we can't hit the ProfileURL
160− newSession = &sessions.SessionState{}
201+ if ss.Email == "" {
161− } else {
202+ ss.Email = ss.User
162− var err error
163− newSession, err = p.createSessionStateInternal(ctx, idToken, token)
164− if err != nil {
165− return nil, err
166− }
167 }
203 }
168
204
169− created := time.Now()
170− newSession.AccessToken = token.AccessToken
205+ ss.AccessToken = token
171− newSession.RefreshToken = token.RefreshToken
206+ ss.IDToken = token
172− newSession.CreatedAt = &created
207+ ss.RefreshToken = ""
173− newSession.ExpiresOn = &token.Expiry
208+ ss.ExpiresOn = &idToken.Expiry
209+
174− return newSession, nil
210+ return ss, nil
175}
211}
176
212
213+// createSession takes an oauth2.Token and creates a SessionState from it.
214+// It alters behavior if called from Redeem vs Refresh
177−func (p *OIDCProvider) CreateSessionFromToken(ctx context.Context, token string) (*sessions.SessionState, error) {
215+func (p *OIDCProvider) createSession(ctx context.Context, token *oauth2.Token, refresh bool) (*sessions.SessionState, error) {
178− idToken, err := p.Verifier.Verify(ctx, token)
216+ idToken, err := p.findVerifiedIDToken(ctx, token)
179 if err != nil {
217 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")
181 }
224 }
182
225
183− newSession, err := p.createSessionStateInternal(ctx, idToken, nil)
226+ ss, err := p.buildSessionFromClaims(idToken)
184 if err != nil {
227 if err != nil {
185 return nil, err
228 return nil, err
186 }
229 }
187
230
188− newSession.AccessToken = token
231+ ss.AccessToken = token.AccessToken
232+ ss.RefreshToken = token.RefreshToken
189− newSession.IDToken = token
233+ ss.IDToken = getIDToken(token)
190− newSession.RefreshToken = ""
191− newSession.ExpiresOn = &idToken.Expiry
192
234
235+ created := time.Now()
236+ ss.CreatedAt = &created
237+ ss.ExpiresOn = &token.Expiry
238+
193− return newSession, nil
239+ return ss, nil
194}
240}
195
241
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+}
197
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) {
198− newSession := &sessions.SessionState{}
253+ ss := &sessions.SessionState{}
199
254
200 if idToken == nil {
255 if idToken == nil {
201− return newSession, nil
256+ return ss, nil
202 }
257 }
203
258
204− claims, err := p.findClaimsFromIDToken(ctx, idToken, token)
259+ claims, err := p.getClaims(idToken)
205 if err != nil {
260 if err != nil {
206 return nil, fmt.Errorf("couldn't extract claims from id_token (%v)", err)
261 return nil, fmt.Errorf("couldn't extract claims from id_token (%v)", err)
207 }
262 }
208
263
209− if token != nil {
264+ ss.User = claims.Subject
210− newSession.IDToken = token.Extra("id_token").(string)
265+ ss.Email = claims.Email
211− }
266+ ss.Groups = claims.Groups
212−
213− newSession.Email = claims.UserID // TODO Rename SessionState.Email to .UserID in the near future
214
267
215− newSession.User = claims.Subject
268+ // TODO (@NickMeves) Deprecate for dynamic claim to session mapping
216− newSession.Groups = claims.Groups
269+ if pref, ok := claims.rawClaims["preferred_username"].(string); ok {
217− newSession.PreferredUsername = claims.PreferredUsername
270+ ss.PreferredUsername = pref
271+ }
218
272
219− verifyEmail := (p.UserIDClaim == emailClaim) && !p.AllowUnverifiedEmail
273+ verifyEmail := (p.EmailClaim == emailClaim) && !p.AllowUnverifiedEmail
220 if verifyEmail && claims.Verified != nil && !*claims.Verified {
274 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)
222 }
276 }
223
277
224− return newSession, nil
278+ return ss, nil
225}
279}
226
280
227−// ValidateSessionState checks that the session's IDToken is still valid
281+type OIDCClaims struct {
228−func (p *OIDCProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
282+ Subject string `json:"sub"`
229− _, err := p.Verifier.Verify(ctx, s.IDToken)
283+ Email string `json:"-"`
230− return err == nil
284+ Groups []string `json:"-"`
285+ Verified *bool `json:"email_verified"`
286+
287+ rawClaims map[string]interface{}
231}
288}
232
289
290+// getClaims extracts IDToken claims into an OIDCClaims
233−func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.IDToken, token *oauth2.Token) (*OIDCClaims, error) {
291+func (p *OIDCProvider) getClaims(idToken *oidc.IDToken) (*OIDCClaims, error) {
234 claims := &OIDCClaims{}
292 claims := &OIDCClaims{}
235
293
236 // Extract default claims.
294 // Extract default claims.
@@ -242,86 +300,28 @@ func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.
242 return nil, fmt.Errorf("failed to parse all id_token claims: %v", err)
300 return nil, fmt.Errorf("failed to parse all id_token claims: %v", err)
243 }
301 }
244
302
245− userID := claims.rawClaims[p.UserIDClaim]
303+ email := claims.rawClaims[p.EmailClaim]
246− if userID != nil {
304+ if email != nil {
247− claims.UserID = fmt.Sprint(userID)
305+ claims.Email = fmt.Sprint(email)
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
284 }
306 }
307+ claims.Groups = p.extractGroups(claims.rawClaims)
285
308
286 return claims, nil
309 return claims, nil
287}
310}
288
311
289−func (p *OIDCProvider) extractGroupsFromRawClaims(rawClaims map[string]interface{}) []string {
312+func (p *OIDCProvider) extractGroups(claims map[string]interface{}) []string {
290 groups := []string{}
313 groups := []string{}
291−
292− rawGroups, ok := rawClaims[p.GroupsClaim].([]interface{})
314+ rawGroups, ok := claims[p.GroupsClaim].([]interface{})
293 if rawGroups != nil && ok {
315 if rawGroups != nil && ok {
294 for _, rawGroup := range rawGroups {
316 for _, rawGroup := range rawGroups {
295 formattedGroup, err := formatGroup(rawGroup)
317 formattedGroup, err := formatGroup(rawGroup)
296 if err != nil {
318 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)
298 continue
321 continue
299 }
322 }
300 groups = append(groups, formattedGroup)
323 groups = append(groups, formattedGroup)
301 }
324 }
302 }
325 }
303−
304 return groups
326 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:"-"`
327}
327}
providers/oidc_test.go+253−31
@@ -154,7 +154,7 @@ func newOIDCProvider(serverURL *url.URL) *OIDCProvider {
154
154
155 p := &OIDCProvider{
155 p := &OIDCProvider{
156 ProviderData: providerData,
156 ProviderData: providerData,
157− UserIDClaim: "email",
157+ EmailClaim: "email",
158 }
158 }
159
159
160 return p
160 return p
@@ -225,7 +225,7 @@ func TestOIDCProviderRedeem_custom_userid(t *testing.T) {
225 })
225 })
226
226
227 server, provider := newTestSetup(body)
227 server, provider := newTestSetup(body)
228− provider.UserIDClaim = "phone_number"
228+ provider.EmailClaim = "phone_number"
229 defer server.Close()
229 defer server.Close()
230
230
231 session, err := provider.Redeem(context.Background(), provider.RedeemURL.String(), "code1234")
231 session, err := provider.Redeem(context.Background(), provider.RedeemURL.String(), "code1234")
@@ -233,6 +233,256 @@ func TestOIDCProviderRedeem_custom_userid(t *testing.T) {
233 assert.Equal(t, defaultIDToken.Phone, session.Email)
233 assert.Equal(t, defaultIDToken.Phone, session.Email)
234}
234}
235
235
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+
236func TestOIDCProviderRefreshSessionIfNeededWithoutIdToken(t *testing.T) {
486func TestOIDCProviderRefreshSessionIfNeededWithoutIdToken(t *testing.T) {
237
487
238 idToken, _ := newSignedTestIDToken(defaultIDToken)
488 idToken, _ := newSignedTestIDToken(defaultIDToken)
@@ -361,7 +611,7 @@ func TestOIDCProviderCreateSessionFromToken(t *testing.T) {
361 }
611 }
362}
612}
363
613
364−func TestOIDCProvider_findVerifiedIdToken(t *testing.T) {
614+func TestOIDCProvider_findVerifiedIDToken(t *testing.T) {
365
615
366 server, provider := newTestSetup([]byte(""))
616 server, provider := newTestSetup([]byte(""))
367
617
@@ -397,31 +647,3 @@ func TestOIDCProvider_findVerifiedIdToken(t *testing.T) {
397 assert.Equal(t, nil, err)
647 assert.Equal(t, nil, err)
398 assert.Equal(t, true, verifiedIDToken == nil)
648 assert.Equal(t, true, verifiedIDToken == nil)
399}
649}
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 @@
1package providers
1package providers
2
2
3import (
3import (
4+ "encoding/json"
4 "fmt"
5 "fmt"
5 "net/http"
6 "net/http"
6 "net/url"
7 "net/url"
8+
9+ "golang.org/x/oauth2"
7)
10)
8
11
9const (
12const (
@@ -55,3 +58,23 @@ func makeLoginURL(p *ProviderData, redirectURI, state string, extraParams url.Va
55 a.RawQuery = params.Encode()
58 a.RawQuery = params.Encode()
56 return a
59 return a
57}
60}
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 (
5 "testing"
5 "testing"
6
6
7 . "github.com/onsi/gomega"
7 . "github.com/onsi/gomega"
8+ "golang.org/x/oauth2"
8)
9)
9
10
10−func TestMakeAuhtorizationHeader(t *testing.T) {
11+func Test_makeAuthorizationHeader(t *testing.T) {
11 testCases := []struct {
12 testCases := []struct {
12 name string
13 name string
13 prefix string
14 prefix string
@@ -64,3 +65,49 @@ func TestMakeAuhtorizationHeader(t *testing.T) {
64 })
65 })
65 }
66 }
66}
67}
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+}