From 1f04e0a1cef1a7d19cff5981315ff5f60c359deb Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 1 Apr 2026 11:03:19 +0200 Subject: [PATCH 1/4] fix: oidc role extraction Entire-Checkpoint: bbe9ad3cf817 --- internal/auth/oidc.go | 61 ++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index ec6c77a7..bb4b5d95 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -164,34 +164,65 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) { projects := make([]string, 0) - // Extract custom claims from userinfo - var claims struct { + // Extract profile claims from userinfo (username, name) + var userInfoClaims struct { + Username string `json:"preferred_username"` + Name string `json:"name"` + } + if err := userInfo.Claims(&userInfoClaims); err != nil { + cclog.Errorf("failed to extract userinfo claims: %s", err.Error()) + http.Error(rw, "Failed to extract user claims", http.StatusInternalServerError) + return + } + + // Extract role claims from the ID token. + // Keycloak includes realm_access and resource_access in the ID token (JWT), + // but NOT in the UserInfo endpoint response by default. + var idTokenClaims struct { Username string `json:"preferred_username"` Name string `json:"name"` // Keycloak realm-level roles RealmAccess struct { Roles []string `json:"roles"` } `json:"realm_access"` - // Keycloak client-level roles - ResourceAccess struct { - Client struct { - Roles []string `json:"roles"` - } `json:"clustercockpit"` + // Keycloak client-level roles: map from client-id to role list + ResourceAccess map[string]struct { + Roles []string `json:"roles"` } `json:"resource_access"` } - if err := userInfo.Claims(&claims); err != nil { - cclog.Errorf("failed to extract claims: %s", err.Error()) - http.Error(rw, "Failed to extract user claims", http.StatusInternalServerError) + if err := idToken.Claims(&idTokenClaims); err != nil { + cclog.Errorf("failed to extract ID token claims: %s", err.Error()) + http.Error(rw, "Failed to extract ID token claims", http.StatusInternalServerError) return } - if claims.Username == "" { + cclog.Debugf("OIDC userinfo claims: username=%q name=%q", userInfoClaims.Username, userInfoClaims.Name) + cclog.Debugf("OIDC ID token realm_access roles: %v", idTokenClaims.RealmAccess.Roles) + cclog.Debugf("OIDC ID token resource_access: %v", idTokenClaims.ResourceAccess) + + // Prefer username from userInfo; fall back to ID token claim + username := userInfoClaims.Username + if username == "" { + username = idTokenClaims.Username + } + name := userInfoClaims.Name + if name == "" { + name = idTokenClaims.Name + } + + if username == "" { http.Error(rw, "Username claim missing from OIDC provider", http.StatusBadRequest) return } - // Merge roles from both client-level and realm-level access - oidcRoles := append(claims.ResourceAccess.Client.Roles, claims.RealmAccess.Roles...) + // Collect roles from realm_access (realm roles) in the ID token + oidcRoles := append([]string{}, idTokenClaims.RealmAccess.Roles...) + + // Also collect roles from resource_access (client roles) for all clients + for clientID, access := range idTokenClaims.ResourceAccess { + cclog.Debugf("OIDC ID token resource_access[%q] roles: %v", clientID, access.Roles) + oidcRoles = append(oidcRoles, access.Roles...) + } roleSet := make(map[string]bool) for _, r := range oidcRoles { @@ -217,8 +248,8 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) { } user := &schema.User{ - Username: claims.Username, - Name: claims.Name, + Username: username, + Name: name, Roles: roles, Projects: projects, AuthSource: schema.AuthViaOIDC, From 84fe61b3e0c3875f7b013f328daf8a579a7c6be3 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 1 Apr 2026 11:09:50 +0200 Subject: [PATCH 2/4] fix: allow all role changes on SyncUser and UpdateUser callback Entire-Checkpoint: 496bace0120e --- internal/repository/user.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/repository/user.go b/internal/repository/user.go index 307916eb..a341e5be 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -14,6 +14,7 @@ import ( "path/filepath" "reflect" "runtime" + "sort" "strings" "sync" @@ -210,6 +211,12 @@ func (r *UserRepository) AddUserIfNotExists(user *schema.User) error { return err } +func sortedRoles(roles []string) []string { + cp := append([]string{}, roles...) + sort.Strings(cp) + return cp +} + func (r *UserRepository) UpdateUser(dbUser *schema.User, user *schema.User) error { // user contains updated info -> Apply to dbUser // --- Simple Name Update --- @@ -279,6 +286,15 @@ func (r *UserRepository) UpdateUser(dbUser *schema.User, user *schema.User) erro } } + // --- Fallback: sync any remaining role differences not covered above --- + // This handles admin role assignment/removal and any other combinations that + // the specific branches above do not cover (e.g. user→admin, admin→user). + if !reflect.DeepEqual(sortedRoles(dbUser.Roles), sortedRoles(user.Roles)) { + if err := updateRoles(user.Roles); err != nil { + return err + } + } + return nil } From 31a8a11f1bed32a0839545274770582377e46d91 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 1 Apr 2026 12:36:37 +0200 Subject: [PATCH 3/4] fix: Always request oidc roles from token Entire-Checkpoint: bfdbffd7aae0 --- internal/auth/oidc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index bb4b5d95..2786c09a 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -79,7 +79,7 @@ func NewOIDC(a *Authentication) *OIDC { ClientID: clientID, ClientSecret: clientSecret, Endpoint: provider.Endpoint(), - Scopes: []string{oidc.ScopeOpenID, "profile"}, + Scopes: []string{oidc.ScopeOpenID, "profile", "roles"}, } oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a} From 43807ae12a82f86be755a8af5415f58b5e0df443 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 1 Apr 2026 13:46:21 +0200 Subject: [PATCH 4/4] feat: Also submit projects array via oidc token Entire-Checkpoint: 2064482d97e1 --- internal/auth/oidc.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 2786c09a..934c8542 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -162,7 +162,8 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) { return } - projects := make([]string, 0) + // projects is populated below from ID token claims + var projects []string // Extract profile claims from userinfo (username, name) var userInfoClaims struct { @@ -189,6 +190,8 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) { ResourceAccess map[string]struct { Roles []string `json:"roles"` } `json:"resource_access"` + // Custom multi-valued user attribute mapped via a Keycloak User Attribute mapper + Projects []string `json:"projects"` } if err := idToken.Claims(&idTokenClaims); err != nil { cclog.Errorf("failed to extract ID token claims: %s", err.Error()) @@ -199,6 +202,12 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) { cclog.Debugf("OIDC userinfo claims: username=%q name=%q", userInfoClaims.Username, userInfoClaims.Name) cclog.Debugf("OIDC ID token realm_access roles: %v", idTokenClaims.RealmAccess.Roles) cclog.Debugf("OIDC ID token resource_access: %v", idTokenClaims.ResourceAccess) + cclog.Debugf("OIDC ID token projects: %v", idTokenClaims.Projects) + + projects = idTokenClaims.Projects + if projects == nil { + projects = []string{} + } // Prefer username from userInfo; fall back to ID token claim username := userInfoClaims.Username