Repository: oauth2-proxy/oauth2-proxy
Pull request #700 (2020-07-27 22:00:17)

Allow OIDC Bearer Tokens without emails
This reverts to functionality before #499 where an OIDC
provider could be used with `--skip-jwt-bearer-tokens` and
tokens without an email or profileURL would still be valid.
This logic mirrors `middleware.createSessionStateFromBearerToken`
which used to be the universal logic before #499.

## Motivation and Context

Machine JWTs or applications that have JWTs without emails that used `--skip-jwt-bearer-token` to send an IDToken directly in a header to be validated (IE an OIDC PKSE CLI application that gets the IDToken client side) -- used to follow the same universal codepath that turned a generic IDToken into a session.

#499 Added richer support for OIDC claims and profileURL lookups, but it forced expectations on the format of the JWT that was stricter than before in `--skip-jwt-bearer-token` mode.

This change allows these v5.1.0 type tokens again instead of erroring out.

## How Has This Been Tested?

Unit Tests (+ new OIDC Bearer Token unit tests added for existing functionality)

## Checklist:

- [x] My change requires a change to the documentation or CHANGELOG.
- [x] I have updated the documentation/CHANGELOG accordingly.
- [x] I have created a feature (non-master) branch for my PR.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5361b2be..03e5de7e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@
 - [#718](https://github.com/oauth2-proxy/oauth2-proxy/pull/718) Allow Logging to stdout with separate Error Log Channel
 - [#690](https://github.com/oauth2-proxy/oauth2-proxy/pull/690) Address GoSec security findings & remediate (@NickMeves)
 - [#689](https://github.com/oauth2-proxy/oauth2-proxy/pull/689) Fix finicky logging_handler_test from time drift (@NickMeves)
+- [#700](https://github.com/oauth2-proxy/oauth2-proxy/pull/700) Allow OIDC Bearer auth IDTokens to have empty email claim & profile URL (@NickMeves)
 - [#699](https://github.com/oauth2-proxy/oauth2-proxy/pull/699) Align persistence ginkgo tests with conventions (@NickMeves)
 - [#696](https://github.com/oauth2-proxy/oauth2-proxy/pull/696) Preserve query when building redirect
 - [#561](https://github.com/oauth2-proxy/oauth2-proxy/pull/561) Refactor provider URLs to package level vars (@JoelSpeed)
diff --git a/providers/oidc.go b/providers/oidc.go
index e456db76..8f2f02b9 100644
--- a/providers/oidc.go
+++ b/providers/oidc.go
@@ -157,7 +157,7 @@ func (p *OIDCProvider) createSessionState(ctx context.Context, token *oauth2.Tok
 		newSession = &sessions.SessionState{}
 	} else {
 		var err error
-		newSession, err = p.createSessionStateInternal(ctx, token.Extra("id_token").(string), idToken, token)
+		newSession, err = p.createSessionStateInternal(ctx, idToken, token)
 		if err != nil {
 			return nil, err
 		}
@@ -172,7 +172,7 @@ func (p *OIDCProvider) createSessionState(ctx context.Context, token *oauth2.Tok
 }
 
 func (p *OIDCProvider) CreateSessionStateFromBearerToken(ctx context.Context, rawIDToken string, idToken *oidc.IDToken) (*sessions.SessionState, error) {
-	newSession, err := p.createSessionStateInternal(ctx, rawIDToken, idToken, nil)
+	newSession, err := p.createSessionStateInternal(ctx, idToken, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -185,24 +185,22 @@ func (p *OIDCProvider) CreateSessionStateFromBearerToken(ctx context.Context, ra
 	return newSession, nil
 }
 
-func (p *OIDCProvider) createSessionStateInternal(ctx context.Context, rawIDToken string, idToken *oidc.IDToken, token *oauth2.Token) (*sessions.SessionState, error) {
+func (p *OIDCProvider) createSessionStateInternal(ctx context.Context, idToken *oidc.IDToken, token *oauth2.Token) (*sessions.SessionState, error) {
 
 	newSession := &sessions.SessionState{}
 
 	if idToken == nil {
 		return newSession, nil
 	}
-	accessToken := ""
-	if token != nil {
-		accessToken = token.AccessToken
-	}
 
-	claims, err := p.findClaimsFromIDToken(ctx, idToken, accessToken, p.ProfileURL.String())
+	claims, err := p.findClaimsFromIDToken(ctx, idToken, token)
 	if err != nil {
 		return nil, fmt.Errorf("couldn't extract claims from id_token (%v)", err)
 	}
 
-	newSession.IDToken = rawIDToken
+	if token != nil {
+		newSession.IDToken = token.Extra("id_token").(string)
+	}
 
 	newSession.Email = claims.UserID // TODO Rename SessionState.Email to .UserID in the near future
 
@@ -230,8 +228,7 @@ func getOIDCHeader(accessToken string) http.Header {
 	return header
 }
 
-func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.IDToken, accessToken string, profileURL string) (*OIDCClaims, error) {
-
+func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.IDToken, token *oauth2.Token) (*OIDCClaims, error) {
 	claims := &OIDCClaims{}
 	// Extract default claims.
 	if err := idToken.Claims(&claims); err != nil {
@@ -249,7 +246,15 @@ func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.
 
 	// userID claim was not present or was empty in the ID Token
 	if claims.UserID == "" {
-		if profileURL == "" {
+		// BearerToken case, allow empty UserID
+		// ProfileURL checks below won't work since we don't have an access token
+		if token == nil {
+			claims.UserID = claims.Subject
+			return claims, nil
+		}
+
+		profileURL := p.ProfileURL.String()
+		if profileURL == "" || token.AccessToken == "" {
 			return nil, fmt.Errorf("id_token did not contain user ID claim (%q)", p.UserIDClaim)
 		}
 
@@ -258,7 +263,7 @@ func (p *OIDCProvider) findClaimsFromIDToken(ctx context.Context, idToken *oidc.
 		// Make a query to the userinfo endpoint, and attempt to locate the email from there.
 		respJSON, err := requests.New(profileURL).
 			WithContext(ctx).
-			WithHeaders(getOIDCHeader(accessToken)).
+			WithHeaders(getOIDCHeader(token.AccessToken)).
 			Do().
 			UnmarshalJSON()
 		if err != nil {


Test patch:
diff --git a/providers/oidc_test.go b/providers/oidc_test.go
index 12b62a47..9e96752d 100644
--- a/providers/oidc_test.go
+++ b/providers/oidc_test.go
@@ -60,6 +60,22 @@ var defaultIDToken idTokenClaims = idTokenClaims{
 	},
 }
 
+var minimalIDToken idTokenClaims = idTokenClaims{
+	"",
+	"",
+	"",
+	"",
+	jwt.StandardClaims{
+		Audience:  "https://test.myapp.com",
+		ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
+		Id:        "id-some-id",
+		IssuedAt:  time.Now().Unix(),
+		Issuer:    "https://issuer.example.com",
+		NotBefore: 0,
+		Subject:   "minimal",
+	},
+}
+
 type fakeKeySetStub struct{}
 
 func (fakeKeySetStub) VerifySignature(_ context.Context, jwt string) (payload []byte, err error) {
@@ -253,6 +269,53 @@ func TestOIDCProviderRefreshSessionIfNeededWithIdToken(t *testing.T) {
 	assert.Equal(t, refreshToken, existingSession.RefreshToken)
 }
 
+func TestCreateSessionStateFromBearerToken(t *testing.T) {
+	const profileURLEmail = "janed@me.com"
+
+	testCases := map[string]struct {
+		IDToken       idTokenClaims
+		ExpectedUser  string
+		ExpectedEmail string
+	}{
+		"Default IDToken": {
+			IDToken:       defaultIDToken,
+			ExpectedUser:  defaultIDToken.Subject,
+			ExpectedEmail: defaultIDToken.Email,
+		},
+		"Minimal IDToken with no email claim": {
+			IDToken:       minimalIDToken,
+			ExpectedUser:  minimalIDToken.Subject,
+			ExpectedEmail: minimalIDToken.Subject,
+		},
+	}
+	for testName, tc := range testCases {
+		t.Run(testName, func(t *testing.T) {
+			jsonResp := []byte(fmt.Sprintf(`{"email":"%s"}`, profileURLEmail))
+			server, provider := newTestSetup(jsonResp)
+			defer server.Close()
+
+			rawIDToken, err := newSignedTestIDToken(tc.IDToken)
+			assert.NoError(t, err)
+
+			keyset := fakeKeySetStub{}
+			verifier := oidc.NewVerifier("https://issuer.example.com", keyset,
+				&oidc.Config{ClientID: "https://test.myapp.com", SkipExpiryCheck: true})
+
+			idToken, err := verifier.Verify(context.Background(), rawIDToken)
+			assert.NoError(t, err)
+
+			ss, err := provider.CreateSessionStateFromBearerToken(context.Background(), rawIDToken, idToken)
+			assert.NoError(t, err)
+
+			assert.Equal(t, tc.ExpectedUser, ss.User)
+			assert.Equal(t, tc.ExpectedEmail, ss.Email)
+			assert.Equal(t, rawIDToken, ss.IDToken)
+			assert.Equal(t, rawIDToken, ss.AccessToken)
+			assert.Equal(t, "", ss.RefreshToken)
+		})
+	}
+}
+
 func TestOIDCProvider_findVerifiedIdToken(t *testing.T) {
 
 	server, provider := newTestSetup([]byte(""))