git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge pull request #936 from grnhse/oidc-provider-refactor

OIDC Provider Refactor

commit8bd2409342f913ba78fad70cceaaa511df0b37ce authorJoel Speed <Joel.speed@hotmail.co.uk> dateWed 23 Dec 2020, 19:04 (6 years ago) parent4fda907d2ffef2
12 files changed+1166−442
CHANGELOG.md+3 docs/docs/configuration/overview.md+2−2 pkg/apis/options/options.go+7−4 pkg/validation/options.go+11−5 providers/gitlab.go+11−14 providers/oidc.go+108−200 providers/oidc_test.go+358−215 providers/provider_data.go+130−1 providers/provider_data_test.go+437 providers/provider_default.go+8 providers/util.go+43 providers/util_test.go+48−1
CHANGELOG.md+3
@@ -4,6 +4,7 @@
44
55## Important Notes
66
7+- [#936](https://github.com/oauth2-proxy/oauth2-proxy/pull/936) `--user-id-claim` option is deprecated and replaced by `--oidc-email-claim`
78- [#630](https://github.com/oauth2-proxy/oauth2-proxy/pull/630) Gitlab projects needs a Gitlab application with the extra `read_api` enabled
89- [#905](https://github.com/oauth2-proxy/oauth2-proxy/pull/905) Existing sessions from v6.0.0 or earlier are no longer valid. They will trigger a reauthentication.
910- [#826](https://github.com/oauth2-proxy/oauth2-proxy/pull/826) `skip-auth-strip-headers` now applies to all requests, not just those where authentication would be skipped.
@@ -47,6 +48,8 @@
4748- [#630](https://github.com/oauth2-proxy/oauth2-proxy/pull/630) Add support for Gitlab project based authentication (@factorysh)
4849- [#907](https://github.com/oauth2-proxy/oauth2-proxy/pull/907) Introduce alpha configuration option to enable testing of structured configuration (@JoelSpeed)
4950- [#938](https://github.com/oauth2-proxy/oauth2-proxy/pull/938) Cleanup missed provider renaming refactor methods (@NickMeves)
51+- [#816](https://github.com/oauth2-proxy/oauth2-proxy/pull/816) (via [#936](https://github.com/oauth2-proxy/oauth2-proxy/pull/936)) Support non-list group claims (@loafoe)
52+- [#936](https://github.com/oauth2-proxy/oauth2-proxy/pull/936) Refactor OIDC Provider and support groups from Profile URL (@NickMeves)
5053- [#925](https://github.com/oauth2-proxy/oauth2-proxy/pull/925) Fix basic auth legacy header conversion (@JoelSpeed)
5154- [#916](https://github.com/oauth2-proxy/oauth2-proxy/pull/916) Add AlphaOptions struct to prepare for alpha config loading (@JoelSpeed)
5255- [#923](https://github.com/oauth2-proxy/oauth2-proxy/pull/923) Support TLS 1.3 (@aajisaka)
docs/docs/configuration/overview.md+2−2
@@ -74,7 +74,8 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
7474| `--insecure-oidc-skip-issuer-verification` | bool | allow the OIDC issuer URL to differ from the expected (currently required for Azure multi-tenant compatibility) | false |
7575| `--oidc-issuer-url` | string | the OpenID Connect issuer URL, e.g. `"https://accounts.google.com"` | |
7676| `--oidc-jwks-url` | string | OIDC JWKS URI for token verification; required if OIDC discovery is disabled | |
77−| `--oidc-groups-claim` | string | which claim contains the user groups | `"groups"` |
77+| `--oidc-email-claim` | string | which OIDC claim contains the user's email | `"email"` |
78+| `--oidc-groups-claim` | string | which OIDC claim contains the user groups | `"groups"` |
7879| `--pass-access-token` | bool | pass OAuth access_token to upstream via X-Forwarded-Access-Token header. When used with `--set-xauthrequest` this adds the X-Auth-Request-Access-Token header to the response | false |
7980| `--pass-authorization-header` | bool | pass OIDC IDToken to upstream via Authorization Bearer header | false |
8081| `--pass-basic-auth` | bool | pass HTTP Basic Auth, X-Forwarded-User, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true |
@@ -128,7 +129,6 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
128129| `--tls-cert-file` | string | path to certificate file | |
129130| `--tls-key-file` | string | path to private key file | |
130131| `--upstream` | string \| list | the http url(s) of the upstream endpoint, file:// paths for static files or `static://<status_code>` for static response. Routing is based on the path | |
131−| `--user-id-claim` | string | which claim contains the user ID | \["email"\] |
132132| `--allowed-group` | string \| list | restrict logins to members of this group (may be given multiple times) | |
133133| `--validate-url` | string | Access token validation endpoint | |
134134| `--version` | n/a | print version string | |
pkg/apis/options/options.go+7−4
@@ -87,6 +87,7 @@ type Options struct {
8787 InsecureOIDCSkipIssuerVerification bool `flag:"insecure-oidc-skip-issuer-verification" cfg:"insecure_oidc_skip_issuer_verification"`
8888 SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"`
8989 OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"`
90+ OIDCEmailClaim string `flag:"oidc-email-claim" cfg:"oidc_email_claim"`
9091 OIDCGroupsClaim string `flag:"oidc-groups-claim" cfg:"oidc_groups_claim"`
9192 LoginURL string `flag:"login-url" cfg:"login_url"`
9293 RedeemURL string `flag:"redeem-url" cfg:"redeem_url"`
@@ -148,11 +149,12 @@ func NewOptions() *Options {
148149 SkipAuthPreflight: false,
149150 Prompt: "", // Change to "login" when ApprovalPrompt officially deprecated
150151 ApprovalPrompt: "force",
151− UserIDClaim: "email",
152152 InsecureOIDCAllowUnverifiedEmail: false,
153153 SkipOIDCDiscovery: false,
154154 Logging: loggingDefaults(),
155− OIDCGroupsClaim: "groups",
155+ UserIDClaim: providers.OIDCEmailClaim, // Deprecated: Use OIDCEmailClaim
156+ OIDCEmailClaim: providers.OIDCEmailClaim,
157+ OIDCGroupsClaim: providers.OIDCGroupsClaim,
156158 }
157159}
158160
@@ -226,7 +228,8 @@ func NewFlagSet() *pflag.FlagSet {
226228 flagSet.Bool("insecure-oidc-skip-issuer-verification", false, "Do not verify if issuer matches OIDC discovery URL")
227229 flagSet.Bool("skip-oidc-discovery", false, "Skip OIDC discovery and use manually supplied Endpoints")
228230 flagSet.String("oidc-jwks-url", "", "OpenID Connect JWKS URL (ie: https://www.googleapis.com/oauth2/v3/certs)")
229− flagSet.String("oidc-groups-claim", "groups", "which claim contains the user groups")
231+ flagSet.String("oidc-groups-claim", providers.OIDCGroupsClaim, "which OIDC claim contains the user groups")
232+ flagSet.String("oidc-email-claim", providers.OIDCEmailClaim, "which OIDC claim contains the user's email")
230233 flagSet.String("login-url", "", "Authentication endpoint")
231234 flagSet.String("redeem-url", "", "Token redemption endpoint")
232235 flagSet.String("profile-url", "", "Profile access endpoint")
@@ -243,7 +246,7 @@ func NewFlagSet() *pflag.FlagSet {
243246 flagSet.String("pubjwk-url", "", "JWK pubkey access endpoint: required by login.gov")
244247 flagSet.Bool("gcp-healthchecks", false, "Enable GCP/GKE healthcheck endpoints")
245248
246− flagSet.String("user-id-claim", "email", "which claim contains the user ID")
249+ flagSet.String("user-id-claim", providers.OIDCEmailClaim, "(DEPRECATED for `oidc-email-claim`) which claim contains the user ID")
247250 flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)")
248251
249252 flagSet.AddFlagSet(cookieFlagSet())
pkg/validation/options.go+11−5
@@ -233,9 +233,19 @@ 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.OIDCEmailClaim
239+ p.GroupsClaim = o.OIDCGroupsClaim
237240 p.Verifier = o.GetOIDCVerifier()
238241
242+ // TODO (@NickMeves) - Remove This
243+ // Backwards Compatibility for Deprecated UserIDClaim option
244+ if o.OIDCEmailClaim == providers.OIDCEmailClaim &&
245+ o.UserIDClaim != providers.OIDCEmailClaim {
246+ p.EmailClaim = o.UserIDClaim
247+ }
248+
239249 p.SetAllowedGroups(o.AllowedGroups)
240250
241251 provider := providers.New(o.ProviderType, p)
@@ -273,14 +283,10 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
273283 p.SetTeam(o.BitbucketTeam)
274284 p.SetRepository(o.BitbucketRepository)
275285 case *providers.OIDCProvider:
276− p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
277− p.UserIDClaim = o.UserIDClaim
278− p.GroupsClaim = o.OIDCGroupsClaim
279286 if p.Verifier == nil {
280287 msgs = append(msgs, "oidc provider requires an oidc issuer URL")
281288 }
282289 case *providers.GitLabProvider:
283− p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
284290 p.Groups = o.GitLabGroup
285291 err := p.AddProjects(o.GitlabProjects)
286292 if err != nil {
providers/gitlab.go+11−14
@@ -20,8 +20,6 @@ type GitLabProvider struct {
2020
2121 Groups []string
2222 Projects []*GitlabProject
23−
24− AllowUnverifiedEmail bool
2523}
2624
2725// GitlabProject represents a Gitlab project constraint entity
@@ -103,7 +101,7 @@ func (p *GitLabProvider) Redeem(ctx context.Context, redirectURL, code string) (
103101 if err != nil {
104102 return nil, fmt.Errorf("token exchange: %v", err)
105103 }
106− s, err = p.createSessionState(ctx, token)
104+ s, err = p.createSession(ctx, token)
107105 if err != nil {
108106 return nil, fmt.Errorf("unable to update session: %v", err)
109107 }
@@ -162,7 +160,7 @@ func (p *GitLabProvider) redeemRefreshToken(ctx context.Context, s *sessions.Ses
162160 if err != nil {
163161 return fmt.Errorf("failed to get token: %v", err)
164162 }
165− newSession, err := p.createSessionState(ctx, token)
163+ newSession, err := p.createSession(ctx, token)
166164 if err != nil {
167165 return fmt.Errorf("unable to update session: %v", err)
168166 }
@@ -255,22 +253,21 @@ func (p *GitLabProvider) AddProjects(projects []string) error {
255253 return nil
256254}
257255
258−func (p *GitLabProvider) createSessionState(ctx context.Context, token *oauth2.Token) (*sessions.SessionState, error) {
259− rawIDToken, ok := token.Extra("id_token").(string)
260− if !ok {
261− return nil, fmt.Errorf("token response did not contain an id_token")
262− }
263−
264− // Parse and verify ID Token payload.
265− idToken, err := p.Verifier.Verify(ctx, rawIDToken)
256+func (p *GitLabProvider) createSession(ctx context.Context, token *oauth2.Token) (*sessions.SessionState, error) {
257+ idToken, err := p.verifyIDToken(ctx, token)
266258 if err != nil {
267− return nil, fmt.Errorf("could not verify id_token: %v", err)
259+ switch err {
260+ case ErrMissingIDToken:
261+ return nil, fmt.Errorf("token response did not contain an id_token")
262+ default:
263+ return nil, fmt.Errorf("could not verify id_token: %v", err)
264+ }
268265 }
269266
270267 created := time.Now()
271268 return &sessions.SessionState{
272269 AccessToken: token.AccessToken,
273− IDToken: rawIDToken,
270+ IDToken: getIDToken(token),
274271 RefreshToken: token.RefreshToken,
275272 CreatedAt: &created,
276273 ExpiresOn: &idToken.Expiry,
providers/oidc.go+108−200
@@ -2,29 +2,20 @@ package providers
22
33import (
44 "context"
5− "encoding/json"
5+ "errors"
66 "fmt"
77 "reflect"
8− "strings"
98 "time"
109
11− oidc "github.com/coreos/go-oidc"
12− "golang.org/x/oauth2"
13−
1410 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
1511 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
1612 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
13+ "golang.org/x/oauth2"
1714)
1815
19−const emailClaim = "email"
20−
2116// OIDCProvider represents an OIDC based Identity Provider
2217type OIDCProvider struct {
2318 *ProviderData
24−
25− AllowUnverifiedEmail bool
26− UserIDClaim string
27− GroupsClaim string
2819}
2920
3021// NewOIDCProvider initiates a new OIDCProvider
@@ -36,10 +27,10 @@ func NewOIDCProvider(p *ProviderData) *OIDCProvider {
3627var _ Provider = (*OIDCProvider)(nil)
3728
3829// 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) {
30+func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code string) (*sessions.SessionState, error) {
4031 clientSecret, err := p.GetClientSecret()
4132 if err != nil {
42− return
33+ return nil, err
4334 }
4435
4536 c := oauth2.Config{
@@ -52,23 +43,74 @@ func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code string) (s
5243 }
5344 token, err := c.Exchange(ctx, code)
5445 if err != nil {
55− return nil, fmt.Errorf("token exchange: %v", err)
46+ return nil, fmt.Errorf("token exchange failed: %v", err)
5647 }
5748
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")
49+ return p.createSession(ctx, token, false)
50+}
51+
52+// EnrichSession is called after Redeem to allow providers to enrich session fields
53+// such as User, Email, Groups with provider specific API calls.
54+func (p *OIDCProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
55+ if p.ProfileURL.String() == "" {
56+ if s.Email == "" {
57+ return errors.New("id_token did not contain an email and profileURL is not defined")
58+ }
59+ return nil
6460 }
6561
66− s, err = p.createSessionState(ctx, token, idToken)
62+ // Try to get missing emails or groups from a profileURL
63+ if s.Email == "" || s.Groups == nil {
64+ err := p.enrichFromProfileURL(ctx, s)
65+ if err != nil {
66+ logger.Errorf("Warning: Profile URL request failed: %v", err)
67+ }
68+ }
69+
70+ // If a mandatory email wasn't set, error at this point.
71+ if s.Email == "" {
72+ return errors.New("neither the id_token nor the profileURL set an email")
73+ }
74+ return nil
75+}
76+
77+// enrichFromProfileURL enriches a session's Email & Groups via the JSON response of
78+// an OIDC profile URL
79+func (p *OIDCProvider) enrichFromProfileURL(ctx context.Context, s *sessions.SessionState) error {
80+ respJSON, err := requests.New(p.ProfileURL.String()).
81+ WithContext(ctx).
82+ WithHeaders(makeOIDCHeader(s.AccessToken)).
83+ Do().
84+ UnmarshalJSON()
6785 if err != nil {
68− return nil, fmt.Errorf("unable to update session: %v", err)
86+ return err
87+ }
88+
89+ email, err := respJSON.Get(p.EmailClaim).String()
90+ if err == nil && s.Email == "" {
91+ s.Email = email
6992 }
7093
71− return
94+ if len(s.Groups) > 0 {
95+ return nil
96+ }
97+ for _, group := range coerceArray(respJSON, p.GroupsClaim) {
98+ formatted, err := formatGroup(group)
99+ if err != nil {
100+ logger.Errorf("Warning: unable to format group of type %s with error %s",
101+ reflect.TypeOf(group), err)
102+ continue
103+ }
104+ s.Groups = append(s.Groups, formatted)
105+ }
106+
107+ return nil
108+}
109+
110+// ValidateSession checks that the session's IDToken is still valid
111+func (p *OIDCProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
112+ _, err := p.Verifier.Verify(ctx, s.IDToken)
113+ return err == nil
72114}
73115
74116// RefreshSessionIfNeeded checks if the session has expired and uses the
@@ -83,14 +125,16 @@ func (p *OIDCProvider) RefreshSessionIfNeeded(ctx context.Context, s *sessions.S
83125 return false, fmt.Errorf("unable to redeem refresh token: %v", err)
84126 }
85127
86− fmt.Printf("refreshed access token %s (expired on %s)\n", s, s.ExpiresOn)
128+ logger.Printf("refreshed session: %s", s)
87129 return true, nil
88130}
89131
90−func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) (err error) {
132+// redeemRefreshToken uses a RefreshToken with the RedeemURL to refresh the
133+// Access Token and (probably) the ID Token.
134+func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) error {
91135 clientSecret, err := p.GetClientSecret()
92136 if err != nil {
93− return
137+ return err
94138 }
95139
96140 c := oauth2.Config{
@@ -109,19 +153,14 @@ func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sessi
109153 return fmt.Errorf("failed to get token: %v", err)
110154 }
111155
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)
156+ newSession, err := p.createSession(ctx, token, true)
119157 if err != nil {
120158 return fmt.Errorf("unable create new session state from response: %v", err)
121159 }
122160
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
161+ // It's possible that if the refresh token isn't in the token response the
162+ // session will not contain an id token.
163+ // If it doesn't it's probably better to retain the old one
125164 if newSession.IDToken != "" {
126165 s.IDToken = newSession.IDToken
127166 s.Email = newSession.Email
@@ -135,193 +174,62 @@ func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sessi
135174 s.CreatedAt = newSession.CreatedAt
136175 s.ExpiresOn = newSession.ExpiresOn
137176
138− return
139−}
140−
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
146− }
147−
148− if rawIDToken, present := getIDToken(); present {
149− verifiedIDToken, err := p.Verifier.Verify(ctx, rawIDToken)
150− return verifiedIDToken, err
151− }
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−
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− }
167− }
168−
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
177+ return nil
175178}
176179
180+// CreateSessionFromToken converts Bearer IDTokens into sessions
177181func (p *OIDCProvider) CreateSessionFromToken(ctx context.Context, token string) (*sessions.SessionState, error) {
178182 idToken, err := p.Verifier.Verify(ctx, token)
179183 if err != nil {
180184 return nil, err
181185 }
182186
183− newSession, err := p.createSessionStateInternal(ctx, idToken, nil)
187+ ss, err := p.buildSessionFromClaims(idToken)
184188 if err != nil {
185189 return nil, err
186190 }
187191
188− newSession.AccessToken = token
189− newSession.IDToken = token
190− newSession.RefreshToken = ""
191− newSession.ExpiresOn = &idToken.Expiry
192−
193− return newSession, nil
194−}
195−
196−func (p *OIDCProvider) createSessionStateInternal(ctx context.Context, idToken *oidc.IDToken, token *oauth2.Token) (*sessions.SessionState, error) {
197−
198− newSession := &sessions.SessionState{}
199−
200− if idToken == nil {
201− return newSession, nil
202− }
203−
204− claims, err := p.findClaimsFromIDToken(ctx, idToken, token)
205− if err != nil {
206− return nil, fmt.Errorf("couldn't extract claims from id_token (%v)", err)
207− }
208−
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
214−
215− newSession.User = claims.Subject
216− newSession.Groups = claims.Groups
217− newSession.PreferredUsername = claims.PreferredUsername
218−
219− verifyEmail := (p.UserIDClaim == emailClaim) && !p.AllowUnverifiedEmail
220− if verifyEmail && claims.Verified != nil && !*claims.Verified {
221− return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.UserID)
222− }
223−
224− return newSession, nil
225−}
226−
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
231−}
232−
233−func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.IDToken, token *oauth2.Token) (*OIDCClaims, error) {
234− claims := &OIDCClaims{}
235−
236− // Extract default claims.
237− if err := idToken.Claims(&claims); err != nil {
238− return nil, fmt.Errorf("failed to parse default id_token claims: %v", err)
239− }
240− // Extract custom claims.
241− if err := idToken.Claims(&claims.rawClaims); err != nil {
242− return nil, fmt.Errorf("failed to parse all id_token claims: %v", err)
243− }
244−
245− userID := claims.rawClaims[p.UserIDClaim]
246− if userID != nil {
247− claims.UserID = fmt.Sprint(userID)
192+ // Allow empty Email in Bearer case since we can't hit the ProfileURL
193+ if ss.Email == "" {
194+ ss.Email = ss.User
248195 }
249196
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− }
197+ ss.AccessToken = token
198+ ss.IDToken = token
199+ ss.RefreshToken = ""
200+ ss.ExpiresOn = &idToken.Expiry
285201
286− return claims, nil
202+ return ss, nil
287203}
288204
289−func (p *OIDCProvider) extractGroupsFromRawClaims(rawClaims map[string]interface{}) []string {
290− groups := []string{}
291−
292− rawGroups, ok := rawClaims[p.GroupsClaim].([]interface{})
293− if rawGroups != nil && ok {
294− for _, rawGroup := range rawGroups {
295− formattedGroup, err := formatGroup(rawGroup)
296− if err != nil {
297− logger.Errorf("unable to format group of type %s with error %s", reflect.TypeOf(rawGroup), err)
298− continue
205+// createSession takes an oauth2.Token and creates a SessionState from it.
206+// It alters behavior if called from Redeem vs Refresh
207+func (p *OIDCProvider) createSession(ctx context.Context, token *oauth2.Token, refresh bool) (*sessions.SessionState, error) {
208+ idToken, err := p.verifyIDToken(ctx, token)
209+ if err != nil {
210+ switch err {
211+ case ErrMissingIDToken:
212+ // IDToken is mandatory in Redeem but optional in Refresh
213+ if !refresh {
214+ return nil, errors.New("token response did not contain an id_token")
299215 }
300− groups = append(groups, formattedGroup)
216+ default:
217+ return nil, fmt.Errorf("could not verify id_token: %v", err)
301218 }
302219 }
303220
304− return groups
221+ ss, err := p.buildSessionFromClaims(idToken)
222+ if err != nil {
223+ return nil, err
224+ }
305225
306−}
226+ ss.AccessToken = token.AccessToken
227+ ss.RefreshToken = token.RefreshToken
228+ ss.IDToken = getIDToken(token)
307229
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−}
230+ created := time.Now()
231+ ss.CreatedAt = &created
232+ ss.ExpiresOn = &token.Expiry
319233
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:"-"`
234+ return ss, nil
327235}
providers/oidc_test.go+358−215
@@ -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− UserIDClaim: "email",
158− }
57+ p := &OIDCProvider{ProviderData: providerData}
15958
16059 return p
16160}
@@ -169,22 +68,7 @@ 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−
187−func newTestSetup(body []byte) (*httptest.Server, *OIDCProvider) {
71+func newTestOIDCSetup(body []byte) (*httptest.Server, *OIDCProvider) {
18872 redeemURL, server := newOIDCServer(body)
18973 provider := newOIDCProvider(redeemURL)
19074 return server, provider
@@ -201,7 +85,7 @@ func TestOIDCProviderRedeem(t *testing.T) {
20185 IDToken: idToken,
20286 })
20387
204− server, provider := newTestSetup(body)
88+ server, provider := newTestOIDCSetup(body)
20589 defer server.Close()
20690
20791 session, err := provider.Redeem(context.Background(), provider.RedeemURL.String(), "code1234")
@@ -224,8 +108,8 @@ func TestOIDCProviderRedeem_custom_userid(t *testing.T) {
224108 IDToken: idToken,
225109 })
226110
227− server, provider := newTestSetup(body)
228− provider.UserIDClaim = "phone_number"
111+ server, provider := newTestOIDCSetup(body)
112+ provider.EmailClaim = "phone_number"
229113 defer server.Close()
230114
231115 session, err := provider.Redeem(context.Background(), provider.RedeemURL.String(), "code1234")
@@ -233,6 +117,333 @@ func TestOIDCProviderRedeem_custom_userid(t *testing.T) {
233117 assert.Equal(t, defaultIDToken.Phone, session.Email)
234118}
235119
120+func TestOIDCProvider_EnrichSession(t *testing.T) {
121+ testCases := map[string]struct {
122+ ExistingSession *sessions.SessionState
123+ EmailClaim string
124+ GroupsClaim string
125+ ProfileJSON map[string]interface{}
126+ ExpectedError error
127+ ExpectedSession *sessions.SessionState
128+ }{
129+ "Already Populated": {
130+ ExistingSession: &sessions.SessionState{
131+ User: "already",
132+ Email: "already@populated.com",
133+ Groups: []string{"already", "populated"},
134+ IDToken: idToken,
135+ AccessToken: accessToken,
136+ RefreshToken: refreshToken,
137+ },
138+ EmailClaim: "email",
139+ GroupsClaim: "groups",
140+ ProfileJSON: map[string]interface{}{
141+ "email": "new@thing.com",
142+ "groups": []string{"new", "thing"},
143+ },
144+ ExpectedError: nil,
145+ ExpectedSession: &sessions.SessionState{
146+ User: "already",
147+ Email: "already@populated.com",
148+ Groups: []string{"already", "populated"},
149+ IDToken: idToken,
150+ AccessToken: accessToken,
151+ RefreshToken: refreshToken,
152+ },
153+ },
154+ "Missing Email": {
155+ ExistingSession: &sessions.SessionState{
156+ User: "missing.email",
157+ Groups: []string{"already", "populated"},
158+ IDToken: idToken,
159+ AccessToken: accessToken,
160+ RefreshToken: refreshToken,
161+ },
162+ EmailClaim: "email",
163+ GroupsClaim: "groups",
164+ ProfileJSON: map[string]interface{}{
165+ "email": "found@email.com",
166+ "groups": []string{"new", "thing"},
167+ },
168+ ExpectedError: nil,
169+ ExpectedSession: &sessions.SessionState{
170+ User: "missing.email",
171+ Email: "found@email.com",
172+ Groups: []string{"already", "populated"},
173+ IDToken: idToken,
174+ AccessToken: accessToken,
175+ RefreshToken: refreshToken,
176+ },
177+ },
178+
179+ "Missing Email Only in Profile URL": {
180+ ExistingSession: &sessions.SessionState{
181+ User: "missing.email",
182+ IDToken: idToken,
183+ AccessToken: accessToken,
184+ RefreshToken: refreshToken,
185+ },
186+ EmailClaim: "email",
187+ GroupsClaim: "groups",
188+ ProfileJSON: map[string]interface{}{
189+ "email": "found@email.com",
190+ },
191+ ExpectedError: nil,
192+ ExpectedSession: &sessions.SessionState{
193+ User: "missing.email",
194+ Email: "found@email.com",
195+ IDToken: idToken,
196+ AccessToken: accessToken,
197+ RefreshToken: refreshToken,
198+ },
199+ },
200+ "Missing Email with Custom Claim": {
201+ ExistingSession: &sessions.SessionState{
202+ User: "missing.email",
203+ Groups: []string{"already", "populated"},
204+ IDToken: idToken,
205+ AccessToken: accessToken,
206+ RefreshToken: refreshToken,
207+ },
208+ EmailClaim: "weird",
209+ GroupsClaim: "groups",
210+ ProfileJSON: map[string]interface{}{
211+ "weird": "weird@claim.com",
212+ "groups": []string{"new", "thing"},
213+ },
214+ ExpectedError: nil,
215+ ExpectedSession: &sessions.SessionState{
216+ User: "missing.email",
217+ Email: "weird@claim.com",
218+ Groups: []string{"already", "populated"},
219+ IDToken: idToken,
220+ AccessToken: accessToken,
221+ RefreshToken: refreshToken,
222+ },
223+ },
224+ "Missing Email not in Profile URL": {
225+ ExistingSession: &sessions.SessionState{
226+ User: "missing.email",
227+ Groups: []string{"already", "populated"},
228+ IDToken: idToken,
229+ AccessToken: accessToken,
230+ RefreshToken: refreshToken,
231+ },
232+ EmailClaim: "email",
233+ GroupsClaim: "groups",
234+ ProfileJSON: map[string]interface{}{
235+ "groups": []string{"new", "thing"},
236+ },
237+ ExpectedError: errors.New("neither the id_token nor the profileURL set an email"),
238+ ExpectedSession: &sessions.SessionState{
239+ User: "missing.email",
240+ Groups: []string{"already", "populated"},
241+ IDToken: idToken,
242+ AccessToken: accessToken,
243+ RefreshToken: refreshToken,
244+ },
245+ },
246+ "Missing Groups": {
247+ ExistingSession: &sessions.SessionState{
248+ User: "already",
249+ Email: "already@populated.com",
250+ Groups: nil,
251+ IDToken: idToken,
252+ AccessToken: accessToken,
253+ RefreshToken: refreshToken,
254+ },
255+ EmailClaim: "email",
256+ GroupsClaim: "groups",
257+ ProfileJSON: map[string]interface{}{
258+ "email": "new@thing.com",
259+ "groups": []string{"new", "thing"},
260+ },
261+ ExpectedError: nil,
262+ ExpectedSession: &sessions.SessionState{
263+ User: "already",
264+ Email: "already@populated.com",
265+ Groups: []string{"new", "thing"},
266+ IDToken: idToken,
267+ AccessToken: accessToken,
268+ RefreshToken: refreshToken,
269+ },
270+ },
271+ "Missing Groups with Complex Groups in Profile URL": {
272+ ExistingSession: &sessions.SessionState{
273+ User: "already",
274+ Email: "already@populated.com",
275+ Groups: nil,
276+ IDToken: idToken,
277+ AccessToken: accessToken,
278+ RefreshToken: refreshToken,
279+ },
280+ EmailClaim: "email",
281+ GroupsClaim: "groups",
282+ ProfileJSON: map[string]interface{}{
283+ "email": "new@thing.com",
284+ "groups": []map[string]interface{}{
285+ {
286+ "groupId": "Admin Group Id",
287+ "roles": []string{"Admin"},
288+ },
289+ },
290+ },
291+ ExpectedError: nil,
292+ ExpectedSession: &sessions.SessionState{
293+ User: "already",
294+ Email: "already@populated.com",
295+ Groups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"},
296+ IDToken: idToken,
297+ AccessToken: accessToken,
298+ RefreshToken: refreshToken,
299+ },
300+ },
301+ "Missing Groups with Singleton Complex Group in Profile URL": {
302+ ExistingSession: &sessions.SessionState{
303+ User: "already",
304+ Email: "already@populated.com",
305+ Groups: nil,
306+ IDToken: idToken,
307+ AccessToken: accessToken,
308+ RefreshToken: refreshToken,
309+ },
310+ EmailClaim: "email",
311+ GroupsClaim: "groups",
312+ ProfileJSON: map[string]interface{}{
313+ "email": "new@thing.com",
314+ "groups": map[string]interface{}{
315+ "groupId": "Admin Group Id",
316+ "roles": []string{"Admin"},
317+ },
318+ },
319+ ExpectedError: nil,
320+ ExpectedSession: &sessions.SessionState{
321+ User: "already",
322+ Email: "already@populated.com",
323+ Groups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"},
324+ IDToken: idToken,
325+ AccessToken: accessToken,
326+ RefreshToken: refreshToken,
327+ },
328+ },
329+ "Empty Groups Claims": {
330+ ExistingSession: &sessions.SessionState{
331+ User: "already",
332+ Email: "already@populated.com",
333+ Groups: []string{},
334+ IDToken: idToken,
335+ AccessToken: accessToken,
336+ RefreshToken: refreshToken,
337+ },
338+ EmailClaim: "email",
339+ GroupsClaim: "groups",
340+ ProfileJSON: map[string]interface{}{
341+ "email": "new@thing.com",
342+ "groups": []string{"new", "thing"},
343+ },
344+ ExpectedError: nil,
345+ ExpectedSession: &sessions.SessionState{
346+ User: "already",
347+ Email: "already@populated.com",
348+ Groups: []string{},
349+ IDToken: idToken,
350+ AccessToken: accessToken,
351+ RefreshToken: refreshToken,
352+ },
353+ },
354+ "Missing Groups with Custom Claim": {
355+ ExistingSession: &sessions.SessionState{
356+ User: "already",
357+ Email: "already@populated.com",
358+ Groups: nil,
359+ IDToken: idToken,
360+ AccessToken: accessToken,
361+ RefreshToken: refreshToken,
362+ },
363+ EmailClaim: "email",
364+ GroupsClaim: "roles",
365+ ProfileJSON: map[string]interface{}{
366+ "email": "new@thing.com",
367+ "roles": []string{"new", "thing", "roles"},
368+ },
369+ ExpectedError: nil,
370+ ExpectedSession: &sessions.SessionState{
371+ User: "already",
372+ Email: "already@populated.com",
373+ Groups: []string{"new", "thing", "roles"},
374+ IDToken: idToken,
375+ AccessToken: accessToken,
376+ RefreshToken: refreshToken,
377+ },
378+ },
379+ "Missing Groups String Profile URL Response": {
380+ ExistingSession: &sessions.SessionState{
381+ User: "already",
382+ Email: "already@populated.com",
383+ Groups: nil,
384+ IDToken: idToken,
385+ AccessToken: accessToken,
386+ RefreshToken: refreshToken,
387+ },
388+ EmailClaim: "email",
389+ GroupsClaim: "groups",
390+ ProfileJSON: map[string]interface{}{
391+ "email": "new@thing.com",
392+ "groups": "singleton",
393+ },
394+ ExpectedError: nil,
395+ ExpectedSession: &sessions.SessionState{
396+ User: "already",
397+ Email: "already@populated.com",
398+ Groups: []string{"singleton"},
399+ IDToken: idToken,
400+ AccessToken: accessToken,
401+ RefreshToken: refreshToken,
402+ },
403+ },
404+ "Missing Groups in both Claims and Profile URL": {
405+ ExistingSession: &sessions.SessionState{
406+ User: "already",
407+ Email: "already@populated.com",
408+ IDToken: idToken,
409+ AccessToken: accessToken,
410+ RefreshToken: refreshToken,
411+ },
412+ EmailClaim: "email",
413+ GroupsClaim: "groups",
414+ ProfileJSON: map[string]interface{}{
415+ "email": "new@thing.com",
416+ },
417+ ExpectedError: nil,
418+ ExpectedSession: &sessions.SessionState{
419+ User: "already",
420+ Email: "already@populated.com",
421+ IDToken: idToken,
422+ AccessToken: accessToken,
423+ RefreshToken: refreshToken,
424+ },
425+ },
426+ }
427+ for testName, tc := range testCases {
428+ t.Run(testName, func(t *testing.T) {
429+ jsonResp, err := json.Marshal(tc.ProfileJSON)
430+ assert.NoError(t, err)
431+
432+ server, provider := newTestOIDCSetup(jsonResp)
433+ provider.ProfileURL, err = url.Parse(server.URL)
434+ assert.NoError(t, err)
435+
436+ provider.EmailClaim = tc.EmailClaim
437+ provider.GroupsClaim = tc.GroupsClaim
438+ defer server.Close()
439+
440+ err = provider.EnrichSession(context.Background(), tc.ExistingSession)
441+ assert.Equal(t, tc.ExpectedError, err)
442+ assert.Equal(t, *tc.ExpectedSession, *tc.ExistingSession)
443+ })
444+ }
445+}
446+
236447func TestOIDCProviderRefreshSessionIfNeededWithoutIdToken(t *testing.T) {
237448
238449 idToken, _ := newSignedTestIDToken(defaultIDToken)
@@ -243,7 +454,7 @@ func TestOIDCProviderRefreshSessionIfNeededWithoutIdToken(t *testing.T) {
243454 RefreshToken: refreshToken,
244455 })
245456
246− server, provider := newTestSetup(body)
457+ server, provider := newTestOIDCSetup(body)
247458 defer server.Close()
248459
249460 existingSession := &sessions.SessionState{
@@ -277,7 +488,7 @@ func TestOIDCProviderRefreshSessionIfNeededWithIdToken(t *testing.T) {
277488 IDToken: idToken,
278489 })
279490
280− server, provider := newTestSetup(body)
491+ server, provider := newTestOIDCSetup(body)
281492 defer server.Close()
282493
283494 existingSession := &sessions.SessionState{
@@ -300,48 +511,45 @@ func TestOIDCProviderRefreshSessionIfNeededWithIdToken(t *testing.T) {
300511}
301512
302513func TestOIDCProviderCreateSessionFromToken(t *testing.T) {
303− const profileURLEmail = "janed@me.com"
304−
305514 testCases := map[string]struct {
306515 IDToken idTokenClaims
307516 GroupsClaim string
308517 ExpectedUser string
309518 ExpectedEmail string
310− ExpectedGroups interface{}
519+ ExpectedGroups []string
311520 }{
312521 "Default IDToken": {
313522 IDToken: defaultIDToken,
314523 GroupsClaim: "groups",
315− ExpectedUser: defaultIDToken.Subject,
316− ExpectedEmail: defaultIDToken.Email,
524+ ExpectedUser: "123456789",
525+ ExpectedEmail: "janed@me.com",
317526 ExpectedGroups: []string{"test:a", "test:b"},
318527 },
319528 "Minimal IDToken with no email claim": {
320529 IDToken: minimalIDToken,
321530 GroupsClaim: "groups",
322− ExpectedUser: minimalIDToken.Subject,
323− ExpectedEmail: minimalIDToken.Subject,
324− ExpectedGroups: []string{},
531+ ExpectedUser: "123456789",
532+ ExpectedEmail: "123456789",
533+ ExpectedGroups: nil,
325534 },
326535 "Custom Groups Claim": {
327536 IDToken: defaultIDToken,
328− GroupsClaim: "other_groups",
329− ExpectedUser: defaultIDToken.Subject,
330− ExpectedEmail: defaultIDToken.Email,
537+ GroupsClaim: "roles",
538+ ExpectedUser: "123456789",
539+ ExpectedEmail: "janed@me.com",
331540 ExpectedGroups: []string{"test:c", "test:d"},
332541 },
333− "Custom Groups Claim2": {
334− IDToken: customGroupClaimIDToken,
542+ "Complex Groups Claim": {
543+ IDToken: complexGroupsIDToken,
335544 GroupsClaim: "groups",
336− ExpectedUser: customGroupClaimIDToken.Subject,
337− ExpectedEmail: customGroupClaimIDToken.Email,
545+ ExpectedUser: "123456789",
546+ ExpectedEmail: "complex@claims.com",
338547 ExpectedGroups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"},
339548 },
340549 }
341550 for testName, tc := range testCases {
342551 t.Run(testName, func(t *testing.T) {
343− jsonResp := []byte(fmt.Sprintf(`{"email":"%s"}`, profileURLEmail))
344− server, provider := newTestSetup(jsonResp)
552+ server, provider := newTestOIDCSetup([]byte(`{}`))
345553 provider.GroupsClaim = tc.GroupsClaim
346554 defer server.Close()
347555
@@ -353,75 +561,10 @@ func TestOIDCProviderCreateSessionFromToken(t *testing.T) {
353561
354562 assert.Equal(t, tc.ExpectedUser, ss.User)
355563 assert.Equal(t, tc.ExpectedEmail, ss.Email)
564+ assert.Equal(t, tc.ExpectedGroups, ss.Groups)
356565 assert.Equal(t, rawIDToken, ss.IDToken)
357566 assert.Equal(t, rawIDToken, ss.AccessToken)
358− assert.Equal(t, tc.ExpectedGroups, ss.Groups)
359567 assert.Equal(t, "", ss.RefreshToken)
360568 })
361569 }
362570}
363−
364−func TestOIDCProvider_findVerifiedIdToken(t *testing.T) {
365−
366− server, provider := newTestSetup([]byte(""))
367−
368− defer server.Close()
369−
370− token := newOauth2Token()
371− signedIDToken, _ := newSignedTestIDToken(defaultIDToken)
372− tokenWithIDToken := token.WithExtra(map[string]interface{}{
373− "id_token": signedIDToken,
374− })
375−
376− verifiedIDToken, err := provider.findVerifiedIDToken(context.Background(), tokenWithIDToken)
377− assert.Equal(t, true, err == nil)
378− if verifiedIDToken == nil {
379− t.Fatal("verifiedIDToken is nil")
380− }
381− assert.Equal(t, defaultIDToken.Issuer, verifiedIDToken.Issuer)
382− assert.Equal(t, defaultIDToken.Subject, verifiedIDToken.Subject)
383−
384− // When the validation fails the response should be nil
385− defaultIDToken.Id = "this-id-fails-validation"
386− signedIDToken, _ = newSignedTestIDToken(defaultIDToken)
387− tokenWithIDToken = token.WithExtra(map[string]interface{}{
388− "id_token": signedIDToken,
389− })
390−
391− verifiedIDToken, err = provider.findVerifiedIDToken(context.Background(), tokenWithIDToken)
392− assert.Equal(t, errors.New("failed to verify signature: the validation failed for subject [123456789]"), err)
393− assert.Equal(t, true, verifiedIDToken == nil)
394−
395− // When there is no id token in the oauth token
396− verifiedIDToken, err = provider.findVerifiedIDToken(context.Background(), newOauth2Token())
397− assert.Equal(t, nil, err)
398− assert.Equal(t, true, verifiedIDToken == nil)
399−}
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/provider_data.go+130−1
@@ -1,12 +1,23 @@
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"
16+)
17+
18+const (
19+ OIDCEmailClaim = "email"
20+ OIDCGroupsClaim = "groups"
1021)
1122
1223// ProviderData contains information required to configure all implementations
@@ -27,7 +38,12 @@ type ProviderData struct {
2738 ClientSecretFile string
2839 Scope string
2940 Prompt string
30− Verifier *oidc.IDTokenVerifier
41+
42+ // Common OIDC options for any OIDC-based providers to consume
43+ AllowUnverifiedEmail bool
44+ EmailClaim string
45+ GroupsClaim string
46+ Verifier *oidc.IDTokenVerifier
3147
3248 // Universal Group authorization data structure
3349 // any provider can set to consume
@@ -94,3 +110,116 @@ func defaultURL(u *url.URL, d *url.URL) *url.URL {
94110 }
95111 return &url.URL{}
96112}
113+
114+// ****************************************************************************
115+// These private OIDC helper methods are available to any providers that are
116+// OIDC compliant
117+// ****************************************************************************
118+
119+// OIDCClaims is a struct to unmarshal the OIDC claims from an ID Token payload
120+type OIDCClaims struct {
121+ Subject string `json:"sub"`
122+ Email string `json:"-"`
123+ Groups []string `json:"-"`
124+ Verified *bool `json:"email_verified"`
125+
126+ raw map[string]interface{}
127+}
128+
129+func (p *ProviderData) verifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
130+ rawIDToken := getIDToken(token)
131+ if strings.TrimSpace(rawIDToken) == "" {
132+ return nil, ErrMissingIDToken
133+ }
134+ if p.Verifier == nil {
135+ return nil, ErrMissingOIDCVerifier
136+ }
137+ return p.Verifier.Verify(ctx, rawIDToken)
138+}
139+
140+// buildSessionFromClaims uses IDToken claims to populate a fresh SessionState
141+// with non-Token related fields.
142+func (p *ProviderData) buildSessionFromClaims(idToken *oidc.IDToken) (*sessions.SessionState, error) {
143+ ss := &sessions.SessionState{}
144+
145+ if idToken == nil {
146+ return ss, nil
147+ }
148+
149+ claims, err := p.getClaims(idToken)
150+ if err != nil {
151+ return nil, fmt.Errorf("couldn't extract claims from id_token (%v)", err)
152+ }
153+
154+ ss.User = claims.Subject
155+ ss.Email = claims.Email
156+ ss.Groups = claims.Groups
157+
158+ // TODO (@NickMeves) Deprecate for dynamic claim to session mapping
159+ if pref, ok := claims.raw["preferred_username"].(string); ok {
160+ ss.PreferredUsername = pref
161+ }
162+
163+ // `email_verified` must be present and explicitly set to `false` to be
164+ // considered unverified.
165+ verifyEmail := (p.EmailClaim == OIDCEmailClaim) && !p.AllowUnverifiedEmail
166+ if verifyEmail && claims.Verified != nil && !*claims.Verified {
167+ return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email)
168+ }
169+
170+ return ss, nil
171+}
172+
173+// getClaims extracts IDToken claims into an OIDCClaims
174+func (p *ProviderData) getClaims(idToken *oidc.IDToken) (*OIDCClaims, error) {
175+ claims := &OIDCClaims{}
176+
177+ // Extract default claims.
178+ if err := idToken.Claims(&claims); err != nil {
179+ return nil, fmt.Errorf("failed to parse default id_token claims: %v", err)
180+ }
181+ // Extract custom claims.
182+ if err := idToken.Claims(&claims.raw); err != nil {
183+ return nil, fmt.Errorf("failed to parse all id_token claims: %v", err)
184+ }
185+
186+ email := claims.raw[p.EmailClaim]
187+ if email != nil {
188+ claims.Email = fmt.Sprint(email)
189+ }
190+ claims.Groups = p.extractGroups(claims.raw)
191+
192+ return claims, nil
193+}
194+
195+// extractGroups extracts groups from a claim to a list in a type safe manner.
196+// If the claim isn't present, `nil` is returned. If the groups claim is
197+// present but empty, `[]string{}` is returned.
198+func (p *ProviderData) extractGroups(claims map[string]interface{}) []string {
199+ rawClaim, ok := claims[p.GroupsClaim]
200+ if !ok {
201+ return nil
202+ }
203+
204+ // Handle traditional list-based groups as well as non-standard singleton
205+ // based groups. Both variants support complex objects if needed.
206+ var claimGroups []interface{}
207+ switch raw := rawClaim.(type) {
208+ case []interface{}:
209+ claimGroups = raw
210+ case interface{}:
211+ claimGroups = []interface{}{raw}
212+ }
213+
214+ groups := []string{}
215+ for _, rawGroup := range claimGroups {
216+ formattedGroup, err := formatGroup(rawGroup)
217+ if err != nil {
218+ logger.Errorf("Warning: unable to format group of type %s with error %s",
219+ reflect.TypeOf(rawGroup), err)
220+ continue
221+ }
222+ groups = append(groups, formattedGroup)
223+ }
224+ return groups
225+}
providers/provider_data_test.gonew file+437
@@ -0,0 +1,437 @@
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+ Verifier bool
141+ ExpectIDToken bool
142+ ExpectedError error
143+ }{
144+ "Valid ID Token": {
145+ IDToken: &defaultIDToken,
146+ Verifier: true,
147+ ExpectIDToken: true,
148+ ExpectedError: nil,
149+ },
150+ "Invalid ID Token": {
151+ IDToken: &failureIDToken,
152+ Verifier: true,
153+ ExpectIDToken: false,
154+ ExpectedError: errors.New("failed to verify signature: the validation failed for subject [123456789]"),
155+ },
156+ "Missing ID Token": {
157+ IDToken: nil,
158+ Verifier: true,
159+ ExpectIDToken: false,
160+ ExpectedError: ErrMissingIDToken,
161+ },
162+ "OIDC Verifier not Configured": {
163+ IDToken: &defaultIDToken,
164+ Verifier: false,
165+ ExpectIDToken: false,
166+ ExpectedError: ErrMissingOIDCVerifier,
167+ },
168+ }
169+
170+ for testName, tc := range testCases {
171+ t.Run(testName, func(t *testing.T) {
172+ g := NewWithT(t)
173+
174+ token := newTestOauth2Token()
175+ if tc.IDToken != nil {
176+ idToken, err := newSignedTestIDToken(*tc.IDToken)
177+ g.Expect(err).ToNot(HaveOccurred())
178+ token = token.WithExtra(map[string]interface{}{
179+ "id_token": idToken,
180+ })
181+ }
182+
183+ provider := &ProviderData{}
184+ if tc.Verifier {
185+ provider.Verifier = oidc.NewVerifier(
186+ oidcIssuer,
187+ mockJWKS{},
188+ &oidc.Config{ClientID: oidcClientID},
189+ )
190+ }
191+ verified, err := provider.verifyIDToken(context.Background(), token)
192+ if err != nil {
193+ g.Expect(err).To(Equal(tc.ExpectedError))
194+ }
195+
196+ if tc.ExpectIDToken {
197+ g.Expect(verified).ToNot(BeNil())
198+ g.Expect(*verified).To(BeAssignableToTypeOf(oidc.IDToken{}))
199+ } else {
200+ g.Expect(verified).To(BeNil())
201+ }
202+ })
203+ }
204+}
205+
206+func TestProviderData_buildSessionFromClaims(t *testing.T) {
207+ testCases := map[string]struct {
208+ IDToken idTokenClaims
209+ AllowUnverified bool
210+ EmailClaim string
211+ GroupsClaim string
212+ ExpectedError error
213+ ExpectedSession *sessions.SessionState
214+ }{
215+ "Standard": {
216+ IDToken: defaultIDToken,
217+ AllowUnverified: false,
218+ EmailClaim: "email",
219+ GroupsClaim: "groups",
220+ ExpectedSession: &sessions.SessionState{
221+ User: "123456789",
222+ Email: "janed@me.com",
223+ Groups: []string{"test:a", "test:b"},
224+ PreferredUsername: "Jane Dobbs",
225+ },
226+ },
227+ "Unverified Denied": {
228+ IDToken: unverifiedIDToken,
229+ AllowUnverified: false,
230+ EmailClaim: "email",
231+ GroupsClaim: "groups",
232+ ExpectedError: errors.New("email in id_token (unverified@email.com) isn't verified"),
233+ },
234+ "Unverified Allowed": {
235+ IDToken: unverifiedIDToken,
236+ AllowUnverified: true,
237+ EmailClaim: "email",
238+ GroupsClaim: "groups",
239+ ExpectedSession: &sessions.SessionState{
240+ User: "123456789",
241+ Email: "unverified@email.com",
242+ Groups: []string{"test:a", "test:b"},
243+ PreferredUsername: "Mystery Man",
244+ },
245+ },
246+ "Complex Groups": {
247+ IDToken: complexGroupsIDToken,
248+ AllowUnverified: true,
249+ EmailClaim: "email",
250+ GroupsClaim: "groups",
251+ ExpectedSession: &sessions.SessionState{
252+ User: "123456789",
253+ Email: "complex@claims.com",
254+ Groups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"},
255+ PreferredUsername: "Complex Claim",
256+ },
257+ },
258+ "Email Claim Switched": {
259+ IDToken: unverifiedIDToken,
260+ AllowUnverified: true,
261+ EmailClaim: "phone_number",
262+ GroupsClaim: "groups",
263+ ExpectedSession: &sessions.SessionState{
264+ User: "123456789",
265+ Email: "+4025205729",
266+ Groups: []string{"test:a", "test:b"},
267+ PreferredUsername: "Mystery Man",
268+ },
269+ },
270+ "Email Claim Switched to Non String": {
271+ IDToken: unverifiedIDToken,
272+ AllowUnverified: true,
273+ EmailClaim: "roles",
274+ GroupsClaim: "groups",
275+ ExpectedSession: &sessions.SessionState{
276+ User: "123456789",
277+ Email: "[test:c test:d]",
278+ Groups: []string{"test:a", "test:b"},
279+ PreferredUsername: "Mystery Man",
280+ },
281+ },
282+ "Email Claim Non Existent": {
283+ IDToken: unverifiedIDToken,
284+ AllowUnverified: true,
285+ EmailClaim: "aksjdfhjksadh",
286+ GroupsClaim: "groups",
287+ ExpectedSession: &sessions.SessionState{
288+ User: "123456789",
289+ Email: "",
290+ Groups: []string{"test:a", "test:b"},
291+ PreferredUsername: "Mystery Man",
292+ },
293+ },
294+ "Groups Claim Switched": {
295+ IDToken: defaultIDToken,
296+ AllowUnverified: false,
297+ EmailClaim: "email",
298+ GroupsClaim: "roles",
299+ ExpectedSession: &sessions.SessionState{
300+ User: "123456789",
301+ Email: "janed@me.com",
302+ Groups: []string{"test:c", "test:d"},
303+ PreferredUsername: "Jane Dobbs",
304+ },
305+ },
306+ "Groups Claim Non Existent": {
307+ IDToken: defaultIDToken,
308+ AllowUnverified: false,
309+ EmailClaim: "email",
310+ GroupsClaim: "alskdjfsalkdjf",
311+ ExpectedSession: &sessions.SessionState{
312+ User: "123456789",
313+ Email: "janed@me.com",
314+ Groups: nil,
315+ PreferredUsername: "Jane Dobbs",
316+ },
317+ },
318+ }
319+ for testName, tc := range testCases {
320+ t.Run(testName, func(t *testing.T) {
321+ g := NewWithT(t)
322+
323+ provider := &ProviderData{
324+ Verifier: oidc.NewVerifier(
325+ oidcIssuer,
326+ mockJWKS{},
327+ &oidc.Config{ClientID: oidcClientID},
328+ ),
329+ }
330+ provider.AllowUnverifiedEmail = tc.AllowUnverified
331+ provider.EmailClaim = tc.EmailClaim
332+ provider.GroupsClaim = tc.GroupsClaim
333+
334+ rawIDToken, err := newSignedTestIDToken(tc.IDToken)
335+ g.Expect(err).ToNot(HaveOccurred())
336+
337+ idToken, err := provider.Verifier.Verify(context.Background(), rawIDToken)
338+ g.Expect(err).ToNot(HaveOccurred())
339+
340+ ss, err := provider.buildSessionFromClaims(idToken)
341+ if err != nil {
342+ g.Expect(err).To(Equal(tc.ExpectedError))
343+ }
344+ if ss != nil {
345+ g.Expect(ss).To(Equal(tc.ExpectedSession))
346+ }
347+ })
348+ }
349+}
350+
351+func TestProviderData_extractGroups(t *testing.T) {
352+ testCases := map[string]struct {
353+ Claims map[string]interface{}
354+ GroupsClaim string
355+ ExpectedGroups []string
356+ }{
357+ "Standard String Groups": {
358+ Claims: map[string]interface{}{
359+ "email": "this@does.not.matter.com",
360+ "groups": []interface{}{"three", "string", "groups"},
361+ },
362+ GroupsClaim: "groups",
363+ ExpectedGroups: []string{"three", "string", "groups"},
364+ },
365+ "Different Claim Name": {
366+ Claims: map[string]interface{}{
367+ "email": "this@does.not.matter.com",
368+ "roles": []interface{}{"three", "string", "roles"},
369+ },
370+ GroupsClaim: "roles",
371+ ExpectedGroups: []string{"three", "string", "roles"},
372+ },
373+ "Numeric Groups": {
374+ Claims: map[string]interface{}{
375+ "email": "this@does.not.matter.com",
376+ "groups": []interface{}{1, 2, 3},
377+ },
378+ GroupsClaim: "groups",
379+ ExpectedGroups: []string{"1", "2", "3"},
380+ },
381+ "Complex Groups": {
382+ Claims: map[string]interface{}{
383+ "email": "this@does.not.matter.com",
384+ "groups": []interface{}{
385+ map[string]interface{}{
386+ "groupId": "Admin Group Id",
387+ "roles": []string{"Admin"},
388+ },
389+ 12345,
390+ "Just::A::String",
391+ },
392+ },
393+ GroupsClaim: "groups",
394+ ExpectedGroups: []string{
395+ "{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}",
396+ "12345",
397+ "Just::A::String",
398+ },
399+ },
400+ "Missing Groups Claim Returns Nil": {
401+ Claims: map[string]interface{}{
402+ "email": "this@does.not.matter.com",
403+ },
404+ GroupsClaim: "groups",
405+ ExpectedGroups: nil,
406+ },
407+ "Non List Groups": {
408+ Claims: map[string]interface{}{
409+ "email": "this@does.not.matter.com",
410+ "groups": "singleton",
411+ },
412+ GroupsClaim: "groups",
413+ ExpectedGroups: []string{"singleton"},
414+ },
415+ }
416+ for testName, tc := range testCases {
417+ t.Run(testName, func(t *testing.T) {
418+ g := NewWithT(t)
419+
420+ provider := &ProviderData{
421+ Verifier: oidc.NewVerifier(
422+ oidcIssuer,
423+ mockJWKS{},
424+ &oidc.Config{ClientID: oidcClientID},
425+ ),
426+ }
427+ provider.GroupsClaim = tc.GroupsClaim
428+
429+ groups := provider.extractGroups(tc.Claims)
430+ if tc.ExpectedGroups != nil {
431+ g.Expect(groups).To(Equal(tc.ExpectedGroups))
432+ } else {
433+ g.Expect(groups).To(BeNil())
434+ }
435+ })
436+ }
437+}
providers/provider_default.go+8
@@ -22,6 +22,14 @@ var (
2222 // code
2323 ErrMissingCode = errors.New("missing code")
2424
25+ // ErrMissingIDToken is returned when an oidc.Token does not contain the
26+ // extra `id_token` field for an IDToken.
27+ ErrMissingIDToken = errors.New("missing id_token")
28+
29+ // ErrMissingOIDCVerifier is returned when a provider didn't set `Verifier`
30+ // but an attempt to call `Verifier.Verify` was about to be made.
31+ ErrMissingOIDCVerifier = errors.New("oidc verifier is not configured")
32+
2533 _ Provider = (*ProviderData)(nil)
2634)
2735
providers/util.go+43
@@ -1,9 +1,13 @@
11package providers
22
33import (
4+ "encoding/json"
45 "fmt"
56 "net/http"
67 "net/url"
8+
9+ "github.com/bitly/go-simplejson"
10+ "golang.org/x/oauth2"
711)
812
913const (
@@ -55,3 +59,42 @@ func makeLoginURL(p *ProviderData, redirectURI, state string, extraParams url.Va
5559 a.RawQuery = params.Encode()
5660 return a
5761}
62+
63+// getIDToken extracts an IDToken stored in the `Extra` fields of an
64+// oauth2.Token
65+func getIDToken(token *oauth2.Token) string {
66+ idToken, ok := token.Extra("id_token").(string)
67+ if !ok {
68+ return ""
69+ }
70+ return idToken
71+}
72+
73+// formatGroup coerces an OIDC groups claim into a string
74+// If it is non-string, marshal it into JSON.
75+func formatGroup(rawGroup interface{}) (string, error) {
76+ if group, ok := rawGroup.(string); ok {
77+ return group, nil
78+ }
79+
80+ jsonGroup, err := json.Marshal(rawGroup)
81+ if err != nil {
82+ return "", err
83+ }
84+ return string(jsonGroup), nil
85+}
86+
87+// coerceArray extracts a field from simplejson.Json that might be a
88+// singleton or a list and coerces it into a list.
89+func coerceArray(sj *simplejson.Json, key string) []interface{} {
90+ array, err := sj.Get(key).Array()
91+ if err == nil {
92+ return array
93+ }
94+
95+ single := sj.Get(key).Interface()
96+ if single == nil {
97+ return nil
98+ }
99+ return []interface{}{single}
100+}
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+}