fix: role extraction from access token in keycloak oidc (#1916)
* Fix wrong token used in Keycloak OIDC provider * Update CHANGELOG for PR #1916 * Update tests * fix: keycloak oidc role extraction --------- Co-authored-by: Jan Larwig <jan@larwig.com>
This commit is contained in:
		
							parent
							
								
									367183d7b8
								
							
						
					
					
						commit
						7b41c8e987
					
				|  | @ -11,6 +11,7 @@ | |||
| - [#3031](https://github.com/oauth2-proxy/oauth2-proxy/pull/3031) Fixes Refresh Token bug with Entra ID and Workload Identity (#3027)[https://github.com/oauth2-proxy/oauth2-proxy/issues/3028] by using client assertion when redeeming the token (@richard87) | ||||
| - [#3001](https://github.com/oauth2-proxy/oauth2-proxy/pull/3001) Allow to set non-default authorization request response mode (@stieler-it) | ||||
| - [#3041](https://github.com/oauth2-proxy/oauth2-proxy/pull/3041) chore(deps): upgrade to latest golang v1.23.x release (@TheImplementer) | ||||
| - [#1916](https://github.com/oauth2-proxy/oauth2-proxy/pull/1916) fix: role extraction from access token in keycloak oidc (@Elektordi / @tuunit) | ||||
| 
 | ||||
| # V7.8.2 | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,7 +2,10 @@ package providers | |||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/base64" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" | ||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | ||||
|  | @ -51,7 +54,7 @@ func (p *KeycloakOIDCProvider) CreateSessionFromToken(ctx context.Context, token | |||
| 	} | ||||
| 
 | ||||
| 	// Extract custom keycloak roles and enrich session
 | ||||
| 	if err := p.extractRoles(ctx, ss); err != nil { | ||||
| 	if err := p.extractRoles(ss); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
|  | @ -65,7 +68,7 @@ func (p *KeycloakOIDCProvider) EnrichSession(ctx context.Context, s *sessions.Se | |||
| 	if err != nil { | ||||
| 		return fmt.Errorf("could not enrich oidc session: %v", err) | ||||
| 	} | ||||
| 	return p.extractRoles(ctx, s) | ||||
| 	return p.extractRoles(s) | ||||
| } | ||||
| 
 | ||||
| // RefreshSession adds role extraction logic to the refresh flow
 | ||||
|  | @ -77,11 +80,11 @@ func (p *KeycloakOIDCProvider) RefreshSession(ctx context.Context, s *sessions.S | |||
| 		return refreshed, err | ||||
| 	} | ||||
| 
 | ||||
| 	return true, p.extractRoles(ctx, s) | ||||
| 	return true, p.extractRoles(s) | ||||
| } | ||||
| 
 | ||||
| func (p *KeycloakOIDCProvider) extractRoles(ctx context.Context, s *sessions.SessionState) error { | ||||
| 	claims, err := p.getAccessClaims(ctx, s) | ||||
| func (p *KeycloakOIDCProvider) extractRoles(s *sessions.SessionState) error { | ||||
| 	claims, err := p.getAccessClaims(s) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | @ -106,18 +109,22 @@ type accessClaims struct { | |||
| 	ResourceAccess map[string]interface{} `json:"resource_access"` | ||||
| } | ||||
| 
 | ||||
| func (p *KeycloakOIDCProvider) getAccessClaims(ctx context.Context, s *sessions.SessionState) (*accessClaims, error) { | ||||
| 	// HACK: This isn't an ID Token, but has similar structure & signing
 | ||||
| 	token, err := p.Verifier.Verify(ctx, s.AccessToken) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| func (p *KeycloakOIDCProvider) getAccessClaims(s *sessions.SessionState) (*accessClaims, error) { | ||||
| 	parts := strings.Split(s.AccessToken, ".") | ||||
| 	if len(parts) < 2 { | ||||
| 		return nil, fmt.Errorf("malformed access token, expected 3 parts got %d", len(parts)) | ||||
| 	} | ||||
| 
 | ||||
| 	var claims *accessClaims | ||||
| 	if err = token.Claims(&claims); err != nil { | ||||
| 	payload, err := base64.RawURLEncoding.DecodeString(parts[1]) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("malformed access token, couldn't extract jwt payload: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	var claims accessClaims | ||||
| 	if err := json.Unmarshal(payload, &claims); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return claims, nil | ||||
| 	return &claims, nil | ||||
| } | ||||
| 
 | ||||
| // getClientRoles extracts client roles from the `resource_access` claim with
 | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ import ( | |||
| 	"fmt" | ||||
| 	"net/http/httptest" | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"github.com/coreos/go-oidc/v3/oidc" | ||||
| 
 | ||||
|  | @ -18,28 +19,40 @@ import ( | |||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	idTokenHeader        = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJjV1IteTRzRVU1MjZVelk1SFd6UEZJbWdMMWRKUllfQ0gyY1FFRXh4UGN3In0" | ||||
| 	idTokenSignature     = "Rh0zQGhWAm-2hn5JTWB3Lzuk9Ahpzs7As7ks-1VInl4" | ||||
| 	accessTokenHeader    = "ewogICJhbGciOiAiUlMyNTYiLAogICJ0eXAiOiAiSldUIgp9" | ||||
| 	accessTokenSignature = "dyt0CoTl4WoVjAHI9Q_CwSKhl6d_9rhM3NrXuJttkao" | ||||
| 	defaultAudienceClaim = "aud" | ||||
| 	mockClientID         = "cd6d4fae-f6a6-4a34-8454-2c6b598e9532" | ||||
| ) | ||||
| 
 | ||||
| var accessTokenPayload = base64.StdEncoding.EncodeToString([]byte( | ||||
| var ( | ||||
| 	accessTokenPayload = base64.RawURLEncoding.EncodeToString([]byte( | ||||
| 		fmt.Sprintf(`{"%s": "%s", "realm_access": {"roles": ["write"]}, "resource_access": {"default": {"roles": ["read"]}}}`, defaultAudienceClaim, mockClientID))) | ||||
| 
 | ||||
| 	idTokenPayload = base64.RawURLEncoding.EncodeToString([]byte( | ||||
| 		fmt.Sprintf(`{"%s": "%s"}`, defaultAudienceClaim, mockClientID))) | ||||
| ) | ||||
| 
 | ||||
| type DummyKeySet struct{} | ||||
| 
 | ||||
| func (DummyKeySet) VerifySignature(_ context.Context, _ string) (payload []byte, err error) { | ||||
| 	p, _ := base64.RawURLEncoding.DecodeString(accessTokenPayload) | ||||
| func (DummyKeySet) VerifySignature(_ context.Context, jwt string) (payload []byte, err error) { | ||||
| 	parts := strings.Split(jwt, ".") | ||||
| 	p, _ := base64.RawURLEncoding.DecodeString(parts[1]) | ||||
| 	return p, nil | ||||
| } | ||||
| 
 | ||||
| func getAccessToken() string { | ||||
| func makeIDToken() string { | ||||
| 	return fmt.Sprintf("%s.%s.%s", idTokenHeader, idTokenPayload, idTokenSignature) | ||||
| } | ||||
| 
 | ||||
| func makeAccessToken() string { | ||||
| 	return fmt.Sprintf("%s.%s.%s", accessTokenHeader, accessTokenPayload, accessTokenSignature) | ||||
| } | ||||
| 
 | ||||
| func newTestKeycloakOIDCSetup() (*httptest.Server, *KeycloakOIDCProvider) { | ||||
| 	redeemURL, server := newOIDCServer([]byte(fmt.Sprintf(`{"email": "new@thing.com", "expires_in": 300, "access_token": "%v"}`, getAccessToken()))) | ||||
| 	redeemURL, server := newOIDCServer([]byte(fmt.Sprintf(`{"email": "new@thing.com", "expires_in": 300, "id_token": "%v", "access_token": "%v"}`, makeIDToken(), makeAccessToken()))) | ||||
| 	provider := newKeycloakOIDCProvider(redeemURL, options.Provider{}) | ||||
| 	return server, provider | ||||
| } | ||||
|  | @ -134,16 +147,16 @@ var _ = Describe("Keycloak OIDC Provider Tests", func() { | |||
| 				User:         "already", | ||||
| 				Email:        "a@b.com", | ||||
| 				Groups:       nil, | ||||
| 				IDToken:      idToken, | ||||
| 				AccessToken:  getAccessToken(), | ||||
| 				IDToken:      makeIDToken(), | ||||
| 				AccessToken:  makeAccessToken(), | ||||
| 				RefreshToken: refreshToken, | ||||
| 			} | ||||
| 			expectedSession := &sessions.SessionState{ | ||||
| 				User:         "already", | ||||
| 				Email:        "a@b.com", | ||||
| 				Groups:       []string{"role:write", "role:default:read"}, | ||||
| 				IDToken:      idToken, | ||||
| 				AccessToken:  getAccessToken(), | ||||
| 				IDToken:      makeIDToken(), | ||||
| 				AccessToken:  makeAccessToken(), | ||||
| 				RefreshToken: refreshToken, | ||||
| 			} | ||||
| 
 | ||||
|  | @ -164,16 +177,16 @@ var _ = Describe("Keycloak OIDC Provider Tests", func() { | |||
| 				User:         "already", | ||||
| 				Email:        "a@b.com", | ||||
| 				Groups:       []string{"existing", "group"}, | ||||
| 				IDToken:      idToken, | ||||
| 				AccessToken:  getAccessToken(), | ||||
| 				IDToken:      makeIDToken(), | ||||
| 				AccessToken:  makeAccessToken(), | ||||
| 				RefreshToken: refreshToken, | ||||
| 			} | ||||
| 			expectedSession := &sessions.SessionState{ | ||||
| 				User:         "already", | ||||
| 				Email:        "a@b.com", | ||||
| 				Groups:       []string{"existing", "group", "role:write", "role:default:read"}, | ||||
| 				IDToken:      idToken, | ||||
| 				AccessToken:  getAccessToken(), | ||||
| 				IDToken:      makeIDToken(), | ||||
| 				AccessToken:  makeAccessToken(), | ||||
| 				RefreshToken: refreshToken, | ||||
| 			} | ||||
| 
 | ||||
|  | @ -196,8 +209,8 @@ var _ = Describe("Keycloak OIDC Provider Tests", func() { | |||
| 				User:         "already", | ||||
| 				Email:        "a@b.com", | ||||
| 				Groups:       nil, | ||||
| 				IDToken:      idToken, | ||||
| 				AccessToken:  getAccessToken(), | ||||
| 				IDToken:      makeIDToken(), | ||||
| 				AccessToken:  makeAccessToken(), | ||||
| 				RefreshToken: refreshToken, | ||||
| 			} | ||||
| 
 | ||||
|  | @ -219,7 +232,7 @@ var _ = Describe("Keycloak OIDC Provider Tests", func() { | |||
| 
 | ||||
| 			provider.ProfileURL = url | ||||
| 
 | ||||
| 			session, err := provider.CreateSessionFromToken(context.Background(), getAccessToken()) | ||||
| 			session, err := provider.CreateSessionFromToken(context.Background(), makeAccessToken()) | ||||
| 			Expect(err).To(BeNil()) | ||||
| 			Expect(session.ExpiresOn).ToNot(BeNil()) | ||||
| 			Expect(session.CreatedAt).ToNot(BeNil()) | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue