@@ -2,18 +2,17 @@ package providers
5− "encoding/json"
5+ "errors"
11− oidc "github.com/coreos/go-oidc"
11+ "github.com/coreos/go-oidc"
12− "golang.org/x/oauth2"
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"
19const emailClaim = "email"
18const emailClaim = "email"
@@ -23,7 +22,7 @@ type OIDCProvider struct {
23 *ProviderData
22 *ProviderData
25 AllowUnverifiedEmail bool
24 AllowUnverifiedEmail bool
26− UserIDClaim string
25+ EmailClaim string
27 GroupsClaim string
26 GroupsClaim string
@@ -36,10 +35,10 @@ func NewOIDCProvider(p *ProviderData) *OIDCProvider {
36var _ Provider = (*OIDCProvider)(nil)
35var _ Provider = (*OIDCProvider)(nil)
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
45 c := oauth2.Config{
44 c := oauth2.Config{
@@ -52,23 +51,74 @@ func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code string) (s
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)
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+}
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() == "" {
65+ return errors.New("id_token did not contain an email and profileURL is not defined")
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+ }
78+ // If a mandatory email wasn't set, error at this point.
80+ return errors.New("neither the id_token nor the profileURL set an email")
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()).
90+ WithHeaders(makeOIDCHeader(s.AccessToken)).
67 if err != nil {
93 if err != nil {
68− return nil, fmt.Errorf("unable to update session: %v", err)
94+ return err
97+ email, err := respJSON.Get(p.EmailClaim).String()
98+ if err == nil && s.Email == "" {
102+ // Handle array & singleton groups cases
103+ if len(s.Groups) == 0 {
104+ groups, err := respJSON.Get(p.GroupsClaim).StringArray()
108+ group, err := respJSON.Get(p.GroupsClaim).String()
110+ s.Groups = []string{group}
71− return
115+ return nil
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)
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)
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
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
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)
112− // in the token refresh response the id_token is optional
113− idToken, err := p.findVerifiedIDToken(ctx, token)
115− return fmt.Errorf("unable to extract id_token from response: %v", err)
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)
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
138− return
185+ return nil
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
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
155−func (p *OIDCProvider) createSessionState(ctx context.Context, token *oauth2.Token, idToken *oidc.IDToken) (*sessions.SessionState, error) {
157− var newSession *sessions.SessionState
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
163− newSession, err = p.createSessionStateInternal(ctx, idToken, token)
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
174− return newSession, nil
210+ return ss, nil
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)
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")
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
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
235+ created := time.Now()
236+ ss.CreatedAt = &created
237+ ss.ExpiresOn = &token.Expiry
193− return newSession, nil
239+ return ss, nil
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)
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{}
200 if idToken == nil {
255 if idToken == nil {
201− return newSession, nil
256+ return ss, nil
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)
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
213− newSession.Email = claims.UserID // TODO Rename SessionState.Email to .UserID in the near future
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
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)
224− return newSession, nil
278+ return ss, nil
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"`
287+ rawClaims map[string]interface{}
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{}
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)
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)
250− claims.Groups = p.extractGroupsFromRawClaims(claims.rawClaims)
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
257− claims.UserID = claims.Subject
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)
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).
271− WithHeaders(makeOIDCHeader(token.AccessToken)).
278− userID, err := respJSON.Get(p.UserIDClaim).String()
280− return nil, fmt.Errorf("neither id_token nor userinfo endpoint contained user ID claim (%q)", p.UserIDClaim)
283− claims.UserID = userID
307+ claims.Groups = p.extractGroups(claims.rawClaims)
286 return claims, nil
309 return claims, nil
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{}
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)
300 groups = append(groups, formattedGroup)
323 groups = append(groups, formattedGroup)
304 return groups
326 return groups
308−func formatGroup(rawGroup interface{}) (string, error) {
309− group, ok := rawGroup.(string)
311− jsonGroup, err := json.Marshal(rawGroup)
315− group = string(jsonGroup)
320−type OIDCClaims struct {
321− rawClaims map[string]interface{}
323− Subject string `json:"sub"`
324− Verified *bool `json:"email_verified"`
325− PreferredUsername string `json:"preferred_username"`
326− Groups []string `json:"-"`