Skip to content

Commit 41ab9ba

Browse files
chore(workspace): merge main - resolve feature flag conflict
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents 19e9787 + c084aee commit 41ab9ba

69 files changed

Lines changed: 4267 additions & 84 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.ai/todo.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Account Linking Plan
2+
3+
## Problem
4+
Self-hosted GitLab (gitlab.lrz.de) has different emails than GitHub. Keycloak can't auto-link. Need custom UI.
5+
6+
## Approach
7+
Backend generates link URLs via Keycloak's legacy broker link endpoint (KC 26.0). Frontend shows linked accounts in settings page.
8+
9+
## Backend Changes
10+
11+
### 1. New DTO: `LinkedAccountDTO.java` (in account package)
12+
```java
13+
public record LinkedAccountDTO(
14+
String providerAlias, // "github", "gitlab-lrz"
15+
String providerName, // "GitHub", "GitLab LRZ"
16+
boolean connected,
17+
@Nullable String linkedUsername
18+
) {}
19+
```
20+
21+
### 2. New service: `LinkedAccountsService.java`
22+
- Inject `Keycloak`, `KeycloakProperties`
23+
- `getLinkedAccounts(keycloakUserId)`:
24+
- Get federated identities via admin API
25+
- Get available IdPs from realm config
26+
- Return merged list with connected status
27+
- `buildLinkUrl(keycloakUserId, provider, redirectUri, token)`:
28+
- Extract sessionState + azp from JWT
29+
- Compute hash = Base64Url(SHA256(nonce + sessionState + clientId + provider))
30+
- Return `{keycloakUrl}/realms/{realm}/broker/{provider}/link?client_id=...&redirect_uri=...&nonce=...&hash=...`
31+
- `unlinkAccount(keycloakUserId, provider)`:
32+
- Remove federated identity via admin API
33+
- Guard: count existing identities, reject if last one
34+
35+
### 3. New endpoints on `AccountController.java`
36+
- `GET /user/linked-accounts` → List<LinkedAccountDTO>
37+
- `GET /user/linked-accounts/{provider}/link-url?redirectUri=...` → String (URL)
38+
- `DELETE /user/linked-accounts/{provider}` → 204
39+
40+
## Frontend Changes
41+
42+
### 4. New component: `LinkedAccountsSection.tsx`
43+
- Query `GET /user/linked-accounts`
44+
- Each provider card: icon + name + status + action button
45+
- Connected: username badge + "Disconnect" (with AlertDialog)
46+
- Not connected: "Connect" → fetches link URL → `window.location.href = url`
47+
- Guard: disable disconnect on last provider
48+
49+
### 5. Modify `SettingsPage.tsx`
50+
- Add LinkedAccountsSection before AccountSection
51+
- Show when GitLab IdP is configured (from linked-accounts response having gitlab-lrz)
52+
53+
### 6. Handle redirect callback in `settings.tsx`
54+
- After Keycloak broker link redirect, user returns to settings page
55+
- Show toast on success/error based on URL params
56+
57+
## Files
58+
59+
### New
60+
- `server/.../account/LinkedAccountDTO.java`
61+
- `server/.../account/LinkedAccountsService.java`
62+
- `webapp/src/components/settings/LinkedAccountsSection.tsx`
63+
64+
### Modified
65+
- `server/.../account/AccountController.java` — 3 endpoints
66+
- `webapp/src/components/settings/SettingsPage.tsx` — add section
67+
- `webapp/src/routes/_authenticated/settings.tsx` — callback handling
68+
- Regenerate OpenAPI + API client
69+
70+
## No feature flag
71+
Show linked accounts whenever multiple IdPs exist (detected from backend response).

docker/.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ KEYCLOAK_GITHUB_CLIENT_SECRET=
3838
# GitHub username that should receive Keycloak admin rights on first login
3939
KEYCLOAK_GITHUB_ADMIN_USERNAME=
4040

41+
# Keycloak GitLab OIDC (for user login via GitLab)
42+
# Create OAuth Application at: https://gitlab.lrz.de/admin/applications
43+
# Required scopes: openid, profile, email
44+
# Redirect URI: https://<hostname>/keycloak/realms/hephaestus/broker/gitlab-lrz/endpoint
45+
KEYCLOAK_GITLAB_ENABLED=true
46+
KEYCLOAK_GITLAB_CLIENT_ID=
47+
KEYCLOAK_GITLAB_CLIENT_SECRET=
48+
49+
# Base URL of the GitLab instance used for login (default: https://gitlab.lrz.de)
50+
# KEYCLOAK_GITLAB_BASE_URL=https://gitlab.lrz.de
51+
52+
# GitLab username that should receive Keycloak admin rights on first login
53+
KEYCLOAK_GITLAB_ADMIN_USERNAME=
54+
4155
# Client secret for the hephaestus-confidential client (used by application-server)
4256
# Generate with: openssl rand -base64 32
4357
KEYCLOAK_HEPHAESTUS_CONFIDENTIAL_CLIENT_SECRET=

docker/compose.core.yaml

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ services:
6868
max-file: "3"
6969

7070
keycloak:
71-
image: quay.io/keycloak/keycloak:26.0
71+
image: quay.io/keycloak/keycloak:26.5
7272
restart: unless-stopped
7373
depends_on:
7474
keycloak-postgres:
@@ -99,6 +99,11 @@ services:
9999
- KEYCLOAK_GITHUB_CLIENT_ID=${KEYCLOAK_GITHUB_CLIENT_ID}
100100
- KEYCLOAK_GITHUB_CLIENT_SECRET=${KEYCLOAK_GITHUB_CLIENT_SECRET}
101101
- KEYCLOAK_GITHUB_ADMIN_USERNAME=${KEYCLOAK_GITHUB_ADMIN_USERNAME}
102+
- KEYCLOAK_GITLAB_ENABLED=${KEYCLOAK_GITLAB_ENABLED:-false}
103+
- KEYCLOAK_GITLAB_CLIENT_ID=${KEYCLOAK_GITLAB_CLIENT_ID}
104+
- KEYCLOAK_GITLAB_CLIENT_SECRET=${KEYCLOAK_GITLAB_CLIENT_SECRET}
105+
- KEYCLOAK_GITLAB_BASE_URL=${KEYCLOAK_GITLAB_BASE_URL:-https://gitlab.lrz.de}
106+
- KEYCLOAK_GITLAB_ADMIN_USERNAME=${KEYCLOAK_GITLAB_ADMIN_USERNAME}
102107
labels:
103108
- "traefik.enable=true"
104109
- "traefik.http.middlewares.gzip.compress=true"
@@ -213,6 +218,44 @@ configs:
213218
"display.on.consent.screen": "false",
214219
"token.response.type.bearer.lower-case": "false"
215220
},
221+
"protocolMappers": [
222+
{
223+
"name": "github_id",
224+
"protocol": "openid-connect",
225+
"protocolMapper": "oidc-usermodel-attribute-mapper",
226+
"config": {
227+
"user.attribute": "github_id",
228+
"claim.name": "github_id",
229+
"id.token.claim": "true",
230+
"access.token.claim": "true",
231+
"jsonType.label": "long"
232+
}
233+
},
234+
{
235+
"name": "gitlab_id",
236+
"protocol": "openid-connect",
237+
"protocolMapper": "oidc-usermodel-attribute-mapper",
238+
"config": {
239+
"user.attribute": "gitlab_id",
240+
"claim.name": "gitlab_id",
241+
"id.token.claim": "true",
242+
"access.token.claim": "true",
243+
"jsonType.label": "long"
244+
}
245+
},
246+
{
247+
"name": "identity_provider",
248+
"protocol": "openid-connect",
249+
"protocolMapper": "oidc-usersessionmodel-note-mapper",
250+
"config": {
251+
"user.session.note": "identity_provider",
252+
"claim.name": "identity_provider",
253+
"id.token.claim": "true",
254+
"access.token.claim": "true",
255+
"jsonType.label": "String"
256+
}
257+
}
258+
],
216259
"authenticationFlowBindingOverrides": {},
217260
"fullScopeAllowed": true,
218261
"nodeReRegistrationTimeout": -1,
@@ -339,7 +382,7 @@ configs:
339382
"displayName": "GitHub",
340383
"providerId": "github",
341384
"enabled": true,
342-
"updateProfileFirstLoginMode": "on",
385+
"updateProfileFirstLoginMode": "off",
343386
"trustEmail": true,
344387
"storeToken": false,
345388
"addReadTokenRoleOnCreate": false,
@@ -352,6 +395,80 @@ configs:
352395
"clientId": "${KEYCLOAK_GITHUB_CLIENT_ID}",
353396
"guiOrder": "1"
354397
}
398+
},
399+
{
400+
"alias": "gitlab-lrz",
401+
"displayName": "GitLab LRZ",
402+
"providerId": "oidc",
403+
"enabled": ${KEYCLOAK_GITLAB_ENABLED:-false},
404+
"updateProfileFirstLoginMode": "off",
405+
"trustEmail": true,
406+
"storeToken": false,
407+
"addReadTokenRoleOnCreate": false,
408+
"authenticateByDefault": false,
409+
"linkOnly": false,
410+
"hideOnLogin": false,
411+
"firstBrokerLoginFlowAlias": "first broker login",
412+
"config": {
413+
"syncMode": "IMPORT",
414+
"clientSecret": "${KEYCLOAK_GITLAB_CLIENT_SECRET}",
415+
"clientId": "${KEYCLOAK_GITLAB_CLIENT_ID}",
416+
"issuer": "${KEYCLOAK_GITLAB_BASE_URL}",
417+
"authorizationUrl": "${KEYCLOAK_GITLAB_BASE_URL}/oauth/authorize",
418+
"tokenUrl": "${KEYCLOAK_GITLAB_BASE_URL}/oauth/token",
419+
"userInfoUrl": "${KEYCLOAK_GITLAB_BASE_URL}/oauth/userinfo",
420+
"jwksUrl": "${KEYCLOAK_GITLAB_BASE_URL}/oauth/discovery/keys",
421+
"logoutUrl": "",
422+
"defaultScope": "openid profile email",
423+
"validateSignature": "true",
424+
"useJwksUrl": "true",
425+
"guiOrder": "2",
426+
"clientAuthMethod": "client_secret_post"
427+
}
428+
}
429+
],
430+
"identityProviderMappers": [
431+
{
432+
"name": "github-id-attribute",
433+
"identityProviderAlias": "github",
434+
"identityProviderMapper": "github-user-attribute-mapper",
435+
"config": {
436+
"syncMode": "FORCE",
437+
"jsonField": "id",
438+
"userAttribute": "github_id"
439+
}
440+
},
441+
{
442+
"name": "gitlab-id-attribute",
443+
"identityProviderAlias": "gitlab-lrz",
444+
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
445+
"config": {
446+
"syncMode": "FORCE",
447+
"claim": "sub",
448+
"user.attribute": "gitlab_id"
449+
}
450+
},
451+
{
452+
"name": "github-admin-realm-role",
453+
"identityProviderAlias": "github",
454+
"identityProviderMapper": "oidc-advanced-role-idp-mapper",
455+
"config": {
456+
"syncMode": "FORCE",
457+
"claims": "[{\"key\":\"login\",\"value\":\"(?i)^${KEYCLOAK_GITHUB_ADMIN_USERNAME}$\"}]",
458+
"are.claim.values.regex": "true",
459+
"role": "admin"
460+
}
461+
},
462+
{
463+
"name": "gitlab-admin-realm-role",
464+
"identityProviderAlias": "gitlab-lrz",
465+
"identityProviderMapper": "oidc-advanced-role-idp-mapper",
466+
"config": {
467+
"syncMode": "FORCE",
468+
"claims": "[{\"key\":\"preferred_username\",\"value\":\"(?i)^${KEYCLOAK_GITLAB_ADMIN_USERNAME}$\"}]",
469+
"are.claim.values.regex": "true",
470+
"role": "admin"
471+
}
355472
}
356473
],
357474
"roles" : {
@@ -434,10 +551,27 @@ configs:
434551
"realm-management": [
435552
"view-users",
436553
"query-users",
437-
"manage-users"
554+
"manage-users",
555+
"view-identity-providers"
438556
]
439557
},
440558
"notBefore": 0,
441559
"groups": []
560+
}],
561+
"authenticatorConfig": [
562+
{
563+
"alias": "review profile config",
564+
"config": {
565+
"update.profile.on.first.login": "off"
566+
}
567+
}
568+
],
569+
"components": {
570+
"org.keycloak.userprofile.UserProfileProvider": [{
571+
"providerId": "declarative-user-profile",
572+
"config": {
573+
"kc.user.profile.config": ["{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}}},{\"name\":\"email\",\"displayName\":\"${email}\",\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"validations\":{\"email\":{},\"length\":{\"max\":255}}},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"validations\":{\"length\":{\"max\":255}}},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"validations\":{\"length\":{\"max\":255}}}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}"]
574+
}
442575
}]
576+
}
443577
}

docker/preview/compose.shared-infra.yaml

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ services:
105105
# Configure domain in Coolify UI: https://example.com/keycloak:8080
106106
# ---------------------------------------------------------------------------
107107
keycloak:
108-
image: quay.io/keycloak/keycloak:26.0
108+
image: quay.io/keycloak/keycloak:26.5
109109
restart: unless-stopped
110110
depends_on:
111111
keycloak-db:
@@ -286,7 +286,7 @@ configs:
286286
"displayName": "GitHub",
287287
"providerId": "github",
288288
"enabled": true,
289-
"updateProfileFirstLoginMode": "on",
289+
"updateProfileFirstLoginMode": "off",
290290
"trustEmail": true,
291291
"storeToken": false,
292292
"addReadTokenRoleOnCreate": false,
@@ -318,7 +318,23 @@ configs:
318318
"enabled": true,
319319
"serviceAccountClientId": "hephaestus-confidential",
320320
"realmRoles": ["default-roles-hephaestus"],
321-
"clientRoles": {"realm-management": ["view-users", "query-users", "manage-users"]}
321+
"clientRoles": {"realm-management": ["view-users", "query-users", "manage-users", "view-identity-providers"]}
322322
}
323-
]
323+
],
324+
"authenticatorConfig": [
325+
{
326+
"alias": "review profile config",
327+
"config": {
328+
"update.profile.on.first.login": "off"
329+
}
330+
}
331+
],
332+
"components": {
333+
"org.keycloak.userprofile.UserProfileProvider": [{
334+
"providerId": "declarative-user-profile",
335+
"config": {
336+
"kc.user.profile.config": ["{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}}},{\"name\":\"email\",\"displayName\":\"${email}\",\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"validations\":{\"email\":{},\"length\":{\"max\":255}}},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"validations\":{\"length\":{\"max\":255}}},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"validations\":{\"length\":{\"max\":255}}}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}"]
337+
}
338+
}]
339+
}
324340
}

server/application-server/.env.example

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,24 @@ KEYCLOAK_GITHUB_ADMIN_USERNAME=replace-with-github-username-with-admin-access
5353
# For PRODUCTION: Generate a secure random secret and update both this file and Keycloak.
5454
KEYCLOAK_CLIENT_SECRET=hephaestus-dev-secret
5555

56+
# =============================================================================
57+
# GITLAB IDENTITY PROVIDER (optional)
58+
# =============================================================================
59+
# To enable GitLab LRZ login alongside GitHub:
60+
#
61+
# 1. Create an OAuth application on your GitLab instance
62+
# (e.g. https://gitlab.lrz.de/-/user_settings/applications) with:
63+
# - Redirect URI: http://localhost:8081/realms/hephaestus/broker/gitlab-lrz/endpoint
64+
# (replace 8081 with your KEYCLOAK_PORT if overridden)
65+
# - Scopes: openid, profile, email
66+
# 2. Uncomment and fill in the values below
67+
# 3. Run `docker compose down -v && mvn spring-boot:run` to re-import the realm
68+
#
69+
#KEYCLOAK_GITLAB_ENABLED=true
70+
#KEYCLOAK_GITLAB_BASE_URL=https://gitlab.lrz.de
71+
#KEYCLOAK_GITLAB_CLIENT_ID=replace-with-gitlab-client-id
72+
#KEYCLOAK_GITLAB_CLIENT_SECRET=replace-with-gitlab-client-secret
73+
#KEYCLOAK_GITLAB_ADMIN_USERNAME=replace-with-gitlab-username-with-admin-access
74+
5675
# GITHUB_PAT for GitHub API authentication (as alternative for env variable)
5776
#GITHUB_PAT=replace-with-github-pat

server/application-server/compose.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,20 @@ services:
1818
- ./postgres-data:/var/lib/postgresql/data
1919

2020
keycloak:
21-
image: quay.io/keycloak/keycloak:26.0.0
21+
image: quay.io/keycloak/keycloak:26.5
2222
environment:
2323
KC_BOOTSTRAP_ADMIN_USERNAME: admin
2424
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
2525
# GitHub OAuth credentials for identity provider (resolved by Keycloak during realm import)
2626
KEYCLOAK_GITHUB_CLIENT_ID: '${KEYCLOAK_GITHUB_CLIENT_ID:?Set GitHub OAuth client ID}'
2727
KEYCLOAK_GITHUB_CLIENT_SECRET: '${KEYCLOAK_GITHUB_CLIENT_SECRET:?Set GitHub OAuth client secret}'
2828
KEYCLOAK_GITHUB_ADMIN_USERNAME: '${KEYCLOAK_GITHUB_ADMIN_USERNAME:?Set GitHub admin username}'
29+
# GitLab OAuth credentials (optional — IdP is disabled by default)
30+
KEYCLOAK_GITLAB_ENABLED: '${KEYCLOAK_GITLAB_ENABLED:-false}'
31+
KEYCLOAK_GITLAB_CLIENT_ID: '${KEYCLOAK_GITLAB_CLIENT_ID:-unused}'
32+
KEYCLOAK_GITLAB_CLIENT_SECRET: '${KEYCLOAK_GITLAB_CLIENT_SECRET:-unused}'
33+
KEYCLOAK_GITLAB_BASE_URL: '${KEYCLOAK_GITLAB_BASE_URL:-https://gitlab.example.com}'
34+
KEYCLOAK_GITLAB_ADMIN_USERNAME: '${KEYCLOAK_GITLAB_ADMIN_USERNAME:-unused}'
2935
# Confidential client secret - must match what the application server uses
3036
# Default: hephaestus-dev-secret (for local development only)
3137
KEYCLOAK_CONFIDENTIAL_CLIENT_SECRET: '${KEYCLOAK_CLIENT_SECRET:-hephaestus-dev-secret}'

0 commit comments

Comments
 (0)