From 3bda10f005531bf7f37f8b4a611c573a3e611ec0 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/options.go | 2 + pkg/validation/options.go | 3 +- providers/keycloak_oidc.go | 92 ++++++++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go index 34cb75d5..0c0a2be0 100644 --- a/pkg/apis/options/options.go +++ b/pkg/apis/options/options.go @@ -33,6 +33,7 @@ type Options struct { AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"` KeycloakGroups []string `flag:"keycloak-group" cfg:"keycloak_groups"` + KeycloakRoles []string `flag:"keycloak-role" cfg:"keycloak_roles"` AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"` BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"` BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository"` @@ -173,6 +174,7 @@ func NewFlagSet() *pflag.FlagSet { flagSet.StringSlice("email-domain", []string{}, "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email") flagSet.StringSlice("whitelist-domain", []string{}, "allowed domains for redirection after authentication. Prefix domain with a . to allow subdomains (eg .example.com)") flagSet.StringSlice("keycloak-group", []string{}, "restrict logins to members of these groups (may be given multiple times)") + flagSet.StringSlice("keycloak-role", []string{}, "restrict logins to members of these roles (may be given multiple times)") flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.") flagSet.String("bitbucket-team", "", "restrict logins to members of this team") flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository") diff --git a/pkg/validation/options.go b/pkg/validation/options.go index f5e5e4a8..916d54de 100644 --- a/pkg/validation/options.go +++ b/pkg/validation/options.go @@ -275,12 +275,13 @@ func parseProviderInfo(o *options.Options, msgs []string) []string { // Backwards compatibility with `--keycloak-group` option if len(o.KeycloakGroups) > 0 { - // Maybe already added with proper `--allowed-group` flag + // Maybe already added with `--allowed-group` flag if !strings.Contains(o.Scope, " groups") { o.Scope += " groups" } p.SetAllowedGroups(o.KeycloakGroups) } + p.AddAllowedRoles(o.KeycloakRoles) case *providers.GoogleProvider: if o.GoogleServiceAccountJSON != "" { file, err := os.Open(o.GoogleServiceAccountJSON) 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) }