M4a: define the six OIDC clients and the authorization model in Keycloak (no cutover) #279

Closed
opened 2026-08-07 18:18:53 +02:00 by dominik.polakovics · 0 comments

Part of #266 (M4, first half). Follows #277, which replaced realmFiles with a converging keycloak-config-cli import and landed the LDAP group mapper.

Define all six OIDC clients and the authorization model in the cloonar realm. Nothing is cut over — every application keeps pointing at Authelia. This issue makes Keycloak ready to serve them; repointing the apps is separate work.

Decisions already made — do not re-litigate

  • Exact parity with Authelia's access matrix. Reproduce it byte-for-byte, so that during the later cutover any "I can't get in" is unambiguously a migration bug rather than an intended policy change. Simplification is a deliberate later decision.
  • Reuse each app's existing client secret. Authelia stored only pbkdf2 hashes; the plaintext already lives app-side. Setting the same value in Keycloak reduces each app's cutover to changing the issuer URL, with no sops edits on web-arm or web-02.
  • claude-mcp-forgejo is being retired and is NOT migrated. Six clients, not the seven Authelia carries. Removing its Authelia client and the hosts/fw/vms/web/mcp-forgejo.nix vhost is separate cleanup — do not touch either here.
  • No 2FA work here. All the clients are one_factor in Authelia today. The two_factor requirement exists only in access_control for the forward-auth vhosts, which is M5.

Secrets — already placed, reference by name

hosts/web-arm/secrets.yaml carries what is needed. Three clients reuse a secret the host already holds; read them under a second name via sops.secrets.<n>.key, the way keycloak-ldap-bind reads linuxbind-password. Do not duplicate a value into a new key.

client sops key on web-arm note
grafana grafana-oauth-secret existing, reuse
immich immich-oidc-client-secret existing, reuse
nextcloud inside nextcloud-secrets confirm it is there; if absent, stop and say so rather than inventing a key
gitea gitea-oidc-client-secret freshly generated
freescout freescout-oidc-client-secret freshly generated
synapse synapse-oidc-client-secret copied from web-02's mas-authelia-client-secret

synapse-oidc-client-secret is a genuine duplicate of a value that also lives in web-02's secrets file — web-arm cannot decrypt web-02's — so note the rotation coupling in a comment. gitea's and freescout's are new values that their applications do not have yet; those get pasted in at cutover, which is why nothing must be repointed in this issue.

Every client must state its secret explicitly. A client left without one may be handed a fresh secret by keycloak-config-cli on each converge run under managed = full, which would break that application intermittently after cutover. Generate-once-then-pin is the only safe steady state.

The access matrix to reproduce

From hosts/web-arm/modules/authelia.nix:

Authelia policy groups clients
admin-only Administrators gitea, grafana, freescout
internal Administrators, Mitarbeiter, macher.solutions nextcloud
immich immich immich
(unrestricted) synapse

Keycloak has no per-client policy field. Express it as realm roles assigned to the mapped LDAP groups, plus per-client authentication-flow overrides containing a Condition - User Role subflow ending in Deny Access. Synapse needs no override and uses the default flow.

Two failure modes to design against

1. An override flow that omits auth-spnego silently kills SSO for that client. A flow override replaces the whole browser flow, so each gating flow must still contain the auth-cookie / auth-spnego / identity-provider-redirector / forms structure from browser-spnego. Miss it and the client still works by password — it just stops doing the one thing this entire workstream exists for, and a smoke test would not catch it.

2. full.path on the group-membership mapper. Keycloak's group membership mapper emits /Administrators by default. Authelia emits bare Administrators, and Grafana's role_attribute_path (hosts/web-arm/modules/grafana/default.nix) is written against the bare form. Set full.path = false or Grafana's role mapping breaks at cutover.

Client details

Port each from authelia.nix, preserving redirect URIs exactly:

client redirect URIs scopes
gitea https://git.cloonar.com/user/oauth2/authelia/callback openid profile email
nextcloud https://nextcloud.cloonar.com/apps/oidc_login/oidc openid profile email groups
grafana https://grafana.cloonar.com/login/generic_oauth openid profile email groups
freescout https://support.cloonar.com/oauth-login/callback/fryg87l64 openid profile email groups
synapse https://matrix.cloonar.com/upstream/callback/01KJPRKN397E5N8D0CA2Z3TJ7Y openid profile email
immich https://immich.cloonar.com/auth/login, https://immich.cloonar.com/user-settings, app.immich:///oauth-callback openid profile email

Per-client notes:

  • consent_mode = "implicit" on every client → consentRequired: false.
  • token_endpoint_auth_method = "client_secret_post" (freescout, synapse, immich): Keycloak's client-secret authenticator accepts both POST-body and Basic, so this needs no per-client setting — but verify rather than assume, since it was an explicit workaround for each of those four.
  • grafana needs groups in the ID token, not just userinfo — that is what Authelia's claims_policies.default was for.

Secrets in the realm file — resolve this first

Client secrets cannot sit in the realm JSON: it is a Nix store path and world-readable, the same reason the LDAP bind went behind the vault SPI in #271.

#277 disabled import.var-substitution on the theory that it would mangle ${vault.ldapbind}. That may have been over-cautious — keycloak-config-cli's substitution uses $(env VAR) syntax, which likely does not collide. Establish, before writing any client:

  1. whether Keycloak's vault SPI supports confidential client secrets at all (it is documented for LDAP bind and SMTP; client secrets are less clear), and
  2. if not, whether var-substitution can be re-enabled safely alongside the existing ${vault.…} reference.

Whichever route, the secrets arrive through the unit's EnvironmentFile or the vault directory — never as literals in the JSON. State the finding in the PR description.

ADR note

Record one consequence in docs/adr/0022-keycloak-and-scoped-kerberos.md: Authelia stored client secrets as one-way pbkdf2 hashes; Keycloak stores them recoverably — the admin console can display them. After M6 every client secret is readable by anyone with Keycloak admin access, its database, or a database backup. Normal for Keycloak, but a posture downgrade worth stating, and an argument for rotating all of them once the migration settles.

Out of scope

  • Cutting any application over. No changes to Gitea, Nextcloud, Grafana, FreeScout, Synapse/MAS or Immich configuration.
  • Retiring claude-mcp-forgejo — its Authelia client and hosts/fw/vms/web/mcp-forgejo.nix stay exactly as they are; removal is separate cleanup.
  • authelia.nix — untouched.
  • oauth2-proxy, the forward-auth vhosts, retiring Authelia, 2FA/OTP.

Verification

  • scripts/pre-commit dry-builds web-arm.
  • Preserve #277's discipline: the converge run must apply exactly the intended additions and nothing else. import.managed.* stays full.

Human, after deploy — no application changes are needed for any of this:

  1. systemctl status keycloak-config; admin console shows six clients with the right redirect URIs, and roles assigned to the four groups.
  2. SPNEGO regression check — the dominik.polakovics session still reaches https://sso.cloonar.com/realms/cloonar/account with no prompt.
  3. Gating, without cutting anything over. Visit the authorize endpoint directly in a browser:
    https://sso.cloonar.com/realms/cloonar/protocol/openid-connect/auth?client_id=immich&redirect_uri=https://immich.cloonar.com/auth/login&response_type=code&scope=openid
    An authorized user is redirected back with a code; an unauthorized one is denied. Repeat for one client per policy (gitea for admin-only, nextcloud for internal, immich), ideally with an account that is not in the relevant group — a policy that admits everyone looks identical to a working one otherwise.
  4. Confirm SSO survives the override flows: step 3 against a gated client, from the TGT session, must complete without a password prompt. This is failure mode 1 above.

Definition of done

  • Six clients, three gating flows, roles mapped from groups; all in the checked-in realm file.
  • No client secret literal anywhere in the Nix store.
  • No application repointed; authelia.nix unchanged.
  • PR opened with Closes #<this issue>, stating how client secrets are delivered and confirming step 4 passed.
Part of #266 (M4, first half). Follows #277, which replaced `realmFiles` with a converging `keycloak-config-cli` import and landed the LDAP group mapper. Define all six OIDC clients and the authorization model in the `cloonar` realm. **Nothing is cut over** — every application keeps pointing at Authelia. This issue makes Keycloak *ready* to serve them; repointing the apps is separate work. ## Decisions already made — do not re-litigate - **Exact parity with Authelia's access matrix.** Reproduce it byte-for-byte, so that during the later cutover any "I can't get in" is unambiguously a migration bug rather than an intended policy change. Simplification is a deliberate later decision. - **Reuse each app's existing client secret.** Authelia stored only pbkdf2 hashes; the plaintext already lives app-side. Setting the same value in Keycloak reduces each app's cutover to changing the issuer URL, with no sops edits on `web-arm` or `web-02`. - **`claude-mcp-forgejo` is being retired and is NOT migrated.** Six clients, not the seven Authelia carries. Removing its Authelia client and the `hosts/fw/vms/web/mcp-forgejo.nix` vhost is separate cleanup — do not touch either here. - **No 2FA work here.** All the clients are `one_factor` in Authelia today. The `two_factor` requirement exists only in `access_control` for the forward-auth vhosts, which is M5. ## Secrets — already placed, reference by name `hosts/web-arm/secrets.yaml` carries what is needed. Three clients reuse a secret the host already holds; read them under a second name via `sops.secrets.<n>.key`, the way `keycloak-ldap-bind` reads `linuxbind-password`. **Do not duplicate a value into a new key.** | client | sops key on `web-arm` | note | |---|---|---| | grafana | `grafana-oauth-secret` | existing, reuse | | immich | `immich-oidc-client-secret` | existing, reuse | | nextcloud | inside `nextcloud-secrets` | **confirm it is there**; if absent, stop and say so rather than inventing a key | | gitea | `gitea-oidc-client-secret` | freshly generated | | freescout | `freescout-oidc-client-secret` | freshly generated | | synapse | `synapse-oidc-client-secret` | copied from web-02's `mas-authelia-client-secret` | `synapse-oidc-client-secret` is a genuine duplicate of a value that also lives in web-02's secrets file — `web-arm` cannot decrypt web-02's — so note the rotation coupling in a comment. gitea's and freescout's are new values that their applications do not have yet; those get pasted in at cutover, which is why nothing must be repointed in this issue. **Every client must state its secret explicitly.** A client left without one may be handed a fresh secret by `keycloak-config-cli` on each converge run under `managed = full`, which would break that application intermittently after cutover. Generate-once-then-pin is the only safe steady state. ## The access matrix to reproduce From `hosts/web-arm/modules/authelia.nix`: | Authelia policy | groups | clients | |---|---|---| | `admin-only` | `Administrators` | gitea, grafana, freescout | | `internal` | `Administrators`, `Mitarbeiter`, `macher.solutions` | nextcloud | | `immich` | `immich` | immich | | *(unrestricted)* | — | synapse | Keycloak has no per-client policy field. Express it as realm roles assigned to the mapped LDAP groups, plus per-client authentication-flow overrides containing a `Condition - User Role` subflow ending in `Deny Access`. Synapse needs no override and uses the default flow. ### Two failure modes to design against **1. An override flow that omits `auth-spnego` silently kills SSO for that client.** A flow override replaces the *whole* browser flow, so each gating flow must still contain the `auth-cookie` / `auth-spnego` / `identity-provider-redirector` / forms structure from `browser-spnego`. Miss it and the client still works by password — it just stops doing the one thing this entire workstream exists for, and a smoke test would not catch it. **2. `full.path` on the group-membership mapper.** Keycloak's group membership mapper emits `/Administrators` by default. Authelia emits bare `Administrators`, and Grafana's `role_attribute_path` (`hosts/web-arm/modules/grafana/default.nix`) is written against the bare form. Set `full.path = false` or Grafana's role mapping breaks at cutover. ## Client details Port each from `authelia.nix`, preserving redirect URIs exactly: | client | redirect URIs | scopes | |---|---|---| | gitea | `https://git.cloonar.com/user/oauth2/authelia/callback` | openid profile email | | nextcloud | `https://nextcloud.cloonar.com/apps/oidc_login/oidc` | openid profile email groups | | grafana | `https://grafana.cloonar.com/login/generic_oauth` | openid profile email groups | | freescout | `https://support.cloonar.com/oauth-login/callback/fryg87l64` | openid profile email groups | | synapse | `https://matrix.cloonar.com/upstream/callback/01KJPRKN397E5N8D0CA2Z3TJ7Y` | openid profile email | | immich | `https://immich.cloonar.com/auth/login`, `https://immich.cloonar.com/user-settings`, `app.immich:///oauth-callback` | openid profile email | Per-client notes: - `consent_mode = "implicit"` on every client → `consentRequired: false`. - `token_endpoint_auth_method = "client_secret_post"` (freescout, synapse, immich): Keycloak's `client-secret` authenticator accepts both POST-body and Basic, so this needs no per-client setting — but **verify** rather than assume, since it was an explicit workaround for each of those four. - **grafana** needs `groups` in the **ID token**, not just userinfo — that is what Authelia's `claims_policies.default` was for. ## Secrets in the realm file — resolve this first Client secrets cannot sit in the realm JSON: it is a Nix store path and world-readable, the same reason the LDAP bind went behind the vault SPI in #271. #277 disabled `import.var-substitution` on the theory that it would mangle `${vault.ldapbind}`. That may have been over-cautious — keycloak-config-cli's substitution uses `$(env VAR)` syntax, which likely does not collide. Establish, before writing any client: 1. whether Keycloak's vault SPI supports confidential client secrets at all (it is documented for LDAP bind and SMTP; client secrets are less clear), and 2. if not, whether `var-substitution` can be re-enabled safely alongside the existing `${vault.…}` reference. Whichever route, the secrets arrive through the unit's `EnvironmentFile` or the vault directory — never as literals in the JSON. State the finding in the PR description. ## ADR note Record one consequence in `docs/adr/0022-keycloak-and-scoped-kerberos.md`: **Authelia stored client secrets as one-way pbkdf2 hashes; Keycloak stores them recoverably** — the admin console can display them. After M6 every client secret is readable by anyone with Keycloak admin access, its database, or a database backup. Normal for Keycloak, but a posture downgrade worth stating, and an argument for rotating all of them once the migration settles. ## Out of scope - **Cutting any application over.** No changes to Gitea, Nextcloud, Grafana, FreeScout, Synapse/MAS or Immich configuration. - **Retiring `claude-mcp-forgejo`** — its Authelia client and `hosts/fw/vms/web/mcp-forgejo.nix` stay exactly as they are; removal is separate cleanup. - `authelia.nix` — untouched. - oauth2-proxy, the forward-auth vhosts, retiring Authelia, 2FA/OTP. ## Verification - `scripts/pre-commit` dry-builds `web-arm`. - Preserve #277's discipline: the converge run must apply exactly the intended additions and nothing else. `import.managed.*` stays `full`. Human, after deploy — no application changes are needed for any of this: 1. `systemctl status keycloak-config`; admin console shows six clients with the right redirect URIs, and roles assigned to the four groups. 2. **SPNEGO regression check** — the `dominik.polakovics` session still reaches `https://sso.cloonar.com/realms/cloonar/account` with no prompt. 3. **Gating, without cutting anything over.** Visit the authorize endpoint directly in a browser: `https://sso.cloonar.com/realms/cloonar/protocol/openid-connect/auth?client_id=immich&redirect_uri=https://immich.cloonar.com/auth/login&response_type=code&scope=openid` An authorized user is redirected back with a `code`; an unauthorized one is denied. Repeat for one client per policy (`gitea` for admin-only, `nextcloud` for internal, `immich`), ideally with an account that is *not* in the relevant group — a policy that admits everyone looks identical to a working one otherwise. 4. **Confirm SSO survives the override flows**: step 3 against a gated client, from the TGT session, must complete without a password prompt. This is failure mode 1 above. ## Definition of done - Six clients, three gating flows, roles mapped from groups; all in the checked-in realm file. - No client secret literal anywhere in the Nix store. - No application repointed; `authelia.nix` unchanged. - PR opened with `Closes #<this issue>`, stating how client secrets are delivered and confirming step 4 passed.
dominik.polakovics changed title from M4a: define the seven OIDC clients and the authorization model in Keycloak (no cutover) to M4a: define the six OIDC clients and the authorization model in Keycloak (no cutover) 2026-08-07 18:30:06 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/nixos#279
No description provided.