From ab54de38cc5f81270ca15cf7dd0f2d683105a9b6 Mon Sep 17 00:00:00 2001 From: Nick Meves Date: Sun, 14 Mar 2021 18:32:24 -0700 Subject: [PATCH] Extract roles from Keycloak Access Tokens --- pkg/apis/options/legacy_options.go | 3 + pkg/apis/options/providers.go | 1 + pkg/validation/options.go | 1 + providers/keycloak_oidc.go | 92 +++++++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index 17cbcbc3..5a6145e2 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -508,6 +508,7 @@ type LegacyProvider struct { ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0 UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"` AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"` + AllowedRoles []string `flag:"allowed-role" cfg:"allowed_roles"` AcrValues string `flag:"acr-values" cfg:"acr_values"` JWTKey string `flag:"jwt-key" cfg:"jwt_key"` @@ -563,6 +564,7 @@ func legacyProviderFlagSet() *pflag.FlagSet { flagSet.String("user-id-claim", providers.OIDCEmailClaim, "(DEPRECATED for `oidc-email-claim`) which claim contains the user ID") flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)") + flagSet.StringSlice("allowed-role", []string{}, "(keycloak-oidc) restrict logins to members of these roles (may be given multiple times)") return flagSet } @@ -659,6 +661,7 @@ func (l *LegacyProvider) convert() (Providers, error) { case "keycloak": provider.KeycloakConfig = KeycloakOptions{ Groups: l.KeycloakGroups, + Roles: l.AllowedRoles, } case "gitlab": provider.GitLabConfig = GitLabOptions{ diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 12248b3f..4e63c86f 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -78,6 +78,7 @@ type Provider struct { type KeycloakOptions struct { // Group enables to restrict login to members of indicated group Groups []string `json:"groups,omitempty"` + Roles []string `json:"roles,omitempty"` } type AzureOptions struct { diff --git a/pkg/validation/options.go b/pkg/validation/options.go index 6d53ab43..37c2aa24 100644 --- a/pkg/validation/options.go +++ b/pkg/validation/options.go @@ -251,6 +251,7 @@ func parseProviderInfo(o *options.Options, msgs []string) []string { if p.Verifier == nil { msgs = append(msgs, "keycloak-oidc provider requires an oidc issuer URL") } + p.AddAllowedRoles(o.Providers[0].KeycloakConfig.Roles) case *providers.GoogleProvider: if o.Providers[0].GoogleConfig.ServiceAccountJSON != "" { file, err := os.Open(o.Providers[0].GoogleConfig.ServiceAccountJSON) diff --git a/providers/keycloak_oidc.go b/providers/keycloak_oidc.go index 553438b7..a58e5871 100644 --- a/providers/keycloak_oidc.go +++ b/providers/keycloak_oidc.go @@ -2,8 +2,10 @@ package providers import ( "context" + "fmt" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" ) const keycloakOIDCProviderName = "Keycloak OIDC" @@ -25,6 +27,15 @@ func NewKeycloakOIDCProvider(p *ProviderData) *KeycloakOIDCProvider { var _ Provider = (*KeycloakOIDCProvider)(nil) +// AddAllowedRoles sets Keycloak roles that are authorized. +// Assumes `SetAllowedGroups` is already called on groups and appends to that +// with `role:` prefixed roles. +func (p *KeycloakOIDCProvider) AddAllowedRoles(roles []string) { + for _, role := range roles { + p.AllowedGroups[formatRole(role)] = struct{}{} + } +} + // EnrichSession is called after Redeem to allow providers to enrich session fields // such as User, Email, Groups with provider specific API calls. func (p *KeycloakOIDCProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { @@ -36,6 +47,83 @@ func (p *KeycloakOIDCProvider) EnrichSession(ctx context.Context, s *sessions.Se } func (p *KeycloakOIDCProvider) extractRoles(ctx context.Context, s *sessions.SessionState) error { - // TODO: Implement me with Access Token Role claim extraction logic - return ErrNotImplemented + claims, err := p.getAccessClaims(ctx, s) + if err != nil { + return err + } + + var roles []string + roles = append(roles, claims.RealmAccess.Roles...) + roles = append(roles, getClientRoles(claims)...) + + // Add to groups list with `role:` prefix to distinguish from groups + for _, role := range roles { + s.Groups = append(s.Groups, formatRole(role)) + } + return nil +} + +type realmAccess struct { + Roles []string `json:"roles"` +} + +type accessClaims struct { + RealmAccess realmAccess `json:"realm_access"` + 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 + } + + var claims *accessClaims + if err = token.Claims(&claims); err != nil { + return nil, err + } + return claims, nil +} + +// getClientRoles extracts client roles from the `resource_access` claim with +// the format `client:role`. +// +// ResourceAccess format: +// "resource_access": { +// "clientA": { +// "roles": [ +// "roleA" +// ] +// }, +// "clientB": { +// "roles": [ +// "roleA", +// "roleB", +// "roleC" +// ] +// } +// } +func getClientRoles(claims *accessClaims) []string { + var clientRoles []string + for clientName, access := range claims.ResourceAccess { + accessMap, ok := access.(map[string]interface{}) + if !ok { + logger.Errorf("Unable to parse client roles from claims for client: %v", clientName) + continue + } + + var roles interface{} + if roles, ok = accessMap["roles"]; !ok { + continue + } + for _, role := range roles.([]interface{}) { + clientRoles = append(clientRoles, fmt.Sprintf("%s:%s", clientName, role)) + } + } + return clientRoles +} + +func formatRole(role string) string { + return fmt.Sprintf("role:%s", role) }