git.iko.soy/typeable/oauth2-proxy
on master help sign in
Extract roles from Keycloak Access Tokens
ab54de3Nick Meves
pkg/apis/options/legacy_options.go+3
@@ -508,6 +508,7 @@ type LegacyProvider struct {
508 ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0
508 ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0
509 UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"`
509 UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"`
510 AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"`
510 AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"`
511+ AllowedRoles []string `flag:"allowed-role" cfg:"allowed_roles"`
511
512
512 AcrValues string `flag:"acr-values" cfg:"acr_values"`
513 AcrValues string `flag:"acr-values" cfg:"acr_values"`
513 JWTKey string `flag:"jwt-key" cfg:"jwt_key"`
514 JWTKey string `flag:"jwt-key" cfg:"jwt_key"`
@@ -563,6 +564,7 @@ func legacyProviderFlagSet() *pflag.FlagSet {
563
564
564 flagSet.String("user-id-claim", providers.OIDCEmailClaim, "(DEPRECATED for `oidc-email-claim`) which claim contains the user ID")
565 flagSet.String("user-id-claim", providers.OIDCEmailClaim, "(DEPRECATED for `oidc-email-claim`) which claim contains the user ID")
565 flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)")
566 flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)")
567+ flagSet.StringSlice("allowed-role", []string{}, "(keycloak-oidc) restrict logins to members of these roles (may be given multiple times)")
566
568
567 return flagSet
569 return flagSet
568}
570}
@@ -659,6 +661,7 @@ func (l *LegacyProvider) convert() (Providers, error) {
659 case "keycloak":
661 case "keycloak":
660 provider.KeycloakConfig = KeycloakOptions{
662 provider.KeycloakConfig = KeycloakOptions{
661 Groups: l.KeycloakGroups,
663 Groups: l.KeycloakGroups,
664+ Roles: l.AllowedRoles,
662 }
665 }
663 case "gitlab":
666 case "gitlab":
664 provider.GitLabConfig = GitLabOptions{
667 provider.GitLabConfig = GitLabOptions{
pkg/apis/options/providers.go+1
@@ -78,6 +78,7 @@ type Provider struct {
78type KeycloakOptions struct {
78type KeycloakOptions struct {
79 // Group enables to restrict login to members of indicated group
79 // Group enables to restrict login to members of indicated group
80 Groups []string `json:"groups,omitempty"`
80 Groups []string `json:"groups,omitempty"`
81+ Roles []string `json:"roles,omitempty"`
81}
82}
82
83
83type AzureOptions struct {
84type AzureOptions struct {
pkg/validation/options.go+1
@@ -251,6 +251,7 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
251 if p.Verifier == nil {
251 if p.Verifier == nil {
252 msgs = append(msgs, "keycloak-oidc provider requires an oidc issuer URL")
252 msgs = append(msgs, "keycloak-oidc provider requires an oidc issuer URL")
253 }
253 }
254+ p.AddAllowedRoles(o.Providers[0].KeycloakConfig.Roles)
254 case *providers.GoogleProvider:
255 case *providers.GoogleProvider:
255 if o.Providers[0].GoogleConfig.ServiceAccountJSON != "" {
256 if o.Providers[0].GoogleConfig.ServiceAccountJSON != "" {
256 file, err := os.Open(o.Providers[0].GoogleConfig.ServiceAccountJSON)
257 file, err := os.Open(o.Providers[0].GoogleConfig.ServiceAccountJSON)
providers/keycloak_oidc.go+90−2
@@ -2,8 +2,10 @@ package providers
2
2
3import (
3import (
4 "context"
4 "context"
5+ "fmt"
5
6
6 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
7 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
8+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
7)
9)
8
10
9const keycloakOIDCProviderName = "Keycloak OIDC"
11const keycloakOIDCProviderName = "Keycloak OIDC"
@@ -25,6 +27,15 @@ func NewKeycloakOIDCProvider(p *ProviderData) *KeycloakOIDCProvider {
25
27
26var _ Provider = (*KeycloakOIDCProvider)(nil)
28var _ Provider = (*KeycloakOIDCProvider)(nil)
27
29
30+// AddAllowedRoles sets Keycloak roles that are authorized.
31+// Assumes `SetAllowedGroups` is already called on groups and appends to that
32+// with `role:` prefixed roles.
33+func (p *KeycloakOIDCProvider) AddAllowedRoles(roles []string) {
34+ for _, role := range roles {
35+ p.AllowedGroups[formatRole(role)] = struct{}{}
36+ }
37+}
38+
28// EnrichSession is called after Redeem to allow providers to enrich session fields
39// EnrichSession is called after Redeem to allow providers to enrich session fields
29// such as User, Email, Groups with provider specific API calls.
40// such as User, Email, Groups with provider specific API calls.
30func (p *KeycloakOIDCProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
41func (p *KeycloakOIDCProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
@@ -36,6 +47,83 @@ func (p *KeycloakOIDCProvider) EnrichSession(ctx context.Context, s *sessions.Se
36}
47}
37
48
38func (p *KeycloakOIDCProvider) extractRoles(ctx context.Context, s *sessions.SessionState) error {
49func (p *KeycloakOIDCProvider) extractRoles(ctx context.Context, s *sessions.SessionState) error {
39− // TODO: Implement me with Access Token Role claim extraction logic
50+ claims, err := p.getAccessClaims(ctx, s)
40− return ErrNotImplemented
51+ if err != nil {
52+ return err
53+ }
54+
55+ var roles []string
56+ roles = append(roles, claims.RealmAccess.Roles...)
57+ roles = append(roles, getClientRoles(claims)...)
58+
59+ // Add to groups list with `role:` prefix to distinguish from groups
60+ for _, role := range roles {
61+ s.Groups = append(s.Groups, formatRole(role))
62+ }
63+ return nil
64+}
65+
66+type realmAccess struct {
67+ Roles []string `json:"roles"`
68+}
69+
70+type accessClaims struct {
71+ RealmAccess realmAccess `json:"realm_access"`
72+ ResourceAccess map[string]interface{} `json:"resource_access"`
73+}
74+
75+func (p *KeycloakOIDCProvider) getAccessClaims(ctx context.Context, s *sessions.SessionState) (*accessClaims, error) {
76+ // HACK: This isn't an ID Token, but has similar structure & signing
77+ token, err := p.Verifier.Verify(ctx, s.AccessToken)
78+ if err != nil {
79+ return nil, err
80+ }
81+
82+ var claims *accessClaims
83+ if err = token.Claims(&claims); err != nil {
84+ return nil, err
85+ }
86+ return claims, nil
87+}
88+
89+// getClientRoles extracts client roles from the `resource_access` claim with
90+// the format `client:role`.
91+//
92+// ResourceAccess format:
93+// "resource_access": {
94+// "clientA": {
95+// "roles": [
96+// "roleA"
97+// ]
98+// },
99+// "clientB": {
100+// "roles": [
101+// "roleA",
102+// "roleB",
103+// "roleC"
104+// ]
105+// }
106+// }
107+func getClientRoles(claims *accessClaims) []string {
108+ var clientRoles []string
109+ for clientName, access := range claims.ResourceAccess {
110+ accessMap, ok := access.(map[string]interface{})
111+ if !ok {
112+ logger.Errorf("Unable to parse client roles from claims for client: %v", clientName)
113+ continue
114+ }
115+
116+ var roles interface{}
117+ if roles, ok = accessMap["roles"]; !ok {
118+ continue
119+ }
120+ for _, role := range roles.([]interface{}) {
121+ clientRoles = append(clientRoles, fmt.Sprintf("%s:%s", clientName, role))
122+ }
123+ }
124+ return clientRoles
125+}
126+
127+func formatRole(role string) string {
128+ return fmt.Sprintf("role:%s", role)
41}
129}