/Users/jan/prg/ClusterCockpit/cc-backend/pkg/metricstore/metricstore.go-254-// This must be called during initialization to provide job state information
/Users/jan/prg/ClusterCockpit/cc-backend/pkg/metricstore/metricstore.go-255-// for selective buffer retention during Free operations.
/Users/jan/prg/ClusterCockpit/cc-backend/pkg/metricstore/metricstore.go-256-// If not set, the Free function will fall back to freeing all buffers.
pkg/metricstore/metricstore.go:257:func (ms *MemoryStore) SetNodeProvider(provider NodeProvider) {
pkg/metricstore/metricstore.go:258:	ms.nodeProvider = provider
/Users/jan/prg/ClusterCockpit/cc-backend/pkg/metricstore/metricstore.go-259-}
/Users/jan/prg/ClusterCockpit/cc-backend/pkg/metricstore/metricstore.go-260-
/Users/jan/prg/ClusterCockpit/cc-backend/pkg/metricstore/metricstore.go-261-// Shutdown performs a graceful shutdown of the metric store.
--
cmd/cc-backend/server.go-140-	info := map[string]any{}
/Users/jan/prg/ClusterCockpit/cc-backend/cmd/cc-backend/server.go-141-	info["hasOpenIDConnect"] = false
/Users/jan/prg/ClusterCockpit/cc-backend/cmd/cc-backend/server.go-142-
cmd/cc-backend/server.go:143:	if auth.Keys.OpenIDConfig != nil {
cmd/cc-backend/server.go-144-		openIDConnect := auth.NewOIDC(authHandle)
/Users/jan/prg/ClusterCockpit/cc-backend/cmd/cc-backend/server.go-145-		openIDConnect.RegisterEndpoints(s.router)
/Users/jan/prg/ClusterCockpit/cc-backend/cmd/cc-backend/server.go-146-		info["hasOpenIDConnect"] = true
--
internal/auth/jwt.go-35-	TrustedIssuer string `json:"trusted-issuer"`
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/jwt.go-36-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/jwt.go-37-	// Should an non-existent user be added to the DB based on the information in the token
internal/auth/jwt.go:38:	SyncUserOnLogin bool `json:"sync-user-on-login"`
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/jwt.go-39-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/jwt.go-40-	// Should an existent user be updated in the DB based on the information in the token
internal/auth/jwt.go-41-	UpdateUserOnLogin bool `json:"update-user-on-login"`
--
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-23-	"golang.org/x/oauth2"
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-24-)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-25-
internal/auth/oidc.go:26:type OpenIDConfig struct {
internal/auth/oidc.go:27:	Provider          string `json:"provider"`
internal/auth/oidc.go:28:	SyncUserOnLogin   bool   `json:"sync-user-on-login"`
internal/auth/oidc.go-29-	UpdateUserOnLogin bool   `json:"update-user-on-login"`
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-30-}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-31-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-32-type OIDC struct {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-33-	client         *oauth2.Config
internal/auth/oidc.go:34:	provider       *oidc.Provider
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-35-	authentication *Authentication
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-36-	clientID       string
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-37-}
--
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-56-	http.SetCookie(w, c)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-57-}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-58-
internal/auth/oidc.go:59:// NewOIDC creates a new OIDC authenticator with the configured provider
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-60-func NewOIDC(a *Authentication) *OIDC {
internal/auth/oidc.go:61:	// Use context with timeout for provider initialization
internal/auth/oidc.go-62-	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-63-	defer cancel()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-64-
internal/auth/oidc.go:65:	provider, err := oidc.NewProvider(ctx, Keys.OpenIDConfig.Provider)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-66-	if err != nil {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-67-		cclog.Fatal(err)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-68-	}
--
internal/auth/oidc.go-78-	client := &oauth2.Config{
internal/auth/oidc.go-79-		ClientID:     clientID,
internal/auth/oidc.go-80-		ClientSecret: clientSecret,
internal/auth/oidc.go:81:		Endpoint:     provider.Endpoint(),
internal/auth/oidc.go-82-		Scopes:       []string{oidc.ScopeOpenID, "profile"},
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-83-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-84-
internal/auth/oidc.go:85:	oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-86-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-87-	return oa
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-88-}
--
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-128-		return
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-129-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-130-
internal/auth/oidc.go:131:	// Get user info from OIDC provider with same timeout
internal/auth/oidc.go:132:	userInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-133-	if err != nil {
internal/auth/oidc.go-134-		cclog.Errorf("failed to get userinfo: %s", err.Error())
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-135-		http.Error(rw, "Failed to retrieve user information", http.StatusInternalServerError)
--
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-149-		return
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-150-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-151-
internal/auth/oidc.go:152:	verifier := oa.provider.Verifier(&oidc.Config{ClientID: oa.clientID})
internal/auth/oidc.go-153-	idToken, err := verifier.Verify(ctx, rawIDToken)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-154-	if err != nil {
internal/auth/oidc.go-155-		cclog.Errorf("ID token verification failed: %s", err.Error())
--
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-211-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-212-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-213-	if username == "" {
internal/auth/oidc.go:214:		http.Error(rw, "Username claim missing from OIDC provider", http.StatusBadRequest)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-215-		return
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-216-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-217-
--
internal/auth/oidc.go-255-		AuthSource: schema.AuthViaOIDC,
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-256-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-257-
internal/auth/oidc.go:258:	if Keys.OpenIDConfig.SyncUserOnLogin || Keys.OpenIDConfig.UpdateUserOnLogin {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-259-		handleOIDCUser(user)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-260-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go-261-
--
internal/auth/schema.go-27-          "description": "Issuer that should be accepted when validating external JWTs ",
internal/auth/schema.go-28-          "type": "string"
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/schema.go-29-        },
internal/auth/schema.go:30:        "sync-user-on-login": {
internal/auth/schema.go-31-          "description": "Add non-existent user to DB at login attempt with values provided in JWT.",
internal/auth/schema.go-32-          "type": "boolean"
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/schema.go-33-        },
--
internal/auth/schema.go-41-    "oidc": {
internal/auth/schema.go-42-      "type": "object",
internal/auth/schema.go-43-      "properties": {
internal/auth/schema.go:44:        "provider": {
internal/auth/schema.go:45:          "description": "OpenID Connect provider URL.",
internal/auth/schema.go-46-          "type": "string"
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/schema.go-47-        },
internal/auth/schema.go:48:        "sync-user-on-login": {
internal/auth/schema.go-49-          "description": "Add non-existent user to DB at login attempt with values provided.",
internal/auth/schema.go-50-          "type": "boolean"
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/schema.go-51-        },
--
internal/auth/schema.go-54-          "type": "boolean"
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/schema.go-55-        }
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/schema.go-56-      },
internal/auth/schema.go:57:      "required": ["provider"]
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/schema.go-58-    },
internal/auth/schema.go-59-    "ldap": {
internal/auth/schema.go-60-      "description": "For LDAP Authentication and user synchronisation.",
--
internal/auth/schema.go-96-          "description": "LDAP attribute used as login username. Default: uid",
internal/auth/schema.go-97-          "type": "string"
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/schema.go-98-        },
internal/auth/schema.go:99:        "sync-user-on-login": {
internal/auth/schema.go-100-          "description": "Add non-existent user to DB at login attempt if user exists in Ldap directory",
internal/auth/schema.go-101-          "type": "boolean"
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/schema.go-102-        },
--
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go-109-type AuthConfig struct {
internal/auth/auth.go-110-	LdapConfig   *LdapConfig    `json:"ldap"`
internal/auth/auth.go-111-	JwtConfig    *JWTAuthConfig `json:"jwts"`
internal/auth/auth.go:112:	OpenIDConfig *OpenIDConfig  `json:"oidc"`
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go-113-}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go-114-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go-115-// Keys holds the global authentication configuration
--
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go-296-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go-297-// handleOIDCUser syncs OIDC user with database
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go-298-func handleOIDCUser(OIDCUser *schema.User) {
internal/auth/auth.go:299:	handleUserSync(OIDCUser, Keys.OpenIDConfig.SyncUserOnLogin, Keys.OpenIDConfig.UpdateUserOnLogin)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go-300-}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go-301-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/auth.go-302-// handleLdapUser syncs LDAP user with database
--
internal/auth/ldap.go-31-	SyncDelOldUsers bool   `json:"sync-del-old-users"`
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/ldap.go-32-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/ldap.go-33-	// Should a non-existent user be added to the DB if user exists in ldap directory
internal/auth/ldap.go:34:	SyncUserOnLogin   bool `json:"sync-user-on-login"`
internal/auth/ldap.go-35-	UpdateUserOnLogin bool `json:"update-user-on-login"`
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/ldap.go-36-}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/ldap.go-37-


Perfect! Now I have all the information. Let me compile the findings:

## Summary

Based on my investigation of `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go`, here are the key findings:

### OAuth2 Scopes Requested

**Exact scopes list (line 82):**
```go
Scopes: []string{oidc.ScopeOpenID, "profile"},
```

This translates to:
- `"openid"` (from `oidc.ScopeOpenID`)
- `"profile"`

**Important:** No "roles" or Keycloak-specific scope is explicitly requested. Roles are instead extracted from the ID token claims.

### Claims Extraction Strategy

The code uses a **two-pronged approach**:

1. **From UserInfo endpoint (lines 168-176):**
   - Extracts `preferred_username` and `name` claims
   - Profiles scopes include user basic profile information (username, name)

2. **From ID Token claims (lines 178-201):**
   - Extracts Keycloak-specific role claims:
     - `realm_access.roles` — realm-level roles
     - `resource_access[clientId].roles` — client-specific roles
   - Comment at line 179-180 explicitly explains: *"Keycloak includes realm_access and resource_access in the ID token (JWT), but NOT in the UserInfo endpoint response by default."*

### Role Mapping

Extracted OIDC roles are mapped to ClusterCockpit roles (lines 228-238):
- `"user"` → `RoleUser`
- `"admin"` → `RoleAdmin`
- `"manager"` → `RoleManager`
- `"support"` → `RoleSupport`

If no roles match, the user defaults to `RoleUser` (lines 246-248).

### Configuration

No Keycloak-specific scope configuration exists in the codebase. The `OpenIDConfig` struct (lines 26-30) only supports:
- `provider` — OIDC provider URL (required)
- `sync-user-on-login` — auto-create users
- `update-user-on-login` — auto-update user info on login

These configuration options are documented in `/Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/schema.go` (lines 41-57).

### Key Insight

The implementation **relies on standard OIDC UserInfo and ID Token endpoints** without requesting a specific "roles" scope. Keycloak automatically includes realm-level and resource-level roles in the ID token, so no additional scope is needed. This is a standard Keycloak behavior that doesn't require explicit scope configuration.

agentId: a476b41a7c7b4de14 (use SendMessage with to: 'a476b41a7c7b4de14' to continue this agent)
<usage>total_tokens: 40479
tool_uses: 14
duration_ms: 17761</usage>

Prompt:
In /Users/jan/prg/ClusterCockpit/cc-backend/internal/auth/oidc.go, look at:
1. The OAuth2 scopes requested (the `Scopes` field in the oauth2.Config)
2. How claims are extracted from the ID token vs UserInfo
3. Whether "roles" or any Keycloak-specific scope is requested

Also check if there is any configuration or documentation about Keycloak scopes anywhere in the repo (configs/, docs/, CLAUDE.md, etc.).

Report the exact scopes list and any relevant surrounding context.


Now I have a clear picture. Let me write the plan.


Write

# Plan: Fix Keycloak Realm Roles Not Appearing in ID Token

## Context

After fixing cc-backend to extract roles from the **ID token** (rather than UserInfo), realm roles are still empty. The root cause is a **Keycloak configuration issue**: Keycloak's built-in "realm roles" mapper, which lives in the default `roles` client scope, does **not** add roles to the ID token by default — it only adds them to the access token. The backend never inspects the access token, only the ID token.

There is also a minor code gap: the `roles` scope is not explicitly requested, so if `roles` is not a default scope on the Keycloak client, it will not be applied at all.

---

## Part 1 — Keycloak Configuration (required)

### Option A: Fix the existing "realm roles" mapper in the `roles` client scope (recommended)

1. Keycloak Admin Console → **Client Scopes** → `roles`
2. Tab **Mappers** → click **realm roles**
3. Set **Add to ID token** = **ON**
4. Save

> This affects all clients that use the `roles` default scope.

### Option B: Add a dedicated mapper directly on the cc-backend client (scoped fix)

1. **Clients** → your client → **Client scopes** → tab **Dedicated scopes**
2. **Add mapper** → **By configuration** → **Realm Roles**
3. Configure:
   - **Name**: `realm-roles-id-token`
   - **Token Claim Name**: `realm_access.roles` (nested, use dot notation)
   - **Add to ID token**: ON
   - **Add to access token**: ON
   - **Add to userinfo**: optional (does not affect current code)
4. Save

> This only affects this specific client.

---

## Part 2 — Code change: request the `roles` scope explicitly

**File:** `internal/auth/oidc.go`, line 82

Change:
```go
Scopes: []string{oidc.ScopeOpenID, "profile"},
```
To:
```go
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
```

**Why:** If `roles` is not configured as a *default* client scope in Keycloak, it won't be applied unless explicitly requested. Adding it here ensures cc-backend always requests role claims regardless of whether the Keycloak client has `roles` as a default or optional scope.

---

## Verification

1. Enable debug logging: start cc-backend with `-loglevel debug`
2. Log in via OIDC → check logs for:
   ```
   OIDC ID token realm_access roles: [user admin ...]
   ```
3. If still empty: use [jwt.io](https://jwt.io) to decode the raw ID token from Keycloak (available in browser dev tools during the callback) and verify `realm_access.roles` is present in the payload.
4. If `realm_access` is absent from the decoded ID token → the Keycloak mapper is not configured correctly (Part 1 above).
5. If `realm_access.roles` is present but cc-backend ignores it → check the role name mapping (`user`, `admin`, `manager`, `support` must match exactly).

---

## Critical Files

- `internal/auth/oidc.go` — scope list (line 82), ID token claims extraction (lines 181–201)


File created successfully at: /Users/jan/.claude/plans/iterative-conjuring-sundae.md

# Plan: Fix Keycloak Realm Roles Not Appearing in ID Token

## Context

After fixing cc-backend to extract roles from the **ID token** (rather than UserInfo), realm roles are still empty. The root cause is a **Keycloak configuration issue**: Keycloak's built-in "realm roles" mapper, which lives in the default `roles` client scope, does **not** add roles to the ID token by default — it only adds them to the access token. The backend never inspects the access token, only the ID token.

There is also a minor code gap: the `roles` scope is not explicitly requested, so if `roles` is not a default scope on the Keycloak client, it will not be applied at all.

---

## Part 1 — Keycloak Configuration (required)

### Option A: Fix the existing "realm roles" mapper in the `roles` client scope (recommended)

1. Keycloak Admin Console → **Client Scopes** → `roles`
2. Tab **Mappers** → click **realm roles**
3. Set **Add to ID token** = **ON**
4. Save

> This affects all clients that use the `roles` default scope.

### Option B: Add a dedicated mapper directly on the cc-backend client (scoped fix)

1. **Clients** → your client → **Client scopes** → tab **Dedicated scopes**
2. **Add mapper** → **By configuration** → **Realm Roles**
3. Configure:
   - **Name**: `realm-roles-id-token`
   - **Token Claim Name**: `realm_access.roles` (nested, use dot notation)
   - **Add to ID token**: ON
   - **Add to access token**: ON
   - **Add to userinfo**: optional (does not affect current code)
4. Save

> This only affects this specific client.

---

## Part 2 — Code change: request the `roles` scope explicitly

**File:** `internal/auth/oidc.go`, line 82

Change:
```go
Scopes: []string{oidc.ScopeOpenID, "profile"},
```
To:
```go
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
```

**Why:** If `roles` is not configured as a *default* client scope in Keycloak, it won't be applied unless explicitly requested. Adding it here ensures cc-backend always requests role claims regardless of whether the Keycloak client has `roles` as a default or optional scope.

---

## Verification

1. Enable debug logging: start cc-backend with `-loglevel debug`
2. Log in via OIDC → check logs for:
   ```
   OIDC ID token realm_access roles: [user admin ...]
   ```
3. If still empty: use [jwt.io](https://jwt.io) to decode the raw ID token from Keycloak (available in browser dev tools during the callback) and verify `realm_access.roles` is present in the payload.
4. If `realm_access` is absent from the decoded ID token → the Keycloak mapper is not configured correctly (Part 1 above).
5. If `realm_access.roles` is present but cc-backend ignores it → check the role name mapping (`user`, `admin`, `manager`, `support` must match exactly).

---

## Critical Files

- `internal/auth/oidc.go` — scope list (line 82), ID token claims extraction (lines 181–201)


ExitPlanMode

Summary:
## Root Cause

Keycloak's built-in "realm roles" mapper (in the default `roles` client scope) **does not add roles to the ID token by default** — it only adds them to the access token. cc-backend reads from the ID token, so realm roles are always empty.

There is also a minor code gap: the `roles` scope is not explicitly requested.

## Two-part fix

### 1. Keycloak (required) — two options:

**Option A (global):** Admin → Client Scopes → `roles` → Mappers → `realm roles` → set **"Add to ID token" = ON**

**Option B (per-client):** Clients → your client → Client scopes (Dedicated) → Add mapper → Realm Roles → Token Claim Name: `realm_access.roles` → **Add to ID token = ON**

### 2. Code change — `internal/auth/oidc.go` line 82

Add `"roles"` to the requested scopes so it works even if `roles` is not a Keycloak *default* scope:

```go
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
```

## Verification

Run with `-loglevel debug` and check for:
```
OIDC ID token realm_access roles: [user admin ...]
```
Or decode the raw ID token at jwt.io to confirm `realm_access.roles` is present.

# Plan: Fix Keycloak Realm Roles Not Appearing in ID Token

## Context

After fixing cc-backend to extract roles from the **ID token** (rather than UserInfo), realm roles are still empty. The root cause is a **Keycloak configuration issue**: Keycloak's built-in "realm roles" mapper, which lives in the default `roles` client scope, does **not** add roles to the ID token by default — it only adds them to the access token. The backend never inspects the access token, only the ID token.

There is also a minor code gap: the `roles` scope is not explicitly requested, so if `roles` is not a default scope on the Keycloak client, it will not be applied at all.

---

## Part 1 — Keycloak Configuration (required)

### Option A: Fix the existing "realm roles" mapper in the `roles` client scope (recommended)

1. Keycloak Admin Console → **Client Scopes** → `roles`
2. Tab **Mappers** → click **realm roles**
3. Set **Add to ID token** = **ON**
4. Save

> This affects all clients that use the `roles` default scope.

### Option B: Add a dedicated mapper directly on the cc-backend client (scoped fix)

1. **Clients** → your client → **Client scopes** → tab **Dedicated scopes**
2. **Add mapper** → **By configuration** → **Realm Roles**
3. Configure:
   - **Name**: `realm-roles-id-token`
   - **Token Claim Name**: `realm_access.roles` (nested, use dot notation)
   - **Add to ID token**: ON
   - **Add to access token**: ON
   - **Add to userinfo**: optional (does not affect current code)
4. Save

> This only affects this specific client.

---

## Part 2 — Code change: request the `roles` scope explicitly

**File:** `internal/auth/oidc.go`, line 82

Change:
```go
Scopes: []string{oidc.ScopeOpenID, "profile"},
```
To:
```go
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
```

**Why:** If `roles` is not configured as a *default* client scope in Keycloak, it won't be applied unless explicitly requested. Adding it here ensures cc-backend always requests role claims regardless of whether the Keycloak client has `roles` as a default or optional scope.

---

## Verification

1. Enable debug logging: start cc-backend with `-loglevel debug`
2. Log in via OIDC → check logs for:
   ```
   OIDC ID token realm_access roles: [user admin ...]
   ```
3. If still empty: use [jwt.io](https://jwt.io) to decode the raw ID token from Keycloak (available in browser dev tools during the callback) and verify `realm_access.roles` is present in the payload.
4. If `realm_access` is absent from the decoded ID token → the Keycloak mapper is not configured correctly (Part 1 above).
5. If `realm_access.roles` is present but cc-backend ignores it → check the role name mapping (`user`, `admin`, `manager`, `support` must match exactly).

---

## Critical Files

- `internal/auth/oidc.go` — scope list (line 82), ID token claims extraction (lines 181–201)


Error: The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:
I use KeyCloak 26.5.6 and there is no client scope roles


ToolSearch

Question: AskUserQuestion


Question: AskUserQuestion


Good to know. In Keycloak 26.x the mapper is added directly on the client rather than through a shared client scope. Let me update the plan with the correct steps.


Write

# Plan: Fix Keycloak Realm Roles Not Appearing in ID Token

## Context

After fixing cc-backend to extract roles from the **ID token** (rather than UserInfo), realm roles are still empty. The root cause is a **Keycloak configuration issue**: by default, the realm roles mapper does **not** add roles to the ID token — it only adds them to the access token. The backend reads only the ID token, so roles are always empty.

There is also a minor code gap: the `roles` scope is not explicitly requested by cc-backend.

Environment: Keycloak 26.5.6

---

## Part 1 — Keycloak Configuration (required)

Add a "User Realm Role" mapper directly on the client that writes realm roles into the ID token.

**Steps in Keycloak Admin Console:**

1. **Clients** → select your cc-backend client
2. Tab **Client scopes** → click the link for the **dedicated** scope (shown as `<clientId>-dedicated`, type = Dedicated)
3. Tab **Mappers** → **Add mapper** → **By configuration**
4. Choose mapper type: **User Realm Role**
5. Configure:
   | Field | Value |
   |---|---|
   | Name | `realm-roles-id-token` |
   | Token Claim Name | `realm_access.roles` |
   | Claim JSON Type | `String` |
   | Add to ID token | **ON** |
   | Add to access token | ON |
   | Add to userinfo | optional |
6. **Save**

> The `realm_access.roles` token claim name (dot-notation) tells Keycloak to nest the array inside a `realm_access` object, matching what cc-backend parses.

---

## Part 2 — Code change: request the `roles` scope explicitly

**File:** `internal/auth/oidc.go`, line 82

```go
// Before
Scopes: []string{oidc.ScopeOpenID, "profile"},

// After
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
```

**Why:** If the `roles` scope is configured as optional (not default) on the Keycloak client, it won't be applied unless explicitly requested. This ensures it is always requested.

---

## Verification

1. Start cc-backend with `-loglevel debug`
2. Log in via OIDC → look for log lines:
   ```
   OIDC ID token realm_access roles: [user admin ...]
   ```
3. If still empty: open browser dev-tools during the OIDC callback, copy the `id_token` value, and decode it at **jwt.io** — verify `realm_access.roles` is present in the payload.
4. If `realm_access` is absent from the decoded ID token → the Keycloak mapper was not saved correctly (repeat Part 1).
5. If roles are present in the token but ignored → verify the role names in Keycloak match exactly: `user`, `admin`, `manager`, `support` (case-sensitive).

---

## Critical Files

- `internal/auth/oidc.go:82` — scope list to modify


The file /Users/jan/.claude/plans/iterative-conjuring-sundae.md has been updated successfully.

# Plan: Fix Keycloak Realm Roles Not Appearing in ID Token

## Context

After fixing cc-backend to extract roles from the **ID token** (rather than UserInfo), realm roles are still empty. The root cause is a **Keycloak configuration issue**: by default, the realm roles mapper does **not** add roles to the ID token — it only adds them to the access token. The backend reads only the ID token, so roles are always empty.

There is also a minor code gap: the `roles` scope is not explicitly requested by cc-backend.

Environment: Keycloak 26.5.6

---

## Part 1 — Keycloak Configuration (required)

Add a "User Realm Role" mapper directly on the client that writes realm roles into the ID token.

**Steps in Keycloak Admin Console:**

1. **Clients** → select your cc-backend client
2. Tab **Client scopes** → click the link for the **dedicated** scope (shown as `<clientId>-dedicated`, type = Dedicated)
3. Tab **Mappers** → **Add mapper** → **By configuration**
4. Choose mapper type: **User Realm Role**
5. Configure:
   | Field | Value |
   |---|---|
   | Name | `realm-roles-id-token` |
   | Token Claim Name | `realm_access.roles` |
   | Claim JSON Type | `String` |
   | Add to ID token | **ON** |
   | Add to access token | ON |
   | Add to userinfo | optional |
6. **Save**

> The `realm_access.roles` token claim name (dot-notation) tells Keycloak to nest the array inside a `realm_access` object, matching what cc-backend parses.

---

## Part 2 — Code change: request the `roles` scope explicitly

**File:** `internal/auth/oidc.go`, line 82

```go
// Before
Scopes: []string{oidc.ScopeOpenID, "profile"},

// After
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
```

**Why:** If the `roles` scope is configured as optional (not default) on the Keycloak client, it won't be applied unless explicitly requested. This ensures it is always requested.

---

## Verification

1. Start cc-backend with `-loglevel debug`
2. Log in via OIDC → look for log lines:
   ```
   OIDC ID token realm_access roles: [user admin ...]
   ```
3. If still empty: open browser dev-tools during the OIDC callback, copy the `id_token` value, and decode it at **jwt.io** — verify `realm_access.roles` is present in the payload.
4. If `realm_access` is absent from the decoded ID token → the Keycloak mapper was not saved correctly (repeat Part 1).
5. If roles are present in the token but ignored → verify the role names in Keycloak match exactly: `user`, `admin`, `manager`, `support` (case-sensitive).

---

## Critical Files

- `internal/auth/oidc.go:82` — scope list to modify

-After fixing cc-backend to extract roles from the **ID token** (rather than UserInfo), realm roles are still empty. The root cause is a **Keycloak configuration issue**: Keycloak's built-in "realm roles" mapper, which lives in the default `roles` client scope, does **not** add roles to the ID token by default — it only adds them to the access token. The backend never inspects the access token, only the ID token.

+After fixing cc-backend to extract roles from the **ID token** (rather than UserInfo), realm roles are still empty. The root cause is a **Keycloak configuration issue**: by default, the realm roles mapper does **not** add roles to the ID token — it only adds them to the access token. The backend reads only the ID token, so roles are always empty.

-There is also a minor code gap: the `roles` scope is not explicitly requested, so if `roles` is not a default scope on the Keycloak client, it will not be applied at all.

+There is also a minor code gap: the `roles` scope is not explicitly requested by cc-backend.

-### Option A: Fix the existing "realm roles" mapper in the `roles` client scope (recommended)

+Add a "User Realm Role" mapper directly on the client that writes realm roles into the ID token.

-1. Keycloak Admin Console → **Client Scopes** → `roles`

-2. Tab **Mappers** → click **realm roles**

-> This affects all clients that use the `roles` default scope.

+1. **Clients** → select your cc-backend client

+2. Tab **Client scopes** → click the link for the **dedicated** scope (shown as `<clientId>-dedicated`, type = Dedicated)

+3. Tab **Mappers** → **Add mapper** → **By configuration**

+4. Choose mapper type: **User Realm Role**

+   | Token Claim Name | `realm_access.roles` |

-### Option B: Add a dedicated mapper directly on the cc-backend client (scoped fix)

+> The `realm_access.roles` token claim name (dot-notation) tells Keycloak to nest the array inside a `realm_access` object, matching what cc-backend parses.

-1. **Clients** → your client → **Client scopes** → tab **Dedicated scopes**

-2. **Add mapper** → **By configuration** → **Realm Roles**

-   - **Token Claim Name**: `realm_access.roles` (nested, use dot notation)

-   - **Add to userinfo**: optional (does not affect current code)

-> This only affects this specific client.

-**Why:** If `roles` is not configured as a *default* client scope in Keycloak, it won't be applied unless explicitly requested. Adding it here ensures cc-backend always requests role claims regardless of whether the Keycloak client has `roles` as a default or optional scope.

+**Why:** If the `roles` scope is configured as optional (not default) on the Keycloak client, it won't be applied unless explicitly requested. This ensures it is always requested.

-1. Enable debug logging: start cc-backend with `-loglevel debug`

+1. Start cc-backend with `-loglevel debug`

+2. Log in via OIDC → look for log lines:

-3. If still empty: use [jwt.io](https://jwt.io) to decode the raw ID token from Keycloak (available in browser dev tools during the callback) and verify `realm_access.roles` is present in the payload.

-4. If `realm_access` is absent from the decoded ID token → the Keycloak mapper is not configured correctly (Part 1 above).

-5. If `realm_access.roles` is present but cc-backend ignores it → check the role name mapping (`user`, `admin`, `manager`, `support` must match exactly).

+3. If still empty: open browser dev-tools during the OIDC callback, copy the `id_token` value, and decode it at **jwt.io** — verify `realm_access.roles` is present in the payload.

+4. If `realm_access` is absent from the decoded ID token → the Keycloak mapper was not saved correctly (repeat Part 1).

+5. If roles are present in the token but ignored → verify the role names in Keycloak match exactly: `user`, `admin`, `manager`, `support` (case-sensitive).

-- `internal/auth/oidc.go` — scope list (line 82), ID token claims extraction (lines 181–201)

+- `internal/auth/oidc.go:82` — scope list to modify

# Plan: Fix Keycloak Realm Roles Not Appearing in ID Token

## Context

After fixing cc-backend to extract roles from the **ID token** (rather than UserInfo), realm roles are still empty. The root cause is a **Keycloak configuration issue**: Keycloak's built-in "realm roles" mapper, which lives in the default `roles` client scope, does **not** add roles to the ID token by default — it only adds them to the access token. The backend never inspects the access token, only the ID token.

There is also a minor code gap: the `roles` scope is not explicitly requested, so if `roles` is not a default scope on the Keycloak client, it will not be applied at all.

---

## Part 1 — Keycloak Configuration (required)

### Option A: Fix the existing "realm roles" mapper in the `roles` client scope (recommended)

1. Keycloak Admin Console → **Client Scopes** → `roles`
2. Tab **Mappers** → click **realm roles**
3. Set **Add to ID token** = **ON**
4. Save

> This affects all clients that use the `roles` default scope.

### Option B: Add a dedicated mapper directly on the cc-backend client (scoped fix)

1. **Clients** → your client → **Client scopes** → tab **Dedicated scopes**
2. **Add mapper** → **By configuration** → **Realm Roles**
3. Configure:
   - **Name**: `realm-roles-id-token`
   - **Token Claim Name**: `realm_access.roles` (nested, use dot notation)
   - **Add to ID token**: ON
   - **Add to access token**: ON
   - **Add to userinfo**: optional (does not affect current code)
4. Save

> This only affects this specific client.

---

## Part 2 — Code change: request the `roles` scope explicitly

**File:** `internal/auth/oidc.go`, line 82

Change:
```go
Scopes: []string{oidc.ScopeOpenID, "profile"},
```
To:
```go
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
```

**Why:** If `roles` is not configured as a *default* client scope in Keycloak, it won't be applied unless explicitly requested. Adding it here ensures cc-backend always requests role claims regardless of whether the Keycloak client has `roles` as a default or optional scope.

---

## Verification

1. Enable debug logging: start cc-backend with `-loglevel debug`
2. Log in via OIDC → check logs for:
   ```
   OIDC ID token realm_access roles: [user admin ...]
   ```
3. If still empty: use [jwt.io](https://jwt.io) to decode the raw ID token from Keycloak (available in browser dev tools during the callback) and verify `realm_access.roles` is present in the payload.
4. If `realm_access` is absent from the decoded ID token → the Keycloak mapper is not configured correctly (Part 1 above).
5. If `realm_access.roles` is present but cc-backend ignores it → check the role name mapping (`user`, `admin`, `manager`, `support` must match exactly).

---

## Critical Files

- `internal/auth/oidc.go` — scope list (line 82), ID token claims extraction (lines 181–201)


ExitPlanMode

Summary:
## Root Cause

Keycloak's realm roles mapper does not add roles to the **ID token** by default — only to the access token. cc-backend only reads the ID token, so roles are always empty.

## Fix

### Keycloak 26.x (required)

Add a **User Realm Role** mapper to the client's dedicated scope:

1. **Clients** → your client → **Client scopes** → click the `<clientId>-dedicated` scope
2. **Mappers** → **Add mapper** → **By configuration** → **User Realm Role**
3. Set:
   - **Token Claim Name**: `realm_access.roles`
   - **Add to ID token**: **ON**
4. Save

### Code change — `internal/auth/oidc.go` line 82

```go
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
```

Adds explicit `roles` scope request so it works even if it's marked optional rather than default on the client.

## Verification

Run with `-loglevel debug` and check for:
```
OIDC ID token realm_access roles: [user admin ...]
```
Or decode the raw ID token at jwt.io and confirm `realm_access.roles` is in the payload.

# Plan: Fix Keycloak Realm Roles Not Appearing in ID Token

## Context

After fixing cc-backend to extract roles from the **ID token** (rather than UserInfo), realm roles are still empty. The root cause is a **Keycloak configuration issue**: by default, the realm roles mapper does **not** add roles to the ID token — it only adds them to the access token. The backend reads only the ID token, so roles are always empty.

There is also a minor code gap: the `roles` scope is not explicitly requested by cc-backend.

Environment: Keycloak 26.5.6

---

## Part 1 — Keycloak Configuration (required)

Add a "User Realm Role" mapper directly on the client that writes realm roles into the ID token.

**Steps in Keycloak Admin Console:**

1. **Clients** → select your cc-backend client
2. Tab **Client scopes** → click the link for the **dedicated** scope (shown as `<clientId>-dedicated`, type = Dedicated)
3. Tab **Mappers** → **Add mapper** → **By configuration**
4. Choose mapper type: **User Realm Role**
5. Configure:
   | Field | Value |
   |---|---|
   | Name | `realm-roles-id-token` |
   | Token Claim Name | `realm_access.roles` |
   | Claim JSON Type | `String` |
   | Add to ID token | **ON** |
   | Add to access token | ON |
   | Add to userinfo | optional |
6. **Save**

> The `realm_access.roles` token claim name (dot-notation) tells Keycloak to nest the array inside a `realm_access` object, matching what cc-backend parses.

---

## Part 2 — Code change: request the `roles` scope explicitly

**File:** `internal/auth/oidc.go`, line 82

```go
// Before
Scopes: []string{oidc.ScopeOpenID, "profile"},

// After
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
```

**Why:** If the `roles` scope is configured as optional (not default) on the Keycloak client, it won't be applied unless explicitly requested. This ensures it is always requested.

---

## Verification

1. Start cc-backend with `-loglevel debug`
2. Log in via OIDC → look for log lines:
   ```
   OIDC ID token realm_access roles: [user admin ...]
   ```
3. If still empty: open browser dev-tools during the OIDC callback, copy the `id_token` value, and decode it at **jwt.io** — verify `realm_access.roles` is present in the payload.
4. If `realm_access` is absent from the decoded ID token → the Keycloak mapper was not saved correctly (repeat Part 1).
5. If roles are present in the token but ignored → verify the role names in Keycloak match exactly: `user`, `admin`, `manager`, `support` (case-sensitive).

---

## Critical Files

- `internal/auth/oidc.go:82` — scope list to modify


User has approved your plan. You can now start coding. Start with updating your todo list if applicable

Your plan has been saved to: /Users/jan/.claude/plans/iterative-conjuring-sundae.md
You can refer back to it if needed during implementation.

## Approved Plan:
# Plan: Fix Keycloak Realm Roles Not Appearing in ID Token

## Context

After fixing cc-backend to extract roles from the **ID token** (rather than UserInfo), realm roles are still empty. The root cause is a **Keycloak configuration issue**: by default, the realm roles mapper does **not** add roles to the ID token — it only adds them to the access token. The backend reads only the ID token, so roles are always empty.

There is also a minor code gap: the `roles` scope is not explicitly requested by cc-backend.

Environment: Keycloak 26.5.6

---

## Part 1 — Keycloak Configuration (required)

Add a "User Realm Role" mapper directly on the client that writes realm roles into the ID token.

**Steps in Keycloak Admin Console:**

1. **Clients** → select your cc-backend client
2. Tab **Client scopes** → click the link for the **dedicated** scope (shown as `<clientId>-dedicated`, type = Dedicated)
3. Tab **Mappers** → **Add mapper** → **By configuration**
4. Choose mapper type: **User Realm Role**
5. Configure:
   | Field | Value |
   |---|---|
   | Name | `realm-roles-id-token` |
   | Token Claim Name | `realm_access.roles` |
   | Claim JSON Type | `String` |
   | Add to ID token | **ON** |
   | Add to access token | ON |
   | Add to userinfo | optional |
6. **Save**

> The `realm_access.roles` token claim name (dot-notation) tells Keycloak to nest the array inside a `realm_access` object, matching what cc-backend parses.

---

## Part 2 — Code change: request the `roles` scope explicitly

**File:** `internal/auth/oidc.go`, line 82

```go
// Before
Scopes: []string{oidc.ScopeOpenID, "profile"},

// After
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
```

**Why:** If the `roles` scope is configured as optional (not default) on the Keycloak client, it won't be applied unless explicitly requested. This ensures it is always requested.

---

## Verification

1. Start cc-backend with `-loglevel debug`
2. Log in via OIDC → look for log lines:
   ```
   OIDC ID token realm_access roles: [user admin ...]
   ```
3. If still empty: open browser dev-tools during the OIDC callback, copy the `id_token` value, and decode it at **jwt.io** — verify `realm_access.roles` is present in the payload.
4. If `realm_access` is absent from the decoded ID token → the Keycloak mapper was not saved correctly (repeat Part 1).
5. If roles are present in the token but ignored → verify the role names in Keycloak match exactly: `user`, `admin`, `manager`, `support` (case-sensitive).

---

## Critical Files

- `internal/auth/oidc.go:82` — scope list to modify


Now applying the code change:


Edit

Scopes:       []string{oidc.ScopeOpenID, "profile"},

Scopes:       []string{oidc.ScopeOpenID, "profile", "roles"},


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

// 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:   username,
		Name:       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)
}

-    Scopes:       []string{oidc.ScopeOpenID, "profile"},

+    Scopes:       []string{oidc.ScopeOpenID, "profile", "roles"},


Bash

Verify build is clean


(Bash completed with no output)


Done. Here's what to do:

### Keycloak 26.x configuration (the required part)

1. **Clients** → your client → **Client scopes** → click the `<clientId>-dedicated` scope link
2. **Mappers** → **Add mapper** → **By configuration** → **User Realm Role**
3. Set:
   - **Token Claim Name**: `realm_access.roles`
   - **Add to ID token**: **ON**
4. Save

### Code change applied

Added `"roles"` to the requested scopes (`oidc.go:82`) so the scope is explicitly requested even if it's optional rather than default on the Keycloak client.

### To verify it works

Start with `-loglevel debug` and log in — you should see:
```
OIDC ID token realm_access roles: [user admin ...]
```
If the array is still empty after the Keycloak mapper is configured, decode the raw `id_token` at **jwt.io** (grab it from browser dev tools during the callback) to confirm `realm_access.roles` is present in the JWT payload.


How can I add the projects array used for the manager role in KeyCloak


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

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

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


The `projects` slice is currently always empty — the code never reads it from the token. This requires both a code change and a Keycloak mapper. Let me implement both.

**Keycloak side:** Projects are a custom attribute (not a standard OIDC claim). You store them as a user attribute in Keycloak and map them into the ID token.

**Step 1 — Keycloak: set a user attribute**

For each manager user in Keycloak:
1. **Users** → select the user → **Attributes** tab
2. Add attribute: Key = `projects`, Value = `project1` (add multiple entries for multiple projects, one per row — Keycloak multi-values)
3. Save

**Step 2 — Keycloak: add a mapper**

1. **Clients** → your client → **Client scopes** → `<clientId>-dedicated`
2. **Mappers** → **Add mapper** → **By configuration** → **User Attribute**
3. Configure:
   | Field | Value |
   |---|---|
   | Name | `projects` |
   | User Attribute | `projects` |
   | Token Claim Name | `projects` |
   | Claim JSON Type | `String` |
   | Multivalued | **ON** |
   | Add to ID token | **ON** |
   | Add to access token | ON |
4. Save

**Step 3 — Code: read `projects` from the ID token**


Edit

	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"`
	}

	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"`
		// Custom multi-valued user attribute mapped via a Keycloak User Attribute mapper
		Projects []string `json:"projects"`
	}


	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"`
		// Custom multi-valued user attribute mapped via a Keycloak User Attribute mapper
		Projects []string `json:"projects"`
	}

// 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", "roles"},
	}

	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:   username,
		Name:       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)
}

+    // Custom multi-valued user attribute mapped via a Keycloak User Attribute mapper

+    Projects []string `json:"projects"`


Now populate `projects` from the extracted claim and add a debug log line:


	// projects is populated below from ID token claims
	var projects []string


	// projects is populated below from ID token claims
	var projects []string

// 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", "roles"},
	}

	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"`
		// Custom multi-valued user attribute mapped via a Keycloak User Attribute mapper
		Projects []string `json:"projects"`
	}
	if err := idToken.Claims(&idTokenClaims); err != nil {
		cclog.Errorf("failed to extract ID token claims: %s", err.Error())
		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)
	}

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

+  // projects is populated below from ID token claims


	cclog.Debugf("OIDC userinfo claims: username=%q name=%q", userInfoClaims.Username, userInfoClaims.Name)
	cclog.Debugf("OIDC ID token realm_access roles: %v", idTokenClaims.RealmAccess.Roles)
	cclog.Debugf("OIDC ID token resource_access: %v", idTokenClaims.ResourceAccess)

	cclog.Debugf("OIDC userinfo claims: username=%q name=%q", userInfoClaims.Username, userInfoClaims.Name)
	cclog.Debugf("OIDC ID token realm_access roles: %v", idTokenClaims.RealmAccess.Roles)
	cclog.Debugf("OIDC ID token resource_access: %v", idTokenClaims.ResourceAccess)
	cclog.Debugf("OIDC ID token projects: %v", idTokenClaims.Projects)

	projects = idTokenClaims.Projects
	if projects == nil {
		projects = []string{}
	}


	cclog.Debugf("OIDC userinfo claims: username=%q name=%q", userInfoClaims.Username, userInfoClaims.Name)
	cclog.Debugf("OIDC ID token realm_access roles: %v", idTokenClaims.RealmAccess.Roles)
	cclog.Debugf("OIDC ID token resource_access: %v", idTokenClaims.ResourceAccess)
	cclog.Debugf("OIDC ID token projects: %v", idTokenClaims.Projects)

	projects = idTokenClaims.Projects
	if projects == nil {
		projects = []string{}
	}

// 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", "roles"},
	}

	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 is populated below from ID token claims
	var projects []string

	// 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"`
		// Custom multi-valued user attribute mapped via a Keycloak User Attribute mapper
		Projects []string `json:"projects"`
	}
	if err := idToken.Claims(&idTokenClaims); err != nil {
		cclog.Errorf("failed to extract ID token claims: %s", err.Error())
		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)
	}

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

+  cclog.Debugf("OIDC ID token projects: %v", idTokenClaims.Projects)


Bash

Verify build is clean


Clean build. Summary:

---

**Keycloak configuration (per manager user):**
1. **Users** → user → **Attributes** → add `projects` attribute (one entry per project, multi-valued)
2. **Clients** → your client → **Client scopes** → `<clientId>-dedicated` → **Mappers** → **Add mapper** → **User Attribute**
   - User Attribute: `projects`, Token Claim Name: `projects`, **Multivalued: ON**, **Add to ID token: ON**

**Code change:**
- Added `Projects []string \`json:"projects"\`` to the ID token claims struct
- The `projects` slice is now populated from that claim before building the `schema.User`

**Verification:** run with `-loglevel debug` and check for:
```
OIDC ID token projects: [project1 project2]
```