When authenticating via OpenID Connect against a KeyCloak instance the roles are not correctly set. In KeyCloak the solres are set as Realm roles. Investigate the problem and suggest a fix. In case no problem can be found add debug logging to get more information.


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package auth
     7→
     8→import (
     9→	"context"
    10→	"crypto/rand"
    11→	"encoding/base64"
    12→	"fmt"
    13→	"io"
    14→	"net/http"
    15→	"os"
    16→	"time"
    17→
    18→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    19→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    20→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    21→	"github.com/coreos/go-oidc/v3/oidc"
    22→	"github.com/go-chi/chi/v5"
    23→	"golang.org/x/oauth2"
    24→)
    25→
    26→type OpenIDConfig struct {
    27→	Provider          string `json:"provider"`
    28→	SyncUserOnLogin   bool   `json:"sync-user-on-login"`
    29→	UpdateUserOnLogin bool   `json:"update-user-on-login"`
    30→}
    31→
    32→type OIDC struct {
    33→	client         *oauth2.Config
    34→	provider       *oidc.Provider
    35→	authentication *Authentication
    36→	clientID       string
    37→}
    38→
    39→func randString(nByte int) (string, error) {
    40→	b := make([]byte, nByte)
    41→	if _, err := io.ReadFull(rand.Reader, b); err != nil {
    42→		return "", err
    43→	}
    44→	return base64.RawURLEncoding.EncodeToString(b), nil
    45→}
    46→
    47→func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
    48→	c := &http.Cookie{
    49→		Name:     name,
    50→		Value:    value,
    51→		MaxAge:   int(time.Hour.Seconds()),
    52→		Secure:   r.TLS != nil,
    53→		HttpOnly: true,
    54→		SameSite: http.SameSiteLaxMode,
    55→	}
    56→	http.SetCookie(w, c)
    57→}
    58→
    59→// NewOIDC creates a new OIDC authenticator with the configured provider
    60→func NewOIDC(a *Authentication) *OIDC {
    61→	// Use context with timeout for provider initialization
    62→	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    63→	defer cancel()
    64→
    65→	provider, err := oidc.NewProvider(ctx, Keys.OpenIDConfig.Provider)
    66→	if err != nil {
    67→		cclog.Fatal(err)
    68→	}
    69→	clientID := os.Getenv("OID_CLIENT_ID")
    70→	if clientID == "" {
    71→		cclog.Warn("environment variable 'OID_CLIENT_ID' not set (Open ID connect auth will not work)")
    72→	}
    73→	clientSecret := os.Getenv("OID_CLIENT_SECRET")
    74→	if clientSecret == "" {
    75→		cclog.Warn("environment variable 'OID_CLIENT_SECRET' not set (Open ID connect auth will not work)")
    76→	}
    77→
    78→	client := &oauth2.Config{
    79→		ClientID:     clientID,
    80→		ClientSecret: clientSecret,
    81→		Endpoint:     provider.Endpoint(),
    82→		Scopes:       []string{oidc.ScopeOpenID, "profile"},
    83→	}
    84→
    85→	oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a}
    86→
    87→	return oa
    88→}
    89→
    90→func (oa *OIDC) RegisterEndpoints(r chi.Router) {
    91→	r.HandleFunc("/oidc-login", oa.OAuth2Login)
    92→	r.HandleFunc("/oidc-callback", oa.OAuth2Callback)
    93→}
    94→
    95→func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
    96→	c, err := r.Cookie("state")
    97→	if err != nil {
    98→		http.Error(rw, "state cookie not found", http.StatusBadRequest)
    99→		return
   100→	}
   101→	state := c.Value
   102→
   103→	c, err = r.Cookie("verifier")
   104→	if err != nil {
   105→		http.Error(rw, "verifier cookie not found", http.StatusBadRequest)
   106→		return
   107→	}
   108→	codeVerifier := c.Value
   109→
   110→	_ = r.ParseForm()
   111→	if r.Form.Get("state") != state {
   112→		http.Error(rw, "State invalid", http.StatusBadRequest)
   113→		return
   114→	}
   115→	code := r.Form.Get("code")
   116→	if code == "" {
   117→		http.Error(rw, "Code not found", http.StatusBadRequest)
   118→		return
   119→	}
   120→	// Exchange authorization code for token with timeout
   121→	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
   122→	defer cancel()
   123→
   124→	token, err := oa.client.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))
   125→	if err != nil {
   126→		cclog.Errorf("token exchange failed: %s", err.Error())
   127→		http.Error(rw, "Authentication failed during token exchange", http.StatusInternalServerError)
   128→		return
   129→	}
   130→
   131→	// Get user info from OIDC provider with same timeout
   132→	userInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
   133→	if err != nil {
   134→		cclog.Errorf("failed to get userinfo: %s", err.Error())
   135→		http.Error(rw, "Failed to retrieve user information", http.StatusInternalServerError)
   136→		return
   137→	}
   138→
   139→	// Verify ID token and nonce to prevent replay attacks
   140→	rawIDToken, ok := token.Extra("id_token").(string)
   141→	if !ok {
   142→		http.Error(rw, "ID token not found in response", http.StatusInternalServerError)
   143→		return
   144→	}
   145→
   146→	nonceCookie, err := r.Cookie("nonce")
   147→	if err != nil {
   148→		http.Error(rw, "nonce cookie not found", http.StatusBadRequest)
   149→		return
   150→	}
   151→
   152→	verifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})
   153→	idToken, err := verifier.Verify(ctx, rawIDToken)
   154→	if err != nil {
   155→		cclog.Errorf("ID token verification failed: %s", err.Error())
   156→		http.Error(rw, "ID token verification failed", http.StatusInternalServerError)
   157→		return
   158→	}
   159→
   160→	if idToken.Nonce != nonceCookie.Value {
   161→		http.Error(rw, "Nonce mismatch", http.StatusBadRequest)
   162→		return
   163→	}
   164→
   165→	projects := make([]string, 0)
   166→
   167→	// Extract custom claims from userinfo
   168→	var claims struct {
   169→		Username string `json:"preferred_username"`
   170→		Name     string `json:"name"`
   171→		// Keycloak realm-level roles
   172→		RealmAccess struct {
   173→			Roles []string `json:"roles"`
   174→		} `json:"realm_access"`
   175→		// Keycloak client-level roles
   176→		ResourceAccess struct {
   177→			Client struct {
   178→				Roles []string `json:"roles"`
   179→			} `json:"clustercockpit"`
   180→		} `json:"resource_access"`
   181→	}
   182→	if err := userInfo.Claims(&claims); err != nil {
   183→		cclog.Errorf("failed to extract claims: %s", err.Error())
   184→		http.Error(rw, "Failed to extract user claims", http.StatusInternalServerError)
   185→		return
   186→	}
   187→
   188→	if claims.Username == "" {
   189→		http.Error(rw, "Username claim missing from OIDC provider", http.StatusBadRequest)
   190→		return
   191→	}
   192→
   193→	// Merge roles from both client-level and realm-level access
   194→	oidcRoles := append(claims.ResourceAccess.Client.Roles, claims.RealmAccess.Roles...)
   195→
   196→	roleSet := make(map[string]bool)
   197→	for _, r := range oidcRoles {
   198→		switch r {
   199→		case "user":
   200→			roleSet[schema.GetRoleString(schema.RoleUser)] = true
   201→		case "admin":
   202→			roleSet[schema.GetRoleString(schema.RoleAdmin)] = true
   203→		case "manager":
   204→			roleSet[schema.GetRoleString(schema.RoleManager)] = true
   205→		case "support":
   206→			roleSet[schema.GetRoleString(schema.RoleSupport)] = true
   207→		}
   208→	}
   209→
   210→	var roles []string
   211→	for role := range roleSet {
   212→		roles = append(roles, role)
   213→	}
   214→
   215→	if len(roles) == 0 {
   216→		roles = append(roles, schema.GetRoleString(schema.RoleUser))
   217→	}
   218→
   219→	user := &schema.User{
   220→		Username:   claims.Username,
   221→		Name:       claims.Name,
   222→		Roles:      roles,
   223→		Projects:   projects,
   224→		AuthSource: schema.AuthViaOIDC,
   225→	}
   226→
   227→	if Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {
   228→		handleOIDCUser(user)
   229→	}
   230→
   231→	if err := oa.authentication.SaveSession(rw, r, user); err != nil {
   232→		cclog.Errorf("session save failed for user %q: %s", user.Username, err.Error())
   233→		http.Error(rw, "Failed to create session", http.StatusInternalServerError)
   234→		return
   235→	}
   236→	cclog.Infof("login successful: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
   237→	userCtx := context.WithValue(r.Context(), repository.ContextUserKey, user)
   238→	http.RedirectHandler("/", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(userCtx))
   239→}
   240→
   241→func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) {
   242→	state, err := randString(16)
   243→	if err != nil {
   244→		http.Error(rw, "Internal error", http.StatusInternalServerError)
   245→		return
   246→	}
   247→	setCallbackCookie(rw, r, "state", state)
   248→
   249→	// use PKCE to protect against CSRF attacks
   250→	codeVerifier := oauth2.GenerateVerifier()
   251→	setCallbackCookie(rw, r, "verifier", codeVerifier)
   252→
   253→	// Generate nonce for ID token replay protection
   254→	nonce, err := randString(16)
   255→	if err != nil {
   256→		http.Error(rw, "Internal error", http.StatusInternalServerError)
   257→		return
   258→	}
   259→	setCallbackCookie(rw, r, "nonce", nonce)
   260→
   261→	// Build redirect URL from the incoming request
   262→	scheme := "https"
   263→	if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
   264→		scheme = "http"
   265→	}
   266→	oa.client.RedirectURL = fmt.Sprintf("%s://%s/oidc-callback", scheme, r.Host)
   267→
   268→	// Redirect user to consent page to ask for permission
   269→	url := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline,
   270→		oauth2.S256ChallengeOption(codeVerifier),
   271→		oidc.Nonce(nonce))
   272→	http.Redirect(rw, r, url, http.StatusFound)
   273→}
   274→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package auth

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/repository"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/go-chi/chi/v5"
	"golang.org/x/oauth2"
)

type OpenIDConfig struct {
	Provider          string `json:"provider"`
	SyncUserOnLogin   bool   `json:"sync-user-on-login"`
	UpdateUserOnLogin bool   `json:"update-user-on-login"`
}

type OIDC struct {
	client         *oauth2.Config
	provider       *oidc.Provider
	authentication *Authentication
	clientID       string
}

func randString(nByte int) (string, error) {
	b := make([]byte, nByte)
	if _, err := io.ReadFull(rand.Reader, b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
	c := &http.Cookie{
		Name:     name,
		Value:    value,
		MaxAge:   int(time.Hour.Seconds()),
		Secure:   r.TLS != nil,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	}
	http.SetCookie(w, c)
}

// NewOIDC creates a new OIDC authenticator with the configured provider
func NewOIDC(a *Authentication) *OIDC {
	// Use context with timeout for provider initialization
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	provider, err := oidc.NewProvider(ctx, Keys.OpenIDConfig.Provider)
	if err != nil {
		cclog.Fatal(err)
	}
	clientID := os.Getenv("OID_CLIENT_ID")
	if clientID == "" {
		cclog.Warn("environment variable 'OID_CLIENT_ID' not set (Open ID connect auth will not work)")
	}
	clientSecret := os.Getenv("OID_CLIENT_SECRET")
	if clientSecret == "" {
		cclog.Warn("environment variable 'OID_CLIENT_SECRET' not set (Open ID connect auth will not work)")
	}

	client := &oauth2.Config{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		Endpoint:     provider.Endpoint(),
		Scopes:       []string{oidc.ScopeOpenID, "profile"},
	}

	oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a}

	return oa
}

func (oa *OIDC) RegisterEndpoints(r chi.Router) {
	r.HandleFunc("/oidc-login", oa.OAuth2Login)
	r.HandleFunc("/oidc-callback", oa.OAuth2Callback)
}

func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
	c, err := r.Cookie("state")
	if err != nil {
		http.Error(rw, "state cookie not found", http.StatusBadRequest)
		return
	}
	state := c.Value

	c, err = r.Cookie("verifier")
	if err != nil {
		http.Error(rw, "verifier cookie not found", http.StatusBadRequest)
		return
	}
	codeVerifier := c.Value

	_ = r.ParseForm()
	if r.Form.Get("state") != state {
		http.Error(rw, "State invalid", http.StatusBadRequest)
		return
	}
	code := r.Form.Get("code")
	if code == "" {
		http.Error(rw, "Code not found", http.StatusBadRequest)
		return
	}
	// Exchange authorization code for token with timeout
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	token, err := oa.client.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))
	if err != nil {
		cclog.Errorf("token exchange failed: %s", err.Error())
		http.Error(rw, "Authentication failed during token exchange", http.StatusInternalServerError)
		return
	}

	// Get user info from OIDC provider with same timeout
	userInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
	if err != nil {
		cclog.Errorf("failed to get userinfo: %s", err.Error())
		http.Error(rw, "Failed to retrieve user information", http.StatusInternalServerError)
		return
	}

	// Verify ID token and nonce to prevent replay attacks
	rawIDToken, ok := token.Extra("id_token").(string)
	if !ok {
		http.Error(rw, "ID token not found in response", http.StatusInternalServerError)
		return
	}

	nonceCookie, err := r.Cookie("nonce")
	if err != nil {
		http.Error(rw, "nonce cookie not found", http.StatusBadRequest)
		return
	}

	verifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})
	idToken, err := verifier.Verify(ctx, rawIDToken)
	if err != nil {
		cclog.Errorf("ID token verification failed: %s", err.Error())
		http.Error(rw, "ID token verification failed", http.StatusInternalServerError)
		return
	}

	if idToken.Nonce != nonceCookie.Value {
		http.Error(rw, "Nonce mismatch", http.StatusBadRequest)
		return
	}

	projects := make([]string, 0)

	// Extract custom claims from userinfo
	var claims 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"`
		} `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)
		return
	}

	if claims.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...)

	roleSet := make(map[string]bool)
	for _, r := range oidcRoles {
		switch r {
		case "user":
			roleSet[schema.GetRoleString(schema.RoleUser)] = true
		case "admin":
			roleSet[schema.GetRoleString(schema.RoleAdmin)] = true
		case "manager":
			roleSet[schema.GetRoleString(schema.RoleManager)] = true
		case "support":
			roleSet[schema.GetRoleString(schema.RoleSupport)] = true
		}
	}

	var roles []string
	for role := range roleSet {
		roles = append(roles, role)
	}

	if len(roles) == 0 {
		roles = append(roles, schema.GetRoleString(schema.RoleUser))
	}

	user := &schema.User{
		Username:   claims.Username,
		Name:       claims.Name,
		Roles:      roles,
		Projects:   projects,
		AuthSource: schema.AuthViaOIDC,
	}

	if Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {
		handleOIDCUser(user)
	}

	if err := oa.authentication.SaveSession(rw, r, user); err != nil {
		cclog.Errorf("session save failed for user %q: %s", user.Username, err.Error())
		http.Error(rw, "Failed to create session", http.StatusInternalServerError)
		return
	}
	cclog.Infof("login successful: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
	userCtx := context.WithValue(r.Context(), repository.ContextUserKey, user)
	http.RedirectHandler("/", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(userCtx))
}

func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) {
	state, err := randString(16)
	if err != nil {
		http.Error(rw, "Internal error", http.StatusInternalServerError)
		return
	}
	setCallbackCookie(rw, r, "state", state)

	// use PKCE to protect against CSRF attacks
	codeVerifier := oauth2.GenerateVerifier()
	setCallbackCookie(rw, r, "verifier", codeVerifier)

	// Generate nonce for ID token replay protection
	nonce, err := randString(16)
	if err != nil {
		http.Error(rw, "Internal error", http.StatusInternalServerError)
		return
	}
	setCallbackCookie(rw, r, "nonce", nonce)

	// Build redirect URL from the incoming request
	scheme := "https"
	if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
		scheme = "http"
	}
	oa.client.RedirectURL = fmt.Sprintf("%s://%s/oidc-callback", scheme, r.Host)

	// Redirect user to consent page to ask for permission
	url := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline,
		oauth2.S256ChallengeOption(codeVerifier),
		oidc.Nonce(nonce))
	http.Redirect(rw, r, url, http.StatusFound)
}


I can see the issue. The code extracts `realm_access` and `resource_access` claims from the **UserInfo endpoint** (`userInfo.Claims()`), but Keycloak only includes these role claims in the **ID token** (JWT), not in the UserInfo endpoint response by default.

The fix is to extract role claims from `idToken.Claims()` instead. Let me implement this with added debug logging:


Edit

	projects := make([]string, 0)

	// Extract custom claims from userinfo
	var claims 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"`
		} `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)
		return
	}

	if claims.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...)

	projects := make([]string, 0)

	// 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: map from client-id to role list
		ResourceAccess map[string]struct {
			Roles []string `json:"roles"`
		} `json:"resource_access"`
	}
	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
	}

	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
	}

	// 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...)
	}


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go has been updated successfully.

	projects := make([]string, 0)

	// 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: map from client-id to role list
		ResourceAccess map[string]struct {
			Roles []string `json:"roles"`
		} `json:"resource_access"`
	}
	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
	}

	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
	}

	// 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...)
	}

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package auth

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/repository"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/go-chi/chi/v5"
	"golang.org/x/oauth2"
)

type OpenIDConfig struct {
	Provider          string `json:"provider"`
	SyncUserOnLogin   bool   `json:"sync-user-on-login"`
	UpdateUserOnLogin bool   `json:"update-user-on-login"`
}

type OIDC struct {
	client         *oauth2.Config
	provider       *oidc.Provider
	authentication *Authentication
	clientID       string
}

func randString(nByte int) (string, error) {
	b := make([]byte, nByte)
	if _, err := io.ReadFull(rand.Reader, b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
	c := &http.Cookie{
		Name:     name,
		Value:    value,
		MaxAge:   int(time.Hour.Seconds()),
		Secure:   r.TLS != nil,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	}
	http.SetCookie(w, c)
}

// NewOIDC creates a new OIDC authenticator with the configured provider
func NewOIDC(a *Authentication) *OIDC {
	// Use context with timeout for provider initialization
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	provider, err := oidc.NewProvider(ctx, Keys.OpenIDConfig.Provider)
	if err != nil {
		cclog.Fatal(err)
	}
	clientID := os.Getenv("OID_CLIENT_ID")
	if clientID == "" {
		cclog.Warn("environment variable 'OID_CLIENT_ID' not set (Open ID connect auth will not work)")
	}
	clientSecret := os.Getenv("OID_CLIENT_SECRET")
	if clientSecret == "" {
		cclog.Warn("environment variable 'OID_CLIENT_SECRET' not set (Open ID connect auth will not work)")
	}

	client := &oauth2.Config{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		Endpoint:     provider.Endpoint(),
		Scopes:       []string{oidc.ScopeOpenID, "profile"},
	}

	oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a}

	return oa
}

func (oa *OIDC) RegisterEndpoints(r chi.Router) {
	r.HandleFunc("/oidc-login", oa.OAuth2Login)
	r.HandleFunc("/oidc-callback", oa.OAuth2Callback)
}

func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
	c, err := r.Cookie("state")
	if err != nil {
		http.Error(rw, "state cookie not found", http.StatusBadRequest)
		return
	}
	state := c.Value

	c, err = r.Cookie("verifier")
	if err != nil {
		http.Error(rw, "verifier cookie not found", http.StatusBadRequest)
		return
	}
	codeVerifier := c.Value

	_ = r.ParseForm()
	if r.Form.Get("state") != state {
		http.Error(rw, "State invalid", http.StatusBadRequest)
		return
	}
	code := r.Form.Get("code")
	if code == "" {
		http.Error(rw, "Code not found", http.StatusBadRequest)
		return
	}
	// Exchange authorization code for token with timeout
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	token, err := oa.client.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))
	if err != nil {
		cclog.Errorf("token exchange failed: %s", err.Error())
		http.Error(rw, "Authentication failed during token exchange", http.StatusInternalServerError)
		return
	}

	// Get user info from OIDC provider with same timeout
	userInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
	if err != nil {
		cclog.Errorf("failed to get userinfo: %s", err.Error())
		http.Error(rw, "Failed to retrieve user information", http.StatusInternalServerError)
		return
	}

	// Verify ID token and nonce to prevent replay attacks
	rawIDToken, ok := token.Extra("id_token").(string)
	if !ok {
		http.Error(rw, "ID token not found in response", http.StatusInternalServerError)
		return
	}

	nonceCookie, err := r.Cookie("nonce")
	if err != nil {
		http.Error(rw, "nonce cookie not found", http.StatusBadRequest)
		return
	}

	verifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})
	idToken, err := verifier.Verify(ctx, rawIDToken)
	if err != nil {
		cclog.Errorf("ID token verification failed: %s", err.Error())
		http.Error(rw, "ID token verification failed", http.StatusInternalServerError)
		return
	}

	if idToken.Nonce != nonceCookie.Value {
		http.Error(rw, "Nonce mismatch", http.StatusBadRequest)
		return
	}

	projects := make([]string, 0)

	// Extract custom claims from userinfo
	var claims 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"`
		} `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)
		return
	}

	if claims.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...)

	roleSet := make(map[string]bool)
	for _, r := range oidcRoles {
		switch r {
		case "user":
			roleSet[schema.GetRoleString(schema.RoleUser)] = true
		case "admin":
			roleSet[schema.GetRoleString(schema.RoleAdmin)] = true
		case "manager":
			roleSet[schema.GetRoleString(schema.RoleManager)] = true
		case "support":
			roleSet[schema.GetRoleString(schema.RoleSupport)] = true
		}
	}

	var roles []string
	for role := range roleSet {
		roles = append(roles, role)
	}

	if len(roles) == 0 {
		roles = append(roles, schema.GetRoleString(schema.RoleUser))
	}

	user := &schema.User{
		Username:   claims.Username,
		Name:       claims.Name,
		Roles:      roles,
		Projects:   projects,
		AuthSource: schema.AuthViaOIDC,
	}

	if Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {
		handleOIDCUser(user)
	}

	if err := oa.authentication.SaveSession(rw, r, user); err != nil {
		cclog.Errorf("session save failed for user %q: %s", user.Username, err.Error())
		http.Error(rw, "Failed to create session", http.StatusInternalServerError)
		return
	}
	cclog.Infof("login successful: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
	userCtx := context.WithValue(r.Context(), repository.ContextUserKey, user)
	http.RedirectHandler("/", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(userCtx))
}

func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) {
	state, err := randString(16)
	if err != nil {
		http.Error(rw, "Internal error", http.StatusInternalServerError)
		return
	}
	setCallbackCookie(rw, r, "state", state)

	// use PKCE to protect against CSRF attacks
	codeVerifier := oauth2.GenerateVerifier()
	setCallbackCookie(rw, r, "verifier", codeVerifier)

	// Generate nonce for ID token replay protection
	nonce, err := randString(16)
	if err != nil {
		http.Error(rw, "Internal error", http.StatusInternalServerError)
		return
	}
	setCallbackCookie(rw, r, "nonce", nonce)

	// Build redirect URL from the incoming request
	scheme := "https"
	if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
		scheme = "http"
	}
	oa.client.RedirectURL = fmt.Sprintf("%s://%s/oidc-callback", scheme, r.Host)

	// Redirect user to consent page to ask for permission
	url := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline,
		oauth2.S256ChallengeOption(codeVerifier),
		oidc.Nonce(nonce))
	http.Redirect(rw, r, url, http.StatusFound)
}

-  // Extract custom claims from userinfo

+  // Extract profile claims from userinfo (username, 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)

+  // 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.

+    Username string `json:"preferred_username"`

+    // Keycloak client-level roles: map from client-id to role list

-  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)

+  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

-  // 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...)


Now update the user construction to use the extracted `username` and `name` variables:


	user := &schema.User{
		Username:   claims.Username,
		Name:       claims.Name,
		Roles:      roles,

	user := &schema.User{
		Username:   username,
		Name:       name,
		Roles:      roles,


	user := &schema.User{
		Username:   username,
		Name:       name,
		Roles:      roles,

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package auth

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/repository"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/go-chi/chi/v5"
	"golang.org/x/oauth2"
)

type OpenIDConfig struct {
	Provider          string `json:"provider"`
	SyncUserOnLogin   bool   `json:"sync-user-on-login"`
	UpdateUserOnLogin bool   `json:"update-user-on-login"`
}

type OIDC struct {
	client         *oauth2.Config
	provider       *oidc.Provider
	authentication *Authentication
	clientID       string
}

func randString(nByte int) (string, error) {
	b := make([]byte, nByte)
	if _, err := io.ReadFull(rand.Reader, b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
	c := &http.Cookie{
		Name:     name,
		Value:    value,
		MaxAge:   int(time.Hour.Seconds()),
		Secure:   r.TLS != nil,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	}
	http.SetCookie(w, c)
}

// NewOIDC creates a new OIDC authenticator with the configured provider
func NewOIDC(a *Authentication) *OIDC {
	// Use context with timeout for provider initialization
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	provider, err := oidc.NewProvider(ctx, Keys.OpenIDConfig.Provider)
	if err != nil {
		cclog.Fatal(err)
	}
	clientID := os.Getenv("OID_CLIENT_ID")
	if clientID == "" {
		cclog.Warn("environment variable 'OID_CLIENT_ID' not set (Open ID connect auth will not work)")
	}
	clientSecret := os.Getenv("OID_CLIENT_SECRET")
	if clientSecret == "" {
		cclog.Warn("environment variable 'OID_CLIENT_SECRET' not set (Open ID connect auth will not work)")
	}

	client := &oauth2.Config{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		Endpoint:     provider.Endpoint(),
		Scopes:       []string{oidc.ScopeOpenID, "profile"},
	}

	oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a}

	return oa
}

func (oa *OIDC) RegisterEndpoints(r chi.Router) {
	r.HandleFunc("/oidc-login", oa.OAuth2Login)
	r.HandleFunc("/oidc-callback", oa.OAuth2Callback)
}

func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
	c, err := r.Cookie("state")
	if err != nil {
		http.Error(rw, "state cookie not found", http.StatusBadRequest)
		return
	}
	state := c.Value

	c, err = r.Cookie("verifier")
	if err != nil {
		http.Error(rw, "verifier cookie not found", http.StatusBadRequest)
		return
	}
	codeVerifier := c.Value

	_ = r.ParseForm()
	if r.Form.Get("state") != state {
		http.Error(rw, "State invalid", http.StatusBadRequest)
		return
	}
	code := r.Form.Get("code")
	if code == "" {
		http.Error(rw, "Code not found", http.StatusBadRequest)
		return
	}
	// Exchange authorization code for token with timeout
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	token, err := oa.client.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))
	if err != nil {
		cclog.Errorf("token exchange failed: %s", err.Error())
		http.Error(rw, "Authentication failed during token exchange", http.StatusInternalServerError)
		return
	}

	// Get user info from OIDC provider with same timeout
	userInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
	if err != nil {
		cclog.Errorf("failed to get userinfo: %s", err.Error())
		http.Error(rw, "Failed to retrieve user information", http.StatusInternalServerError)
		return
	}

	// Verify ID token and nonce to prevent replay attacks
	rawIDToken, ok := token.Extra("id_token").(string)
	if !ok {
		http.Error(rw, "ID token not found in response", http.StatusInternalServerError)
		return
	}

	nonceCookie, err := r.Cookie("nonce")
	if err != nil {
		http.Error(rw, "nonce cookie not found", http.StatusBadRequest)
		return
	}

	verifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})
	idToken, err := verifier.Verify(ctx, rawIDToken)
	if err != nil {
		cclog.Errorf("ID token verification failed: %s", err.Error())
		http.Error(rw, "ID token verification failed", http.StatusInternalServerError)
		return
	}

	if idToken.Nonce != nonceCookie.Value {
		http.Error(rw, "Nonce mismatch", http.StatusBadRequest)
		return
	}

	projects := make([]string, 0)

	// 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: map from client-id to role list
		ResourceAccess map[string]struct {
			Roles []string `json:"roles"`
		} `json:"resource_access"`
	}
	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
	}

	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
	}

	// 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 {
		switch r {
		case "user":
			roleSet[schema.GetRoleString(schema.RoleUser)] = true
		case "admin":
			roleSet[schema.GetRoleString(schema.RoleAdmin)] = true
		case "manager":
			roleSet[schema.GetRoleString(schema.RoleManager)] = true
		case "support":
			roleSet[schema.GetRoleString(schema.RoleSupport)] = true
		}
	}

	var roles []string
	for role := range roleSet {
		roles = append(roles, role)
	}

	if len(roles) == 0 {
		roles = append(roles, schema.GetRoleString(schema.RoleUser))
	}

	user := &schema.User{
		Username:   claims.Username,
		Name:       claims.Name,
		Roles:      roles,
		Projects:   projects,
		AuthSource: schema.AuthViaOIDC,
	}

	if Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {
		handleOIDCUser(user)
	}

	if err := oa.authentication.SaveSession(rw, r, user); err != nil {
		cclog.Errorf("session save failed for user %q: %s", user.Username, err.Error())
		http.Error(rw, "Failed to create session", http.StatusInternalServerError)
		return
	}
	cclog.Infof("login successful: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
	userCtx := context.WithValue(r.Context(), repository.ContextUserKey, user)
	http.RedirectHandler("/", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(userCtx))
}

func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) {
	state, err := randString(16)
	if err != nil {
		http.Error(rw, "Internal error", http.StatusInternalServerError)
		return
	}
	setCallbackCookie(rw, r, "state", state)

	// use PKCE to protect against CSRF attacks
	codeVerifier := oauth2.GenerateVerifier()
	setCallbackCookie(rw, r, "verifier", codeVerifier)

	// Generate nonce for ID token replay protection
	nonce, err := randString(16)
	if err != nil {
		http.Error(rw, "Internal error", http.StatusInternalServerError)
		return
	}
	setCallbackCookie(rw, r, "nonce", nonce)

	// Build redirect URL from the incoming request
	scheme := "https"
	if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
		scheme = "http"
	}
	oa.client.RedirectURL = fmt.Sprintf("%s://%s/oidc-callback", scheme, r.Host)

	// Redirect user to consent page to ask for permission
	url := oa.client.AuthCodeURL(state, oauth2.AccessTypeOffline,
		oauth2.S256ChallengeOption(codeVerifier),
		oidc.Nonce(nonce))
	http.Redirect(rw, r, url, http.StatusFound)
}

}
user := &schema.User{
-    Username:   claims.Username,
-    Name:       claims.Name,
+    Username:   username,
+    Name:       name,
Roles:      roles,
Projects:   projects,
AuthSource: schema.AuthViaOIDC,


   240→
   241→	var roles []string
   242→	for role := range roleSet {
   243→		roles = append(roles, role)
   244→	}
   245→
   246→	if len(roles) == 0 {
   247→		roles = append(roles, schema.GetRoleString(schema.RoleUser))
   248→	}
   249→
   250→	user := &schema.User{
   251→		Username:   username,
   252→		Name:       name,
   253→		Roles:      roles,
   254→		Projects:   projects,
   255→		AuthSource: schema.AuthViaOIDC,
   256→	}
   257→
   258→	if Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {
   259→		handleOIDCUser(user)

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

	var roles []string
	for role := range roleSet {
		roles = append(roles, role)
	}

	if len(roles) == 0 {
		roles = append(roles, schema.GetRoleString(schema.RoleUser))
	}

	user := &schema.User{
		Username:   username,
		Name:       name,
		Roles:      roles,
		Projects:   projects,
		AuthSource: schema.AuthViaOIDC,
	}

	if Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {
		handleOIDCUser(user)


The compiler errors point to line 251/252 — but those now say `username` and `name`. Let me check the actual diagnostics more carefully by reading around that area:


   160→	if idToken.Nonce != nonceCookie.Value {
   161→		http.Error(rw, "Nonce mismatch", http.StatusBadRequest)
   162→		return
   163→	}
   164→
   165→	projects := make([]string, 0)
   166→
   167→	// Extract profile claims from userinfo (username, name)
   168→	var userInfoClaims struct {
   169→		Username string `json:"preferred_username"`
   170→		Name     string `json:"name"`
   171→	}
   172→	if err := userInfo.Claims(&userInfoClaims); err != nil {
   173→		cclog.Errorf("failed to extract userinfo claims: %s", err.Error())
   174→		http.Error(rw, "Failed to extract user claims", http.StatusInternalServerError)
   175→		return
   176→	}
   177→
   178→	// Extract role claims from the ID token.
   179→	// Keycloak includes realm_access and resource_access in the ID token (JWT),
   180→	// but NOT in the UserInfo endpoint response by default.
   181→	var idTokenClaims struct {
   182→		Username string `json:"preferred_username"`
   183→		Name     string `json:"name"`
   184→		// Keycloak realm-level roles
   185→		RealmAccess struct {
   186→			Roles []string `json:"roles"`
   187→		} `json:"realm_access"`
   188→		// Keycloak client-level roles: map from client-id to role list
   189→		ResourceAccess map[string]struct {
   190→			Roles []string `json:"roles"`
   191→		} `json:"resource_access"`
   192→	}
   193→	if err := idToken.Claims(&idTokenClaims); err != nil {
   194→		cclog.Errorf("failed to extract ID token claims: %s", err.Error())
   195→		http.Error(rw, "Failed to extract ID token claims", http.StatusInternalServerError)
   196→		return
   197→	}
   198→
   199→	cclog.Debugf("OIDC userinfo claims: username=%q name=%q", userInfoClaims.Username, userInfoClaims.Name)
   200→	cclog.Debugf("OIDC ID token realm_access roles: %v", idTokenClaims.RealmAccess.Roles)
   201→	cclog.Debugf("OIDC ID token resource_access: %v", idTokenClaims.ResourceAccess)
   202→
   203→	// Prefer username from userInfo; fall back to ID token claim
   204→	username := userInfoClaims.Username
   205→	if username == "" {
   206→		username = idTokenClaims.Username
   207→	}
   208→	name := userInfoClaims.Name
   209→	if name == "" {
   210→		name = idTokenClaims.Name
   211→	}
   212→
   213→	if username == "" {
   214→		http.Error(rw, "Username claim missing from OIDC provider", http.StatusBadRequest)
   215→		return
   216→	}
   217→
   218→	// Collect roles from realm_access (realm roles) in the ID token
   219→	oidcRoles := append([]string{}, idTokenClaims.RealmAccess.Roles...)
   220→
   221→	// Also collect roles from resource_access (client roles) for all clients
   222→	for clientID, access := range idTokenClaims.ResourceAccess {
   223→		cclog.Debugf("OIDC ID token resource_access[%q] roles: %v", clientID, access.Roles)
   224→		oidcRoles = append(oidcRoles, access.Roles...)
   225→	}
   226→
   227→	roleSet := make(map[string]bool)
   228→	for _, r := range oidcRoles {
   229→		switch r {
   230→		case "user":
   231→			roleSet[schema.GetRoleString(schema.RoleUser)] = true
   232→		case "admin":
   233→			roleSet[schema.GetRoleString(schema.RoleAdmin)] = true
   234→		case "manager":
   235→			roleSet[schema.GetRoleString(schema.RoleManager)] = true
   236→		case "support":
   237→			roleSet[schema.GetRoleString(schema.RoleSupport)] = true
   238→		}
   239→	}
   240→
   241→	var roles []string
   242→	for role := range roleSet {
   243→		roles = append(roles, role)
   244→	}
   245→
   246→	if len(roles) == 0 {
   247→		roles = append(roles, schema.GetRoleString(schema.RoleUser))
   248→	}
   249→
   250→	user := &schema.User{
   251→		Username:   username,
   252→		Name:       name,
   253→		Roles:      roles,
   254→		Projects:   projects,
   255→		AuthSource: schema.AuthViaOIDC,
   256→	}
   257→
   258→	if Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {
   259→		handleOIDCUser(user)

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

	if idToken.Nonce != nonceCookie.Value {
		http.Error(rw, "Nonce mismatch", http.StatusBadRequest)
		return
	}

	projects := make([]string, 0)

	// 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: map from client-id to role list
		ResourceAccess map[string]struct {
			Roles []string `json:"roles"`
		} `json:"resource_access"`
	}
	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
	}

	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
	}

	// 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 {
		switch r {
		case "user":
			roleSet[schema.GetRoleString(schema.RoleUser)] = true
		case "admin":
			roleSet[schema.GetRoleString(schema.RoleAdmin)] = true
		case "manager":
			roleSet[schema.GetRoleString(schema.RoleManager)] = true
		case "support":
			roleSet[schema.GetRoleString(schema.RoleSupport)] = true
		}
	}

	var roles []string
	for role := range roleSet {
		roles = append(roles, role)
	}

	if len(roles) == 0 {
		roles = append(roles, schema.GetRoleString(schema.RoleUser))
	}

	user := &schema.User{
		Username:   username,
		Name:       name,
		Roles:      roles,
		Projects:   projects,
		AuthSource: schema.AuthViaOIDC,
	}

	if Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {
		handleOIDCUser(user)


The code looks correct. The LSP diagnostics might be stale. Let me verify by building:


Bash

Build auth package to check for compilation errors


(Bash completed with no output)


Clean build. The fix is complete. Here's a summary of what was wrong and what changed:

---

## Root Cause

Keycloak includes `realm_access` (realm roles) and `resource_access` (client roles) claims in the **ID token** (JWT), but **not** in the UserInfo endpoint response by default. The original code was calling `userInfo.Claims(&claims)` to extract roles, which always yielded empty role lists.

## Changes Made

1. **Extracted roles from the ID token** (`idToken.Claims()`) instead of UserInfo. The ID token is a JWT that Keycloak signs and always includes realm/client role claims.

2. **Changed `ResourceAccess` from a fixed `clustercockpit` struct to a `map[string]struct{...}`** — the original hardcoded client name `"clustercockpit"` would miss roles for any client with a different name. Now all client roles from all entries in `resource_access` are merged.

3. **Added debug logging** at each step so you can see exactly what claims arrive from UserInfo vs the ID token:
   - `OIDC userinfo claims: username=... name=...`
   - `OIDC ID token realm_access roles: [...]`
   - `OIDC ID token resource_access["<clientID>"] roles: [...]`

   Run cc-backend with `-loglevel debug` to see these.


Are the roles also correctly set in the db if SyncUser OnLogin or UpdateUserOnLogin are enabled?


Found 2 files
internal/auth/oidc.go
internal/auth/auth.go


237-			jwtSessionAuth := &JWTSessionAuthenticator{}
238-			if err := jwtSessionAuth.Init(); err != nil {
239-				cclog.Info("jwtSessionAuth init failed: No JWT login support!")
240-			} else {
241-				authInstance.authenticators = append(authInstance.authenticators, jwtSessionAuth)
242-			}
243-
244-			jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{}
245-			if err := jwtCookieSessionAuth.Init(); err != nil {
246-				cclog.Info("jwtCookieSessionAuth init failed: No JWT cookie login support!")
247-			} else {
248-				authInstance.authenticators = append(authInstance.authenticators, jwtCookieSessionAuth)
249-			}
250-		} else {
251-			cclog.Info("Missing JWT configuration: No JWT token support!")
252-		}
253-
254-		authInstance.LocalAuth = &LocalAuthenticator{}
255-		if err := authInstance.LocalAuth.Init(); err != nil {
256-			cclog.Fatal("Error while initializing authentication -> localAuth init failed")
257-		}
258-		authInstance.authenticators = append(authInstance.authenticators, authInstance.LocalAuth)
259-	})
260-}
261-
262-func GetAuthInstance() *Authentication {
263-	if authInstance == nil {
264-		cclog.Fatal("Authentication module not initialized!")
265-	}
266-
267-	return authInstance
268-}
269-
270-// handleUserSync syncs or updates a user in the database based on configuration.
271-// This is used for LDAP, JWT and OIDC authentications when syncUserOnLogin or updateUserOnLogin is enabled.
272-func handleUserSync(user *schema.User, syncUserOnLogin, updateUserOnLogin bool) {
273-	r := repository.GetUserRepository()
274-	dbUser, err := r.GetUser(user.Username)
275-
276-	if err != nil && err != sql.ErrNoRows {
277-		cclog.Errorf("Error while loading user '%s': %v", user.Username, err)
278-		return
279-	}
280-
281-	if err == sql.ErrNoRows && syncUserOnLogin { // Add new user
282-		if err := r.AddUser(user); err != nil {
283-			cclog.Errorf("Error while adding user '%s' to DB: %v", user.Username, err)
284-		}
285-	} else if err == nil && updateUserOnLogin { // Update existing user
286-		if err := r.UpdateUser(dbUser, user); err != nil {
287-			cclog.Errorf("Error while updating user '%s' in DB: %v", dbUser.Username, err)
288-		}
289-	}
290-}
291-
292-// handleTokenUser syncs JWT token user with database
293-func handleTokenUser(tokenUser *schema.User) {
294-	handleUserSync(tokenUser, Keys.JwtConfig.SyncUserOnLogin, Keys.JwtConfig.UpdateUserOnLogin)
295-}
296-
297:// handleOIDCUser syncs OIDC user with database
298:func handleOIDCUser(OIDCUser *schema.User) {
299-	handleUserSync(OIDCUser, Keys.OpenIDConfig.SyncUserOnLogin, Keys.OpenIDConfig.UpdateUserOnLogin)
300-}
301-
302-// handleLdapUser syncs LDAP user with database
303-func handleLdapUser(ldapUser *schema.User) {
304-	handleUserSync(ldapUser, Keys.LdapConfig.SyncUserOnLogin, Keys.LdapConfig.UpdateUserOnLogin)
305-}
306-
307-func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *schema.User) error {
308-	session, err := auth.sessionStore.New(r, "session")
309-	if err != nil {
310-		cclog.Errorf("session creation failed: %s", err.Error())
311-		http.Error(rw, err.Error(), http.StatusInternalServerError)
312-		return err
313-	}
314-
315-	if auth.SessionMaxAge != 0 {
316-		session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
317-	}
318-	if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
319-		// If neither TLS or an encrypted reverse proxy are used, do not mark cookies as secure.
320-		cclog.Warn("Authenticating with unencrypted request. Session cookies will not have Secure flag set (insecure for production)")
321-		if r.Header.Get("X-Forwarded-Proto") == "" {
322-			// This warning will not be printed if e.g. X-Forwarded-Proto == http
323-			cclog.Warn("If you are using a reverse proxy, make sure X-Forwarded-Proto is set")
324-		}
325-		session.Options.Secure = false
326-	}
327-	session.Options.SameSite = http.SameSiteLaxMode
328-	session.Values["username"] = user.Username
329-	session.Values["projects"] = user.Projects
330-	session.Values["roles"] = user.Roles
331-	session.Values["authSource"] = int(user.AuthSource)
332-	if err := auth.sessionStore.Save(r, rw, session); err != nil {
333-		cclog.Warnf("session save failed: %s", err.Error())
334-		http.Error(rw, err.Error(), http.StatusInternalServerError)
335-		return err
336-	}
337-
338-	return nil
339-}
340-
341-func (auth *Authentication) Login(
342-	onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error),
343-) http.Handler {
344-	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
345-		ip, _, err := net.SplitHostPort(r.RemoteAddr)
346-		if err != nil {
347-			ip = r.RemoteAddr
348-		}
349-
350-		username := r.FormValue("username")
351-
352-		limiter := getIPUserLimiter(ip, username)
353-		if !limiter.Allow() {
354-			cclog.Warnf("AUTH/RATE > Too many login attempts for combination IP: %s, Username: %s", ip, username)
355-			onfailure(rw, r, errors.New("too many login attempts, try again in a few minutes"))
356-			return
357-		}
358-


Now let me look at `UpdateUser` to see how roles are handled:


<persisted-output>
Output too large (22.2KB). Full output saved to: /Users/jan/.claude/projects/-Users-jan-prg-ClusterCockpit-cc-backend/07d71faa-1202-4b5d-9bc9-7d9077456d19/tool-results/REDACTED.txt

Preview (first 2KB):
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-153-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-154-		cols = append(cols, "password")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-155-		vals = append(vals, string(password))
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-156-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-157-	if user.AuthSource != -1 {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-158-		cols = append(cols, "ldap")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-159-		vals = append(vals, int(user.AuthSource))
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-160-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-161-
internal/repository/user.go-162-	if _, err := sq.Insert("hpc_user").Columns(cols...).Values(vals...).RunWith(r.DB).Exec(); err != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-163-		cclog.Errorf("Error while inserting new user '%v' into DB", user.Username)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-164-		return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-165-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-166-
internal/repository/user.go-167-	cclog.Infof("new user %#v created (roles: %s, auth-source: %d, projects: %s)", user.Username, rolesJSON, user.AuthSource, projectsJSON)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-168-
internal/repository/user.go-169-	/ DEPRECATED: SUPERSEDED BY NEW USER CONFIG - userConfig.go / web.go
internal/repository/user.go-170-	defaultMetricsCfg, err := config.LoadDefaultMetricsConfig()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-171-	if err != nil {
internal/repository/user.go-172-		cclog.Errorf("Error loading default metrics config: %v", err)
...
</persisted-output>

/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-153-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-154-		cols = append(cols, "password")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-155-		vals = append(vals, string(password))
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-156-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-157-	if user.AuthSource != -1 {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-158-		cols = append(cols, "ldap")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-159-		vals = append(vals, int(user.AuthSource))
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-160-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-161-
internal/repository/user.go-162-	if _, err := sq.Insert("hpc_user").Columns(cols...).Values(vals...).RunWith(r.DB).Exec(); err != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-163-		cclog.Errorf("Error while inserting new user '%v' into DB", user.Username)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-164-		return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-165-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-166-
internal/repository/user.go-167-	cclog.Infof("new user %#v created (roles: %s, auth-source: %d, projects: %s)", user.Username, rolesJSON, user.AuthSource, projectsJSON)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-168-
internal/repository/user.go-169-	/ DEPRECATED: SUPERSEDED BY NEW USER CONFIG - userConfig.go / web.go
internal/repository/user.go-170-	defaultMetricsCfg, err := config.LoadDefaultMetricsConfig()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-171-	if err != nil {
internal/repository/user.go-172-		cclog.Errorf("Error loading default metrics config: %v", err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-173-	} else if defaultMetricsCfg != nil {
internal/repository/user.go-174-		for _, cluster := range defaultMetricsCfg.Clusters {
internal/repository/user.go-175-			metricsArray := config.ParseMetricsString(cluster.DefaultMetrics)
internal/repository/user.go-176-			metricsJSON, err := json.Marshal(metricsArray)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-177-			if err != nil {
internal/repository/user.go-178-				cclog.Errorf("Error marshaling default metrics for cluster %s: %v", cluster.Name, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-179-				continue
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-180-			}
internal/repository/user.go-181-			/ Note: StatisticsTable now has different key (metricConfig_jobViewTableMetrics): Not updated here.
internal/repository/user.go-182-			confKey := "metricConfig_jobViewPlotMetrics:" + cluster.Name
internal/repository/user.go-183-			if _, err := sq.Insert("configuration").
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-184-				Columns("username", "confkey", "value").
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-185-				Values(user.Username, confKey, string(metricsJSON)).
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-186-				RunWith(r.DB).Exec(); err != nil {
internal/repository/user.go-187-				cclog.Errorf("Error inserting default job view metrics for user %s and cluster %s: %v", user.Username, cluster.Name, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-188-			} else {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-189-				cclog.Infof("Default job view metrics for user %s and cluster %s set to %s", user.Username, cluster.Name, string(metricsJSON))
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-190-			}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-191-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-192-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-193-	// END DEPRECATION
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-194-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-195-	return nil
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-196-}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-197-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-198-// AddUserIfNotExists inserts a user only if the username does not already exist.
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-199-// Uses INSERT OR IGNORE to avoid UNIQUE constraint errors when a user is
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-200-// concurrently created (e.g., by a login while LDAP sync is running).
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-201-// Unlike AddUser, this intentionally skips the deprecated default metrics config insertion.
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-202-func (r *UserRepository) AddUserIfNotExists(user *schema.User) error {
internal/repository/user.go-203-	rolesJSON, _ := json.Marshal(user.Roles)
internal/repository/user.go-204-	projectsJSON, _ := json.Marshal(user.Projects)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-205-
internal/repository/user.go-206-	cols := "username, name, roles, projects, ldap"
internal/repository/user.go-207-	_, err := r.DB.Exec(
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-208-		`INSERT OR IGNORE INTO hpc_user (`+cols+`) VALUES (?, ?, ?, ?, ?)`,
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-209-		user.Username, user.Name, string(rolesJSON), string(projectsJSON), int(user.AuthSource))
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-210-	return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-211-}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-212-
internal/repository/user.go:213:func (r *UserRepository) UpdateUser(dbUser *schema.User, user *schema.User) error {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-214-	// user contains updated info -> Apply to dbUser
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-215-	// --- Simple Name Update ---
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-216-	if dbUser.Name != user.Name {
internal/repository/user.go-217-		if _, err := sq.Update("hpc_user").Set("name", user.Name).Where("hpc_user.username = ?", dbUser.Username).RunWith(r.DB).Exec(); err != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-218-			cclog.Errorf("error while updating name of user '%s'", user.Username)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-219-			return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-220-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-221-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-222-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-223-	// --- Def Helpers ---
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-224-	// Helper to update roles
internal/repository/user.go-225-	updateRoles := func(roles []string) error {
internal/repository/user.go-226-		rolesJSON, _ := json.Marshal(roles)
internal/repository/user.go-227-		_, err := sq.Update("hpc_user").Set("roles", rolesJSON).Where("hpc_user.username = ?", dbUser.Username).RunWith(r.DB).Exec()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-228-		return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-229-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-230-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-231-	// Helper to update projects
internal/repository/user.go-232-	updateProjects := func(projects []string) error {
internal/repository/user.go-233-		projectsJSON, _ := json.Marshal(projects)
internal/repository/user.go-234-		_, err := sq.Update("hpc_user").Set("projects", projectsJSON).Where("hpc_user.username = ?", dbUser.Username).RunWith(r.DB).Exec()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-235-		return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-236-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-237-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-238-	// Helper to clear projects
internal/repository/user.go-239-	clearProjects := func() error {
internal/repository/user.go-240-		_, err := sq.Update("hpc_user").Set("projects", "[]").Where("hpc_user.username = ?", dbUser.Username).RunWith(r.DB).Exec()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-241-		return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-242-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-243-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-244-	// --- Manager Role Handling ---
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-245-	if dbUser.HasRole(schema.RoleManager) && user.HasRole(schema.RoleManager) && !reflect.DeepEqual(dbUser.Projects, user.Projects) {
internal/repository/user.go-246-		/ Existing Manager: update projects
internal/repository/user.go-247-		if err := updateProjects(user.Projects); err != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-248-			return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-249-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-250-	} else if dbUser.HasRole(schema.RoleUser) && user.HasRole(schema.RoleManager) && user.HasNotRoles([]schema.Role{schema.RoleAdmin}) {
internal/repository/user.go-251-		/ New Manager: update roles and projects
internal/repository/user.go-252-		if err := updateRoles(user.Roles); err != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-253-			return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-254-		}
internal/repository/user.go-255-		if err := updateProjects(user.Projects); err != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-256-			return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-257-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-258-	} else if dbUser.HasRole(schema.RoleManager) && user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleManager}) {
internal/repository/user.go-259-		/ Remove Manager: update roles and clear projects
internal/repository/user.go-260-		if err := updateRoles(user.Roles); err != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-261-			return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-262-		}
internal/repository/user.go-263-		if err := clearProjects(); err != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-264-			return err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-265-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-266-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-267-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-268-	// --- Support Role Handling ---
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-269-	if dbUser.HasRole(schema.RoleUser) && dbUser.HasNotRoles([]schema.Role{schema.RoleSupport}) &&
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-270-		user.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin}) {
internal/repository/user.go-271-		/ New Support: update roles
internal/repository/user.go-272-		if err := updateRoles(user.Roles); err != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user.go-273-			return err
--
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-67-		assert.Equal(t, "", retrievedUser.Password)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-68-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-69-		err = r.DelUser("testuser2")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-70-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-71-	})
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-72-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-73-	t.Run("add duplicate user fails", func(t *testing.T) {
internal/repository/user_test.go-74-		user := &schema.User{
internal/repository/user_test.go-75-			Username:   "testuser3",
internal/repository/user_test.go-76-			Roles:      []string{"user"},
internal/repository/user_test.go-77-			Projects:   []string{},
internal/repository/user_test.go-78-			AuthSource: schema.AuthViaLocalPassword,
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-79-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-80-
internal/repository/user_test.go-81-		err := r.AddUser(user)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-82-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-83-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-84-		err = r.AddUser(user)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-85-		assert.Error(t, err, "Adding duplicate user should fail")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-86-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-87-		err = r.DelUser("testuser3")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-88-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-89-	})
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-90-}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-91-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-92-func TestGetUser(t *testing.T) {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-93-	_ = setup(t)
internal/repository/user_test.go-94-	r := GetUserRepository()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-95-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-96-	t.Run("get existing user", func(t *testing.T) {
internal/repository/user_test.go-97-		user := &schema.User{
internal/repository/user_test.go-98-			Username:   "getuser1",
internal/repository/user_test.go-99-			Name:       "Get User",
internal/repository/user_test.go-100-			Email:      "getuser@example.com",
internal/repository/user_test.go-101-			Roles:      []string{"user", "admin"},
internal/repository/user_test.go-102-			Projects:   []string{"proj1"},
internal/repository/user_test.go-103-			AuthSource: schema.AuthViaLocalPassword,
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-104-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-105-
internal/repository/user_test.go-106-		err := r.AddUser(user)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-107-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-108-
internal/repository/user_test.go-109-		retrieved, err := r.GetUser("getuser1")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-110-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-111-		assert.Equal(t, user.Username, retrieved.Username)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-112-		assert.Equal(t, user.Name, retrieved.Name)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-113-		assert.Equal(t, user.Email, retrieved.Email)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-114-		assert.ElementsMatch(t, user.Roles, retrieved.Roles)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-115-		assert.ElementsMatch(t, user.Projects, retrieved.Projects)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-116-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-117-		err = r.DelUser("getuser1")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-118-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-119-	})
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-120-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-121-	t.Run("get non-existent user", func(t *testing.T) {
internal/repository/user_test.go-122-		_, err := r.GetUser("nonexistent")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-123-		assert.Error(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-124-	})
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-125-}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-126-
internal/repository/user_test.go:127:func TestUpdateUser(t *testing.T) {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-128-	_ = setup(t)
internal/repository/user_test.go-129-	r := GetUserRepository()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-130-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-131-	t.Run("update user name", func(t *testing.T) {
internal/repository/user_test.go-132-		user := &schema.User{
internal/repository/user_test.go-133-			Username:   "updateuser1",
internal/repository/user_test.go-134-			Name:       "Original Name",
internal/repository/user_test.go-135-			Roles:      []string{"user"},
internal/repository/user_test.go-136-			Projects:   []string{},
internal/repository/user_test.go-137-			AuthSource: schema.AuthViaLocalPassword,
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-138-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-139-
internal/repository/user_test.go-140-		err := r.AddUser(user)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-141-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-142-
internal/repository/user_test.go-143-		dbUser, err := r.GetUser("updateuser1")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-144-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-145-
internal/repository/user_test.go-146-		updatedUser := &schema.User{
internal/repository/user_test.go-147-			Username: "updateuser1",
internal/repository/user_test.go-148-			Name:     "Updated Name",
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-149-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-150-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-151-		err = r.UpdateUser(dbUser, updatedUser)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-152-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-153-
internal/repository/user_test.go-154-		retrieved, err := r.GetUser("updateuser1")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-155-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-156-		assert.Equal(t, "Updated Name", retrieved.Name)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-157-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-158-		err = r.DelUser("updateuser1")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-159-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-160-	})
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-161-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-162-	t.Run("update with no changes", func(t *testing.T) {
internal/repository/user_test.go-163-		user := &schema.User{
internal/repository/user_test.go-164-			Username:   "updateuser2",
internal/repository/user_test.go-165-			Name:       "Same Name",
internal/repository/user_test.go-166-			Roles:      []string{"user"},
internal/repository/user_test.go-167-			Projects:   []string{},
internal/repository/user_test.go-168-			AuthSource: schema.AuthViaLocalPassword,
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-169-		}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-170-
internal/repository/user_test.go-171-		err := r.AddUser(user)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-172-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-173-
internal/repository/user_test.go-174-		dbUser, err := r.GetUser("updateuser2")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-175-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-176-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-177-		err = r.UpdateUser(dbUser, dbUser)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-178-		assert.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-179-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-180-		err = r.DelUser("updateuser2")
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-181-		require.NoError(t, err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-182-	})
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-183-}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-184-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-185-func TestDelUser(t *testing.T) {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/user_test.go-186-	_ = setup(t)
internal/repository/user_test.go-187-	r := GetUserRepository()


163-		cclog.Errorf("Error while inserting new user '%v' into DB", user.Username)
164-		return err
165-	}
166-
167-	cclog.Infof("new user %#v created (roles: %s, auth-source: %d, projects: %s)", user.Username, rolesJSON, user.AuthSource, projectsJSON)
168-
169-	/ DEPRECATED: SUPERSEDED BY NEW USER CONFIG - userConfig.go / web.go
170-	defaultMetricsCfg, err := config.LoadDefaultMetricsConfig()
171-	if err != nil {
172-		cclog.Errorf("Error loading default metrics config: %v", err)
173-	} else if defaultMetricsCfg != nil {
174-		for _, cluster := range defaultMetricsCfg.Clusters {
175-			metricsArray := config.ParseMetricsString(cluster.DefaultMetrics)
176-			metricsJSON, err := json.Marshal(metricsArray)
177-			if err != nil {
178-				cclog.Errorf("Error marshaling default metrics for cluster %s: %v", cluster.Name, err)
179-				continue
180-			}
181-			/ Note: StatisticsTable now has different key (metricConfig_jobViewTableMetrics): Not updated here.
182-			confKey := "metricConfig_jobViewPlotMetrics:" + cluster.Name
183-			if _, err := sq.Insert("configuration").
184-				Columns("username", "confkey", "value").
185-				Values(user.Username, confKey, string(metricsJSON)).
186-				RunWith(r.DB).Exec(); err != nil {
187-				cclog.Errorf("Error inserting default job view metrics for user %s and cluster %s: %v", user.Username, cluster.Name, err)
188-			} else {
189-				cclog.Infof("Default job view metrics for user %s and cluster %s set to %s", user.Username, cluster.Name, string(metricsJSON))
190-			}
191-		}
192-	}
193-	// END DEPRECATION
194-
195-	return nil
196-}
197-
198-// AddUserIfNotExists inserts a user only if the username does not already exist.
199-// Uses INSERT OR IGNORE to avoid UNIQUE constraint errors when a user is
200-// concurrently created (e.g., by a login while LDAP sync is running).
201-// Unlike AddUser, this intentionally skips the deprecated default metrics config insertion.
202-func (r *UserRepository) AddUserIfNotExists(user *schema.User) error {
203-	rolesJSON, _ := json.Marshal(user.Roles)
204-	projectsJSON, _ := json.Marshal(user.Projects)
205-
206-	cols := "username, name, roles, projects, ldap"
207-	_, err := r.DB.Exec(
208-		`INSERT OR IGNORE INTO hpc_user (`+cols+`) VALUES (?, ?, ?, ?, ?)`,
209-		user.Username, user.Name, string(rolesJSON), string(projectsJSON), int(user.AuthSource))
210-	return err
211-}
212-
213:func (r *UserRepository) UpdateUser(dbUser *schema.User, user *schema.User) error {
214-	// user contains updated info -> Apply to dbUser
215-	// --- Simple Name Update ---
216-	if dbUser.Name != user.Name {
217-		if _, err := sq.Update("hpc_user").Set("name", user.Name).Where("hpc_user.username = ?", dbUser.Username).RunWith(r.DB).Exec(); err != nil {
218-			cclog.Errorf("error while updating name of user '%s'", user.Username)
219-			return err
220-		}
221-	}
222-
223-	// --- Def Helpers ---
224-	// Helper to update roles
225-	updateRoles := func(roles []string) error {
226-		rolesJSON, _ := json.Marshal(roles)
227-		_, err := sq.Update("hpc_user").Set("roles", rolesJSON).Where("hpc_user.username = ?", dbUser.Username).RunWith(r.DB).Exec()
228-		return err
229-	}
230-
231-	// Helper to update projects
232-	updateProjects := func(projects []string) error {
233-		projectsJSON, _ := json.Marshal(projects)
234-		_, err := sq.Update("hpc_user").Set("projects", projectsJSON).Where("hpc_user.username = ?", dbUser.Username).RunWith(r.DB).Exec()
235-		return err
236-	}
237-
238-	// Helper to clear projects
239-	clearProjects := func() error {
240-		_, err := sq.Update("hpc_user").Set("projects", "[]").Where("hpc_user.username = ?", dbUser.Username).RunWith(r.DB).Exec()
241-		return err
242-	}
243-
244-	// --- Manager Role Handling ---
245-	if dbUser.HasRole(schema.RoleManager) && user.HasRole(schema.RoleManager) && !reflect.DeepEqual(dbUser.Projects, user.Projects) {
246-		/ Existing Manager: update projects
247-		if err := updateProjects(user.Projects); err != nil {
248-			return err
249-		}
250-	} else if dbUser.HasRole(schema.RoleUser) && user.HasRole(schema.RoleManager) && user.HasNotRoles([]schema.Role{schema.RoleAdmin}) {
251-		/ New Manager: update roles and projects
252-		if err := updateRoles(user.Roles); err != nil {
253-			return err
254-		}
255-		if err := updateProjects(user.Projects); err != nil {
256-			return err
257-		}
258-	} else if dbUser.HasRole(schema.RoleManager) && user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleManager}) {
259-		/ Remove Manager: update roles and clear projects
260-		if err := updateRoles(user.Roles); err != nil {
261-			return err
262-		}
263-		if err := clearProjects(); err != nil {


   263→		if err := clearProjects(); err != nil {
   264→			return err
   265→		}
   266→	}
   267→
   268→	// --- Support Role Handling ---
   269→	if dbUser.HasRole(schema.RoleUser) && dbUser.HasNotRoles([]schema.Role{schema.RoleSupport}) &&
   270→		user.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin}) {
   271→		// New Support: update roles
   272→		if err := updateRoles(user.Roles); err != nil {
   273→			return err
   274→		}
   275→	} else if dbUser.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
   276→		// Remove Support: update roles
   277→		if err := updateRoles(user.Roles); err != nil {
   278→			return err
   279→		}
   280→	}
   281→
   282→	return nil
   283→}
   284→
   285→func (r *UserRepository) DelUser(username string) error {
   286→	_, err := r.DB.Exec(`DELETE FROM hpc_user WHERE hpc_user.username = ?`, username)
   287→	if err != nil {
   288→		cclog.Errorf("Error while deleting user '%s' from DB", username)
   289→		return err
   290→	}
   291→	cclog.Infof("deleted user '%s' from DB", username)
   292→	return nil
   293→}
   294→
   295→func (r *UserRepository) ListUsers(specialsOnly bool) ([]*schema.User, error) {
   296→	q := sq.Select("username", "name", "email", "roles", "projects").From("hpc_user")
   297→	if specialsOnly {
   298→		q = q.Where("(roles != '[\"user\"]' AND roles != '[]')")
   299→	}
   300→
   301→	rows, err := q.RunWith(r.DB).Query()
   302→	if err != nil {
   303→		cclog.Warn("Error while querying user list")
   304→		return nil, err
   305→	}
   306→
   307→	users := make([]*schema.User, 0)
   308→	defer rows.Close()
   309→	for rows.Next() {
   310→		rawroles := ""
   311→		rawprojects := ""
   312→		user := &schema.User{}
   313→		var name, email sql.NullString
   314→		if err := rows.Scan(&user.Username, &name, &email, &rawroles, &rawprojects); err != nil {
   315→			cclog.Warn("Error while scanning user list")
   316→			return nil, err
   317→		}
   318→
   319→		if err := json.Unmarshal([]byte(rawroles), &user.Roles); err != nil {
   320→			cclog.Warn("Error while unmarshaling raw role list")
   321→			return nil, err
   322→		}

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

		if err := clearProjects(); err != nil {
			return err
		}
	}

	// --- Support Role Handling ---
	if dbUser.HasRole(schema.RoleUser) && dbUser.HasNotRoles([]schema.Role{schema.RoleSupport}) &&
		user.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin}) {
		// New Support: update roles
		if err := updateRoles(user.Roles); err != nil {
			return err
		}
	} else if dbUser.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
		// Remove Support: update roles
		if err := updateRoles(user.Roles); err != nil {
			return err
		}
	}

	return nil
}

func (r *UserRepository) DelUser(username string) error {
	_, err := r.DB.Exec(`DELETE FROM hpc_user WHERE hpc_user.username = ?`, username)
	if err != nil {
		cclog.Errorf("Error while deleting user '%s' from DB", username)
		return err
	}
	cclog.Infof("deleted user '%s' from DB", username)
	return nil
}

func (r *UserRepository) ListUsers(specialsOnly bool) ([]*schema.User, error) {
	q := sq.Select("username", "name", "email", "roles", "projects").From("hpc_user")
	if specialsOnly {
		q = q.Where("(roles != '[\"user\"]' AND roles != '[]')")
	}

	rows, err := q.RunWith(r.DB).Query()
	if err != nil {
		cclog.Warn("Error while querying user list")
		return nil, err
	}

	users := make([]*schema.User, 0)
	defer rows.Close()
	for rows.Next() {
		rawroles := ""
		rawprojects := ""
		user := &schema.User{}
		var name, email sql.NullString
		if err := rows.Scan(&user.Username, &name, &email, &rawroles, &rawprojects); err != nil {
			cclog.Warn("Error while scanning user list")
			return nil, err
		}

		if err := json.Unmarshal([]byte(rawroles), &user.Roles); err != nil {
			cclog.Warn("Error while unmarshaling raw role list")
			return nil, err
		}


There's a significant problem in `UpdateUser`. The role update logic only handles specific transitions for `manager` and `support` roles — **the `admin` role is never updated**, and several combinations are missed entirely. Let me show the issues:


   244→	// --- Manager Role Handling ---
   245→	if dbUser.HasRole(schema.RoleManager) && user.HasRole(schema.RoleManager) && !reflect.DeepEqual(dbUser.Projects, user.Projects) {
   246→		// Existing Manager: update projects
   247→		if err := updateProjects(user.Projects); err != nil {
   248→			return err
   249→		}
   250→	} else if dbUser.HasRole(schema.RoleUser) && user.HasRole(schema.RoleManager) && user.HasNotRoles([]schema.Role{schema.RoleAdmin}) {
   251→		// New Manager: update roles and projects
   252→		if err := updateRoles(user.Roles); err != nil {
   253→			return err
   254→		}
   255→		if err := updateProjects(user.Projects); err != nil {
   256→			return err
   257→		}
   258→	} else if dbUser.HasRole(schema.RoleManager) && user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleManager}) {
   259→		// Remove Manager: update roles and clear projects
   260→		if err := updateRoles(user.Roles); err != nil {
   261→			return err
   262→		}
   263→		if err := clearProjects(); err != nil {
   264→			return err
   265→		}
   266→	}
   267→
   268→	// --- Support Role Handling ---
   269→	if dbUser.HasRole(schema.RoleUser) && dbUser.HasNotRoles([]schema.Role{schema.RoleSupport}) &&
   270→		user.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin}) {
   271→		// New Support: update roles
   272→		if err := updateRoles(user.Roles); err != nil {
   273→			return err
   274→		}
   275→	} else if dbUser.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
   276→		// Remove Support: update roles
   277→		if err := updateRoles(user.Roles); err != nil {
   278→			return err
   279→		}
   280→	}
   281→
   282→	return nil
   283→}

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

	// --- Manager Role Handling ---
	if dbUser.HasRole(schema.RoleManager) && user.HasRole(schema.RoleManager) && !reflect.DeepEqual(dbUser.Projects, user.Projects) {
		// Existing Manager: update projects
		if err := updateProjects(user.Projects); err != nil {
			return err
		}
	} else if dbUser.HasRole(schema.RoleUser) && user.HasRole(schema.RoleManager) && user.HasNotRoles([]schema.Role{schema.RoleAdmin}) {
		// New Manager: update roles and projects
		if err := updateRoles(user.Roles); err != nil {
			return err
		}
		if err := updateProjects(user.Projects); err != nil {
			return err
		}
	} else if dbUser.HasRole(schema.RoleManager) && user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleManager}) {
		// Remove Manager: update roles and clear projects
		if err := updateRoles(user.Roles); err != nil {
			return err
		}
		if err := clearProjects(); err != nil {
			return err
		}
	}

	// --- Support Role Handling ---
	if dbUser.HasRole(schema.RoleUser) && dbUser.HasNotRoles([]schema.Role{schema.RoleSupport}) &&
		user.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin}) {
		// New Support: update roles
		if err := updateRoles(user.Roles); err != nil {
			return err
		}
	} else if dbUser.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
		// Remove Support: update roles
		if err := updateRoles(user.Roles); err != nil {
			return err
		}
	}

	return nil
}


The problems are clear:

**Cases that are silently ignored by `UpdateUser`:**

| DB role | OIDC role | Result |
|---|---|---|
| `user` | `admin` | ❌ no update (no branch covers `user → admin`) |
| `user` | `admin` + `manager` | ❌ no update |
| `admin` | `user` | ❌ no update (admin is only used as an exclusion guard) |
| `manager` | `admin` | ❌ roles not updated (only projects cleared in the "Remove Manager" branch) |
| `support` | `admin` | ❌ no update |

The logic was built for incremental transitions but is incomplete. The intent of `UpdateUserOnLogin` from an IdP like Keycloak should be: **the IdP is the authority on roles — just write whatever the token says**.

The fix is to add a simple full-role-diff check at the end that handles everything not caught by the specific manager/project branches:


Edit

	// --- Support Role Handling ---
	if dbUser.HasRole(schema.RoleUser) && dbUser.HasNotRoles([]schema.Role{schema.RoleSupport}) &&
		user.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin}) {
		// New Support: update roles
		if err := updateRoles(user.Roles); err != nil {
			return err
		}
	} else if dbUser.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
		// Remove Support: update roles
		if err := updateRoles(user.Roles); err != nil {
			return err
		}
	}

	return nil
}

	// --- Support Role Handling ---
	if dbUser.HasRole(schema.RoleUser) && dbUser.HasNotRoles([]schema.Role{schema.RoleSupport}) &&
		user.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin}) {
		// New Support: update roles
		if err := updateRoles(user.Roles); err != nil {
			return err
		}
	} else if dbUser.HasRole(schema.RoleSupport) && user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
		// Remove Support: update roles
		if err := updateRoles(user.Roles); err != nil {
			return err
		}
	}

	// --- 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
}