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) | - [#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) | - [#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) | - [#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 | # V7.8.2 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,7 +2,10 @@ package providers | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"encoding/base64" | ||||||
|  | 	"encoding/json" | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"strings" | ||||||
| 
 | 
 | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" | 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" | ||||||
| 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" | 	"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
 | 	// 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 | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -65,7 +68,7 @@ func (p *KeycloakOIDCProvider) EnrichSession(ctx context.Context, s *sessions.Se | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("could not enrich oidc session: %v", err) | 		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
 | // 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 refreshed, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return true, p.extractRoles(ctx, s) | 	return true, p.extractRoles(s) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (p *KeycloakOIDCProvider) extractRoles(ctx context.Context, s *sessions.SessionState) error { | func (p *KeycloakOIDCProvider) extractRoles(s *sessions.SessionState) error { | ||||||
| 	claims, err := p.getAccessClaims(ctx, s) | 	claims, err := p.getAccessClaims(s) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | @ -106,18 +109,22 @@ type accessClaims struct { | ||||||
| 	ResourceAccess map[string]interface{} `json:"resource_access"` | 	ResourceAccess map[string]interface{} `json:"resource_access"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (p *KeycloakOIDCProvider) getAccessClaims(ctx context.Context, s *sessions.SessionState) (*accessClaims, error) { | func (p *KeycloakOIDCProvider) getAccessClaims(s *sessions.SessionState) (*accessClaims, error) { | ||||||
| 	// HACK: This isn't an ID Token, but has similar structure & signing
 | 	parts := strings.Split(s.AccessToken, ".") | ||||||
| 	token, err := p.Verifier.Verify(ctx, s.AccessToken) | 	if len(parts) < 2 { | ||||||
| 	if err != nil { | 		return nil, fmt.Errorf("malformed access token, expected 3 parts got %d", len(parts)) | ||||||
| 		return nil, err |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	var claims *accessClaims | 	payload, err := base64.RawURLEncoding.DecodeString(parts[1]) | ||||||
| 	if err = token.Claims(&claims); err != nil { | 	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 nil, err | ||||||
| 	} | 	} | ||||||
| 	return claims, nil | 	return &claims, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // getClientRoles extracts client roles from the `resource_access` claim with
 | // getClientRoles extracts client roles from the `resource_access` claim with
 | ||||||
|  |  | ||||||
|  | @ -6,6 +6,7 @@ import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"net/http/httptest" | 	"net/http/httptest" | ||||||
| 	"net/url" | 	"net/url" | ||||||
|  | 	"strings" | ||||||
| 
 | 
 | ||||||
| 	"github.com/coreos/go-oidc/v3/oidc" | 	"github.com/coreos/go-oidc/v3/oidc" | ||||||
| 
 | 
 | ||||||
|  | @ -18,28 +19,40 @@ import ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
|  | 	idTokenHeader        = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJjV1IteTRzRVU1MjZVelk1SFd6UEZJbWdMMWRKUllfQ0gyY1FFRXh4UGN3In0" | ||||||
|  | 	idTokenSignature     = "Rh0zQGhWAm-2hn5JTWB3Lzuk9Ahpzs7As7ks-1VInl4" | ||||||
| 	accessTokenHeader    = "ewogICJhbGciOiAiUlMyNTYiLAogICJ0eXAiOiAiSldUIgp9" | 	accessTokenHeader    = "ewogICJhbGciOiAiUlMyNTYiLAogICJ0eXAiOiAiSldUIgp9" | ||||||
| 	accessTokenSignature = "dyt0CoTl4WoVjAHI9Q_CwSKhl6d_9rhM3NrXuJttkao" | 	accessTokenSignature = "dyt0CoTl4WoVjAHI9Q_CwSKhl6d_9rhM3NrXuJttkao" | ||||||
| 	defaultAudienceClaim = "aud" | 	defaultAudienceClaim = "aud" | ||||||
| 	mockClientID         = "cd6d4fae-f6a6-4a34-8454-2c6b598e9532" | 	mockClientID         = "cd6d4fae-f6a6-4a34-8454-2c6b598e9532" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| var accessTokenPayload = base64.StdEncoding.EncodeToString([]byte( | var ( | ||||||
| 	fmt.Sprintf(`{"%s": "%s", "realm_access": {"roles": ["write"]}, "resource_access": {"default": {"roles": ["read"]}}}`, defaultAudienceClaim, mockClientID))) | 	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{} | type DummyKeySet struct{} | ||||||
| 
 | 
 | ||||||
| func (DummyKeySet) VerifySignature(_ context.Context, _ string) (payload []byte, err error) { | func (DummyKeySet) VerifySignature(_ context.Context, jwt string) (payload []byte, err error) { | ||||||
| 	p, _ := base64.RawURLEncoding.DecodeString(accessTokenPayload) | 	parts := strings.Split(jwt, ".") | ||||||
|  | 	p, _ := base64.RawURLEncoding.DecodeString(parts[1]) | ||||||
| 	return p, nil | 	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) | 	return fmt.Sprintf("%s.%s.%s", accessTokenHeader, accessTokenPayload, accessTokenSignature) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func newTestKeycloakOIDCSetup() (*httptest.Server, *KeycloakOIDCProvider) { | 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{}) | 	provider := newKeycloakOIDCProvider(redeemURL, options.Provider{}) | ||||||
| 	return server, provider | 	return server, provider | ||||||
| } | } | ||||||
|  | @ -134,16 +147,16 @@ var _ = Describe("Keycloak OIDC Provider Tests", func() { | ||||||
| 				User:         "already", | 				User:         "already", | ||||||
| 				Email:        "a@b.com", | 				Email:        "a@b.com", | ||||||
| 				Groups:       nil, | 				Groups:       nil, | ||||||
| 				IDToken:      idToken, | 				IDToken:      makeIDToken(), | ||||||
| 				AccessToken:  getAccessToken(), | 				AccessToken:  makeAccessToken(), | ||||||
| 				RefreshToken: refreshToken, | 				RefreshToken: refreshToken, | ||||||
| 			} | 			} | ||||||
| 			expectedSession := &sessions.SessionState{ | 			expectedSession := &sessions.SessionState{ | ||||||
| 				User:         "already", | 				User:         "already", | ||||||
| 				Email:        "a@b.com", | 				Email:        "a@b.com", | ||||||
| 				Groups:       []string{"role:write", "role:default:read"}, | 				Groups:       []string{"role:write", "role:default:read"}, | ||||||
| 				IDToken:      idToken, | 				IDToken:      makeIDToken(), | ||||||
| 				AccessToken:  getAccessToken(), | 				AccessToken:  makeAccessToken(), | ||||||
| 				RefreshToken: refreshToken, | 				RefreshToken: refreshToken, | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
|  | @ -164,16 +177,16 @@ var _ = Describe("Keycloak OIDC Provider Tests", func() { | ||||||
| 				User:         "already", | 				User:         "already", | ||||||
| 				Email:        "a@b.com", | 				Email:        "a@b.com", | ||||||
| 				Groups:       []string{"existing", "group"}, | 				Groups:       []string{"existing", "group"}, | ||||||
| 				IDToken:      idToken, | 				IDToken:      makeIDToken(), | ||||||
| 				AccessToken:  getAccessToken(), | 				AccessToken:  makeAccessToken(), | ||||||
| 				RefreshToken: refreshToken, | 				RefreshToken: refreshToken, | ||||||
| 			} | 			} | ||||||
| 			expectedSession := &sessions.SessionState{ | 			expectedSession := &sessions.SessionState{ | ||||||
| 				User:         "already", | 				User:         "already", | ||||||
| 				Email:        "a@b.com", | 				Email:        "a@b.com", | ||||||
| 				Groups:       []string{"existing", "group", "role:write", "role:default:read"}, | 				Groups:       []string{"existing", "group", "role:write", "role:default:read"}, | ||||||
| 				IDToken:      idToken, | 				IDToken:      makeIDToken(), | ||||||
| 				AccessToken:  getAccessToken(), | 				AccessToken:  makeAccessToken(), | ||||||
| 				RefreshToken: refreshToken, | 				RefreshToken: refreshToken, | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
|  | @ -196,8 +209,8 @@ var _ = Describe("Keycloak OIDC Provider Tests", func() { | ||||||
| 				User:         "already", | 				User:         "already", | ||||||
| 				Email:        "a@b.com", | 				Email:        "a@b.com", | ||||||
| 				Groups:       nil, | 				Groups:       nil, | ||||||
| 				IDToken:      idToken, | 				IDToken:      makeIDToken(), | ||||||
| 				AccessToken:  getAccessToken(), | 				AccessToken:  makeAccessToken(), | ||||||
| 				RefreshToken: refreshToken, | 				RefreshToken: refreshToken, | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
|  | @ -219,7 +232,7 @@ var _ = Describe("Keycloak OIDC Provider Tests", func() { | ||||||
| 
 | 
 | ||||||
| 			provider.ProfileURL = url | 			provider.ProfileURL = url | ||||||
| 
 | 
 | ||||||
| 			session, err := provider.CreateSessionFromToken(context.Background(), getAccessToken()) | 			session, err := provider.CreateSessionFromToken(context.Background(), makeAccessToken()) | ||||||
| 			Expect(err).To(BeNil()) | 			Expect(err).To(BeNil()) | ||||||
| 			Expect(session.ExpiresOn).ToNot(BeNil()) | 			Expect(session.ExpiresOn).ToNot(BeNil()) | ||||||
| 			Expect(session.CreatedAt).ToNot(BeNil()) | 			Expect(session.CreatedAt).ToNot(BeNil()) | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue