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