diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c460410..001dbe6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/providers/keycloak_oidc.go b/providers/keycloak_oidc.go index ab613752..6b949f45 100644 --- a/providers/keycloak_oidc.go +++ b/providers/keycloak_oidc.go @@ -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 diff --git a/providers/keycloak_oidc_test.go b/providers/keycloak_oidc_test.go index 699309fc..2e60b0d2 100644 --- a/providers/keycloak_oidc_test.go +++ b/providers/keycloak_oidc_test.go @@ -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( - fmt.Sprintf(`{"%s": "%s", "realm_access": {"roles": ["write"]}, "resource_access": {"default": {"roles": ["read"]}}}`, defaultAudienceClaim, mockClientID))) +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())