git.iko.soy/typeable/oauth2-proxy
on master help sign in
Feature/configurable userid claim minimal (#499)

* Add -user-id-claim to support other claims than email Fix #431 - This is a minimal change to allow the user to configure which claim is the source of the "user ID". - Add the option `user-id-claim` (defaults to email) - OIDC extracts this claim into session.Email (to be renamed later) - providers: add `CreateSessionStateFromBearerToken` with a default impl taken from `GetJwtSession` and overridden by oidc to respect `user-id-claim` Once #466 is merged, I can continue to rename SessionState.Email to .UserID and add HTTP headers with a corresponding name. * Apply suggestions from code review Co-Authored-By: Joel Speed <Joel.speed@hotmail.co.uk> * Review feedback: Don't extract claims manually Instead, parse them twice - it might be sligtly slower but less bug-prone as the code evolves. * Fix indentation Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk>

commit1961424561c1941f5845ec9fd6f5da3fbfcc0029 authorJakub Holy <jakub.holy@telia.no> dateTue 28 Apr 2020, 08:46 (6 years ago) parent4d21b8a
9 files changed+144−53
CHANGELOG.md+1 docs/configuration/configuration.md+1 main.go+2 oauthproxy.go+2−30 options.go+3 providers/oidc.go+74−23 providers/oidc_test.go+23 providers/provider_default.go+36 providers/providers.go+2
CHANGELOG.md+1
@@ -19,6 +19,7 @@
1919
2020## Changes since v5.1.0
2121
22+- [#499](https://github.com/oauth2-proxy/oauth2-proxy/pull/469) Add `-user-id-claim` to support generic claims in addition to email
2223- [#486](https://github.com/oauth2-proxy/oauth2-proxy/pull/486) Add new linters (@johejo)
2324- [#440](https://github.com/oauth2-proxy/oauth2-proxy/pull/440) Switch Azure AD Graph API to Microsoft Graph API (@johejo)
2425- [#453](https://github.com/oauth2-proxy/oauth2-proxy/pull/453) Prevent browser caching during auth flow (@johejo)
docs/configuration/configuration.md+1
@@ -119,6 +119,7 @@ An example [oauth2-proxy.cfg]({{ site.gitweb }}/contrib/oauth2-proxy.cfg.example
119119| `-tls-cert-file` | string | path to certificate file | |
120120| `-tls-key-file` | string | path to private key file | |
121121| `-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 | |
122+| `-user-id-claim` | string | which claim contains the user ID | \["email"\] |
122123| `-validate-url` | string | Access token validation endpoint | |
123124| `-version` | n/a | print version string | |
124125| `-whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (eg `.example.com`) | |
main.go+2
@@ -147,6 +147,8 @@ func main() {
147147 flagSet.String("pubjwk-url", "", "JWK pubkey access endpoint: required by login.gov")
148148 flagSet.Bool("gcp-healthchecks", false, "Enable GCP/GKE healthcheck endpoints")
149149
150+ flagSet.String("user-id-claim", "email", "which claim contains the user ID")
151+
150152 flagSet.Parse(os.Args[1:])
151153
152154 if *showVersion {
oauthproxy.go+2−30
@@ -1115,6 +1115,7 @@ func (p *OAuthProxy) ErrorJSON(rw http.ResponseWriter, code int) {
11151115}
11161116
11171117// GetJwtSession loads a session based on a JWT token in the authorization header.
1118+// (see the config options skip-jwt-bearer-tokens and extra-jwt-issuers)
11181119func (p *OAuthProxy) GetJwtSession(req *http.Request) (*sessionsapi.SessionState, error) {
11191120 rawBearerToken, err := p.findBearerToken(req)
11201121 if err != nil {
@@ -1122,7 +1123,6 @@ func (p *OAuthProxy) GetJwtSession(req *http.Request) (*sessionsapi.SessionState
11221123 }
11231124
11241125 ctx := context.Background()
1125− var session *sessionsapi.SessionState
11261126 for _, verifier := range p.jwtBearerVerifiers {
11271127 bearerToken, err := verifier.Verify(ctx, rawBearerToken)
11281128
@@ -1131,35 +1131,7 @@ func (p *OAuthProxy) GetJwtSession(req *http.Request) (*sessionsapi.SessionState
11311131 continue
11321132 }
11331133
1134− var claims struct {
1135− Subject string `json:"sub"`
1136− Email string `json:"email"`
1137− Verified *bool `json:"email_verified"`
1138− PreferredUsername string `json:"preferred_username"`
1139− }
1140−
1141− if err := bearerToken.Claims(&claims); err != nil {
1142− return nil, fmt.Errorf("failed to parse bearer token claims: %v", err)
1143− }
1144−
1145− if claims.Email == "" {
1146− claims.Email = claims.Subject
1147− }
1148−
1149− if claims.Verified != nil && !*claims.Verified {
1150− return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email)
1151− }
1152−
1153− session = &sessionsapi.SessionState{
1154− AccessToken: rawBearerToken,
1155− IDToken: rawBearerToken,
1156− RefreshToken: "",
1157− ExpiresOn: bearerToken.Expiry,
1158− Email: claims.Email,
1159− User: claims.Email,
1160− PreferredUsername: claims.PreferredUsername,
1161− }
1162− return session, nil
1134+ return p.provider.CreateSessionStateFromBearerToken(rawBearerToken, bearerToken)
11631135 }
11641136 return nil, fmt.Errorf("unable to verify jwt token %s", req.Header.Get("Authorization"))
11651137}
options.go+3
@@ -107,6 +107,7 @@ type Options struct {
107107 Scope string `flag:"scope" cfg:"scope" env:"OAUTH2_PROXY_SCOPE"`
108108 Prompt string `flag:"prompt" cfg:"prompt" env:"OAUTH2_PROXY_PROMPT"`
109109 ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt" env:"OAUTH2_PROXY_APPROVAL_PROMPT"` // Deprecated by OIDC 1.0
110+ UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim" env:"OAUTH2_PROXY_USER_ID_CLAIM"`
110111
111112 // Configuration values for logging
112113 LoggingFilename string `flag:"logging-filename" cfg:"logging_filename" env:"OAUTH2_PROXY_LOGGING_FILENAME"`
@@ -179,6 +180,7 @@ func NewOptions() *Options {
179180 PreferEmailToUser: false,
180181 Prompt: "", // Change to "login" when ApprovalPrompt officially deprecated
181182 ApprovalPrompt: "force",
183+ UserIDClaim: "email",
182184 InsecureOIDCAllowUnverifiedEmail: false,
183185 SkipOIDCDiscovery: false,
184186 LoggingFilename: "",
@@ -500,6 +502,7 @@ func parseProviderInfo(o *Options, msgs []string) []string {
500502 p.SetRepository(o.BitbucketRepository)
501503 case *providers.OIDCProvider:
502504 p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
505+ p.UserIDClaim = o.UserIDClaim
503506 if o.oidcVerifier == nil {
504507 msgs = append(msgs, "oidc provider requires an oidc issuer URL")
505508 } else {
providers/oidc.go+74−23
@@ -14,12 +14,15 @@ import (
1414 "github.com/oauth2-proxy/oauth2-proxy/pkg/requests"
1515)
1616
17+const emailClaim = "email"
18+
1719// OIDCProvider represents an OIDC based Identity Provider
1820type OIDCProvider struct {
1921 *ProviderData
2022
2123 Verifier *oidc.IDTokenVerifier
2224 AllowUnverifiedEmail bool
25+ UserIDClaim string
2326}
2427
2528// NewOIDCProvider initiates a new OIDCProvider
@@ -148,24 +151,15 @@ func (p *OIDCProvider) findVerifiedIDToken(ctx context.Context, token *oauth2.To
148151
149152func (p *OIDCProvider) createSessionState(token *oauth2.Token, idToken *oidc.IDToken) (*sessions.SessionState, error) {
150153
151− newSession := &sessions.SessionState{}
154+ var newSession *sessions.SessionState
152155
153− if idToken != nil {
154− claims, err := findClaimsFromIDToken(idToken, token.AccessToken, p.ProfileURL.String())
156+ if idToken == nil {
157+ newSession = &sessions.SessionState{}
158+ } else {
159+ var err error
160+ newSession, err = p.createSessionStateInternal(token.Extra("id_token").(string), idToken, token)
155161 if err != nil {
156− return nil, fmt.Errorf("couldn't extract claims from id_token (%e)", err)
157− }
158−
159− if claims != nil {
160−
161− if !p.AllowUnverifiedEmail && claims.Verified != nil && !*claims.Verified {
162− return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email)
163− }
164−
165− newSession.IDToken = token.Extra("id_token").(string)
166− newSession.Email = claims.Email
167− newSession.User = claims.Subject
168− newSession.PreferredUsername = claims.PreferredUsername
162+ return nil, err
169163 }
170164 }
171165
@@ -176,6 +170,52 @@ func (p *OIDCProvider) createSessionState(token *oauth2.Token, idToken *oidc.IDT
176170 return newSession, nil
177171}
178172
173+func (p *OIDCProvider) CreateSessionStateFromBearerToken(rawIDToken string, idToken *oidc.IDToken) (*sessions.SessionState, error) {
174+ newSession, err := p.createSessionStateInternal(rawIDToken, idToken, nil)
175+ if err != nil {
176+ return nil, err
177+ }
178+
179+ newSession.AccessToken = rawIDToken
180+ newSession.IDToken = rawIDToken
181+ newSession.RefreshToken = ""
182+ newSession.ExpiresOn = idToken.Expiry
183+
184+ return newSession, nil
185+}
186+
187+func (p *OIDCProvider) createSessionStateInternal(rawIDToken string, idToken *oidc.IDToken, token *oauth2.Token) (*sessions.SessionState, error) {
188+
189+ newSession := &sessions.SessionState{}
190+
191+ if idToken == nil {
192+ return newSession, nil
193+ }
194+ accessToken := ""
195+ if token != nil {
196+ accessToken = token.AccessToken
197+ }
198+
199+ claims, err := p.findClaimsFromIDToken(idToken, accessToken, p.ProfileURL.String())
200+ if err != nil {
201+ return nil, fmt.Errorf("couldn't extract claims from id_token (%e)", err)
202+ }
203+
204+ newSession.IDToken = rawIDToken
205+
206+ newSession.Email = claims.UserID // TODO Rename SessionState.Email to .UserID in the near future
207+
208+ newSession.User = claims.Subject
209+ newSession.PreferredUsername = claims.PreferredUsername
210+
211+ verifyEmail := (p.UserIDClaim == emailClaim) && !p.AllowUnverifiedEmail
212+ if verifyEmail && claims.Verified != nil && !*claims.Verified {
213+ return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.UserID)
214+ }
215+
216+ return newSession, nil
217+}
218+
179219// ValidateSessionState checks that the session's IDToken is still valid
180220func (p *OIDCProvider) ValidateSessionState(s *sessions.SessionState) bool {
181221 ctx := context.Background()
@@ -190,15 +230,25 @@ func getOIDCHeader(accessToken string) http.Header {
190230 return header
191231}
192232
193−func findClaimsFromIDToken(idToken *oidc.IDToken, accessToken string, profileURL string) (*OIDCClaims, error) {
233+func (p *OIDCProvider) findClaimsFromIDToken(idToken *oidc.IDToken, accessToken string, profileURL string) (*OIDCClaims, error) {
194234
195− // Extract custom claims.
196235 claims := &OIDCClaims{}
197− if err := idToken.Claims(claims); err != nil {
198− return nil, fmt.Errorf("failed to parse id_token claims: %v", err)
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+ return nil, fmt.Errorf("claims did not contains the required user-id-claim '%s'", p.UserIDClaim)
199248 }
249+ claims.UserID = fmt.Sprint(userID)
200250
201− if claims.Email == "" {
251+ if p.UserIDClaim == emailClaim && claims.UserID == "" {
202252 if profileURL == "" {
203253 return nil, fmt.Errorf("id_token did not contain an email")
204254 }
@@ -223,15 +273,16 @@ func findClaimsFromIDToken(idToken *oidc.IDToken, accessToken string, profileURL
223273 return nil, fmt.Errorf("neither id_token nor userinfo endpoint contained an email")
224274 }
225275
226− claims.Email = email
276+ claims.UserID = email
227277 }
228278
229279 return claims, nil
230280}
231281
232282type OIDCClaims struct {
283+ rawClaims map[string]interface{}
284+ UserID string
233285 Subject string `json:"sub"`
234− Email string `json:"email"`
235286 Verified *bool `json:"email_verified"`
236287 PreferredUsername string `json:"preferred_username"`
237288}
providers/oidc_test.go+23
@@ -31,6 +31,7 @@ const secret = "secret"
3131type idTokenClaims struct {
3232 Name string `json:"name,omitempty"`
3333 Email string `json:"email,omitempty"`
34+ Phone string `json:"phone_number,omitempty"`
3435 Picture string `json:"picture,omitempty"`
3536 jwt.StandardClaims
3637}
@@ -46,6 +47,7 @@ type redeemTokenResponse struct {
4647var defaultIDToken idTokenClaims = idTokenClaims{
4748 "Jane Dobbs",
4849 "janed@me.com",
50+ "+4798765432",
4951 "http://mugbook.com/janed/me.jpg",
5052 jwt.StandardClaims{
5153 Audience: "https://test.myapp.com",
@@ -106,6 +108,7 @@ func newOIDCProvider(serverURL *url.URL) *OIDCProvider {
106108 fakeKeySetStub{},
107109 &oidc.Config{ClientID: clientID},
108110 ),
111+ UserIDClaim: "email",
109112 }
110113
111114 return p
@@ -165,6 +168,26 @@ func TestOIDCProviderRedeem(t *testing.T) {
165168 assert.Equal(t, "123456789", session.User)
166169}
167170
171+func TestOIDCProviderRedeem_custom_userid(t *testing.T) {
172+
173+ idToken, _ := newSignedTestIDToken(defaultIDToken)
174+ body, _ := json.Marshal(redeemTokenResponse{
175+ AccessToken: accessToken,
176+ ExpiresIn: 10,
177+ TokenType: "Bearer",
178+ RefreshToken: refreshToken,
179+ IDToken: idToken,
180+ })
181+
182+ server, provider := newTestSetup(body)
183+ provider.UserIDClaim = "phone_number"
184+ defer server.Close()
185+
186+ session, err := provider.Redeem(provider.RedeemURL.String(), "code1234")
187+ assert.Equal(t, nil, err)
188+ assert.Equal(t, defaultIDToken.Phone, session.Email)
189+}
190+
168191func TestOIDCProviderRefreshSessionIfNeededWithoutIdToken(t *testing.T) {
169192
170193 idToken, _ := newSignedTestIDToken(defaultIDToken)
providers/provider_default.go+36
@@ -10,6 +10,8 @@ import (
1010 "net/url"
1111 "time"
1212
13+ "github.com/coreos/go-oidc"
14+
1315 "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/sessions"
1416 "github.com/oauth2-proxy/oauth2-proxy/pkg/encryption"
1517)
@@ -144,3 +146,37 @@ func (p *ProviderData) ValidateSessionState(s *sessions.SessionState) bool {
144146func (p *ProviderData) RefreshSessionIfNeeded(s *sessions.SessionState) (bool, error) {
145147 return false, nil
146148}
149+
150+func (p *ProviderData) CreateSessionStateFromBearerToken(rawIDToken string, idToken *oidc.IDToken) (*sessions.SessionState, error) {
151+ var claims struct {
152+ Subject string `json:"sub"`
153+ Email string `json:"email"`
154+ Verified *bool `json:"email_verified"`
155+ PreferredUsername string `json:"preferred_username"`
156+ }
157+
158+ if err := idToken.Claims(&claims); err != nil {
159+ return nil, fmt.Errorf("failed to parse bearer token claims: %v", err)
160+ }
161+
162+ if claims.Email == "" {
163+ claims.Email = claims.Subject
164+ }
165+
166+ if claims.Verified != nil && !*claims.Verified {
167+ return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email)
168+ }
169+
170+ newSession := &sessions.SessionState{
171+ Email: claims.Email,
172+ User: claims.Email,
173+ PreferredUsername: claims.PreferredUsername,
174+ }
175+
176+ newSession.AccessToken = rawIDToken
177+ newSession.IDToken = rawIDToken
178+ newSession.RefreshToken = ""
179+ newSession.ExpiresOn = idToken.Expiry
180+
181+ return newSession, nil
182+}
providers/providers.go+2
@@ -1,6 +1,7 @@
11package providers
22
33import (
4+ "github.com/coreos/go-oidc"
45 "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/sessions"
56 "github.com/oauth2-proxy/oauth2-proxy/pkg/encryption"
67)
@@ -18,6 +19,7 @@ type Provider interface {
1819 RefreshSessionIfNeeded(*sessions.SessionState) (bool, error)
1920 SessionFromCookie(string, *encryption.Cipher) (*sessions.SessionState, error)
2021 CookieForSession(*sessions.SessionState, *encryption.Cipher) (string, error)
22+ CreateSessionStateFromBearerToken(rawIDToken string, idToken *oidc.IDToken) (*sessions.SessionState, error)
2123}
2224
2325// New provides a new Provider based on the configured provider string