feat(web-arm): define the six OIDC clients and the authorization model in Keycloak #281

Merged
dominik.polakovics merged 1 commit from afk/279 into main 2026-08-07 19:30:15 +02:00

Closes #279

M4's first half. The six surviving OIDC clients and Authelia's access matrix now exist in the cloonar realm. Nothing is cut over — every application still points at Authelia, authelia.nix is byte-for-byte unchanged, and claude-mcp-forgejo and its vhost are untouched.

How client secrets are delivered

The issue asked for this to be settled first. Both of its candidate routes were checked against source, and the answer changed since the assumption in #277 was written.

Keycloak resolves ${vault.…} in a confidential client's secret — as of 26.6.0. The channel pins 26.7.0, so this is available to us. It was added in keycloak/keycloak@cb4c533464 ("Add support for looking up client secrets via Vault SPI", fixes #13102): OIDCClientSecretConfigWrapper.validateSecret now wraps the client and resolves session.vault().getStringSecret(secret) at authentication time, and docs/.../vault.adoc at that tag lists "OIDC client secret" alongside the SMTP password, LDAP bind credential and IdP secret. Grepping session.vault() in that file across tags puts the change between 26.5.0 (absent) and 26.6.0 (present) — the published https://www.keycloak.org/server/vault page still shows the older three-item list, so the tagged adoc is the authority here.

So the realm JSON carries "secret": "${vault.<app>secret}" and nothing else. materialiseSecrets writes six mode-0400 files into /run/keycloak/vault/, the same file-vault directory the LDAP bind already uses, with the trailing newline stripped for the same reason. No client secret literal exists anywhere in the Nix store, in git, or in the Keycloak database — the database holds only the reference.

--import.var-substitution.enabled=false stays as it is, and #277's caution turns out to have been unnecessary but harmless: keycloak-config-cli 6.4.0's application.properties sets import.var-substitution.prefix=$( / suffix=), chosen in its 4.0.0 release precisely "to prevent incompatibility with keycloak variables". ${vault.…} would have passed through untouched either way. We don't need substitution at all, so the flag is left alone.

Secret sourcing, per the issue's table — three read an existing secret under a second name via sops.secrets.<n>.key, mirroring keycloak-ldap-bind; no value is duplicated into a new sops key:

client source note
gitea gitea-oidc-client-secret fresh
freescout freescout-oidc-client-secret fresh
synapse synapse-oidc-client-secret genuine duplicate of web-02's mas-authelia-client-secret; rotation coupling noted in a comment
grafana grafana-oauth-secret reused
immich immich-oidc-client-secret reused
nextcloud oidc_login_client_secret inside nextcloud-secrets see below

Nextcloud, honestly: nextcloud-secrets is a single opaque sops string (the NixOS services.nextcloud.secretFile JSON blob), so sops.secrets.…key cannot address a field inside it and I have no age key here to decrypt and confirm. The evidence that the field is there and is named oidc_login_client_secret is that hosts/web-arm/modules/nextcloud/default.nix sets every other oidc_login_* option in settings and not the secret, Nextcloud's Authelia login works today, and that is the config key nextcloud-oidc-login reads. So it is extracted with jq -er at unit start rather than copied into a new key. If that assumption is wrong, keycloak.service fails at deploy with nextcloud-secrets has no usable oidc_login_client_secret — fail-closed and loud, chosen deliberately over silently writing a wrong secret that would only surface at cutover. Keycloak serves nothing load-bearing yet, so deploy time is the cheapest place to find out.

Two failure modes the issue named — and a third

1. The gating flows would have killed SSO — but not the way the issue expected. The issue warned that an override flow omitting auth-spnego silently drops SSO. The real hazard is worse and is triggered by the obvious layout. DefaultAuthenticationFlow.fillListsOfExecutions sorts a CONDITIONAL subflow into the required list, then:

if (!requiredList.isEmpty() && !alternativeList.isEmpty()) {
    logger.warnf("REQUIRED and ALTERNATIVE elements at same level! Those alternative executions will be ignored: %s", alternativeIds);
    alternativeList.clear();
}

Putting the role gate beside auth-cookie / auth-spnego / identity-provider-redirector / forms discards all four. The conditional then evaluates with context.getUser() == null, ConditionalRoleAuthenticator.matchCondition returns false, the subflow is removed as disabled, and processFlow returns null with successful == falseAuthenticationFlowError.INTERNAL_ERROR on every request, for every client bound to that flow. Not a subtle SSO regression; a total outage, whose only trace is one WARN line.

Each gating flow is therefore shaped as a top level of exactly two children:

browser-spnego <policy>                      topLevel
├── browser-spnego <policy> auth             REQUIRED     ← the four ALTERNATIVEs, mirroring browser-spnego
│   └── browser-spnego <policy> forms        ALTERNATIVE
└── browser-spnego <policy> gate             CONDITIONAL  ← conditional-user-role (negate) + deny-access-authenticator

This also fixes the ordering that makes the gate meaningful: the wrapper must succeed before the gate is evaluated, so the user is set. A gate evaluated too early does not deny — it disables itself, i.e. fails open. Same reason negate: "true" is load-bearing: matchCondition returns negate != user.hasRole(role), and the subflow runs only when the condition matches, so the deny fires exactly for users lacking the role.

Verified against Keycloak 26.7.0 DefaultAuthenticationFlow.java, ConditionalRoleAuthenticator*, DenyAccessAuthenticatorFactory, and RoleMapperModel.hasRole (whose contract explicitly includes "this object belongs to a group G which is assigned the role R" — which is what makes group-assigned roles work at all).

2. full.path — as the issue said. The groups client scope's oidc-group-membership-mapper sets "full.path": "false", so the claim is Administrators, not /Administrators. GroupMembershipMapper.useFullPath() is "true".equals(...), so the string form matters. id.token.claim is "true", which is what Authelia's claims_policies.default existed to do for Grafana's role_attribute_path.

3. Not in the issue: Keycloak rejects unknown scopes. Nextcloud, Grafana and FreeScout all send scope=openid profile email groups, and Keycloak has no built-in groups scope. AuthorizationEndpointChecker.checkValidScopeTokenManager.isValidScope returns false for any scope with no matching client scope → HTTP 400 invalid_scope, before the login page renders. So a groups client scope is created and assigned. Because RepresentationToModel.updateClientScopes treats the union of defaultClientScopes + optionalClientScopes as the complete desired set — it replaces, it does not merge — the built-ins are listed explicitly on every client. acr is deliberately left out: it is gated on the STEP_UP_AUTHENTICATION feature and nothing here reads an acr claim.

The access matrix

Three realm roles, assigned to the four LDAP groups the existing group-ldap-mapper federates, and one flow override per policy. Synapse was one_factor in Authelia (any authenticated user) and gets no override, so it keeps the realm's default browser-spnego.

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

Roles rather than group conditions because a conditional subflow ANDs its conditions — it cannot express internal's three-group OR. Roles are declared rather than discovered for a related reason: matchCondition resolves the role by name and returns false if it does not exist, which is the fail-open direction, so the role must not depend on an LDAP sync having happened first.

Authelia set token_endpoint_auth_method = "client_secret_post" on freescout, synapse and immich. The issue asked for this to be verified rather than assumed: Keycloak needs nothing per client. ClientIdAndSecretAuthenticator reads client_id/client_secret from the POST body as well as the Basic header and advertises both methods. There is an opt-in restriction — the client.secret.authentication.allowed.method attribute — and the correct action is to not set it, which is what the realm file does.

One deviation: --import.managed.group=no-delete

The issue asked that import.managed.* stay full. Everything else does. Groups cannot, and the reason is specific rather than a general loosening.

Every other importer that deletes is either state-gated or excludes built-ins. ClientImportService.deleteClientsMissingInImport filters on KeycloakUtil.isDefaultClient and stateClients.contains(...); RoleImportService.deleteRealmRolesMissingInImport filters on isDefaultRole and remote state. GroupImportService references StateService zero times — its deletion pass removes every top-level group not named in the import, with no state gate, no built-in exclusion, and no way to recognise a federated group. Declaring four groups under full would delete every other LDAP-synced group on the first converge; the mapper recreates them at the next member login (GroupLDAPStorageMapper.findKcGroupOrSyncFromLDAP), and the next converge deletes them again. Perpetual churn, a new group UUID each cycle, and any hand-created Keycloak group silently gone.

kcc's group deletion exists to clean up groups kcc created. Ours are always in the import, so under full it can only ever delete things it did not create. no-delete costs nothing and removes the whole class.

Worth recording separately, since the ADR states otherwise: import.managed.client-scope and import.managed.scope-mapping are not full by default at 6.4.0. The @DefaultValue("FULL") annotations are overridden by the application.properties inside the jar, which sets both to no-delete. That is why adding a clientScopes block deletes nothing — and it is worth not passing --import.managed.client-scope=full, which would delete the service_account scope (created but never realm-assigned, so outside the exclusion set).

Verification

  • scripts/test-configuration web-arm passes. The pre-commit hook ran on the commit: :: web-arm OK. (This container ships no Nix, so it ran against a nix-portable nix-instantiate — a real evaluation of hosts/web-arm/configuration.nix against the pinned nixos-26.05, producing nixos-system-web-arm-26.05.7006.445d861c6d31.drv.)
  • The realm file's diff is 541 insertions, 0 deletions — the header, both original flows and the whole components block are byte-identical, checked by parsing both revisions and comparing.
  • A checker asserts, independently of the file's author: no flow anywhere mixes ALTERNATIVE with REQUIRED/CONDITIONAL at one level; each gating top level is [REQUIRED auth, CONDITIONAL gate] in priority order and its auth subflow reproduces browser-spnego's executions exactly; every flowAlias resolves and every subflow is referenced exactly once; every authenticator config is referenced exactly once and names a declared realm role with negate == "true"; every client secret is a ${vault.…} reference whose key matches a file default.nix actually materialises; and no pbkdf2 string survives anywhere.
  • A second checker parses authelia.nix and compares: client set, every redirect URI character for character, policy→flow-override mapping, and which clients carry the groups scope. All match.

What has not been verified, and cannot be from here: everything that needs a running Keycloak. The converge itself, the nextcloud-secrets field name, and the issue's step 4 (SSO surviving an override flow) are all post-deploy. Step 4 is argued from source above rather than demonstrated — I could not run it, and I am not claiming otherwise.

After deploy

  1. systemctl status keycloak-config; console shows six clients and four groups carrying the policy-* roles.
  2. SPNEGO regression: 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, once per policy (gitea, nextcloud, immich), ideally with an account outside the relevant group. A gate that admits everyone is indistinguishable from a working one otherwise, and the two ways this can fail (a role name that does not resolve; a gate evaluated before the user is set) both fail open.
  4. Step 3 against a gated client from the TGT session must complete with no password prompt.

ADR

docs/adr/0022 records the consequence the issue asked for: Authelia stored client secrets as one-way pbkdf2 digests, Keycloak stores recoverable material. The vault route keeps our six out of the store, git and the database — but the distinction still holds for anything set through the console or rotated that way, and it is the argument for rotating all six once the migration settles.

Closes #279 M4's first half. The six surviving OIDC clients and Authelia's access matrix now exist in the `cloonar` realm. **Nothing is cut over** — every application still points at Authelia, `authelia.nix` is byte-for-byte unchanged, and `claude-mcp-forgejo` and its vhost are untouched. ## How client secrets are delivered The issue asked for this to be settled first. Both of its candidate routes were checked against source, and the answer changed since the assumption in #277 was written. **Keycloak resolves `${vault.…}` in a confidential client's `secret` — as of 26.6.0.** The channel pins 26.7.0, so this is available to us. It was added in keycloak/keycloak@cb4c533464 ("Add support for looking up client secrets via Vault SPI", fixes #13102): `OIDCClientSecretConfigWrapper.validateSecret` now wraps the client and resolves `session.vault().getStringSecret(secret)` at authentication time, and `docs/.../vault.adoc` at that tag lists "OIDC client secret" alongside the SMTP password, LDAP bind credential and IdP secret. Grepping `session.vault()` in that file across tags puts the change between 26.5.0 (absent) and 26.6.0 (present) — the published <https://www.keycloak.org/server/vault> page still shows the older three-item list, so the tagged adoc is the authority here. So the realm JSON carries `"secret": "${vault.<app>secret}"` and nothing else. `materialiseSecrets` writes six mode-0400 files into `/run/keycloak/vault/`, the same file-vault directory the LDAP bind already uses, with the trailing newline stripped for the same reason. **No client secret literal exists anywhere in the Nix store, in git, or in the Keycloak database** — the database holds only the reference. `--import.var-substitution.enabled=false` stays as it is, and #277's caution turns out to have been unnecessary but harmless: keycloak-config-cli 6.4.0's `application.properties` sets `import.var-substitution.prefix=$(` / `suffix=)`, chosen in its 4.0.0 release precisely "to prevent incompatibility with keycloak variables". `${vault.…}` would have passed through untouched either way. We don't need substitution at all, so the flag is left alone. Secret sourcing, per the issue's table — three read an existing secret under a second name via `sops.secrets.<n>.key`, mirroring `keycloak-ldap-bind`; no value is duplicated into a new sops key: | client | source | note | |---|---|---| | gitea | `gitea-oidc-client-secret` | fresh | | freescout | `freescout-oidc-client-secret` | fresh | | synapse | `synapse-oidc-client-secret` | genuine duplicate of web-02's `mas-authelia-client-secret`; rotation coupling noted in a comment | | grafana | `grafana-oauth-secret` | reused | | immich | `immich-oidc-client-secret` | reused | | nextcloud | `oidc_login_client_secret` inside `nextcloud-secrets` | see below | **Nextcloud, honestly:** `nextcloud-secrets` is a single opaque sops string (the NixOS `services.nextcloud.secretFile` JSON blob), so `sops.secrets.…key` cannot address a field inside it and I have no age key here to decrypt and confirm. The evidence that the field is there and is named `oidc_login_client_secret` is that `hosts/web-arm/modules/nextcloud/default.nix` sets every other `oidc_login_*` option in `settings` and not the secret, Nextcloud's Authelia login works today, and that is the config key `nextcloud-oidc-login` reads. So it is extracted with `jq -er` at unit start rather than copied into a new key. **If that assumption is wrong, `keycloak.service` fails at deploy with `nextcloud-secrets has no usable oidc_login_client_secret`** — fail-closed and loud, chosen deliberately over silently writing a wrong secret that would only surface at cutover. Keycloak serves nothing load-bearing yet, so deploy time is the cheapest place to find out. ## Two failure modes the issue named — and a third **1. The gating flows would have killed SSO — but not the way the issue expected.** The issue warned that an override flow omitting `auth-spnego` silently drops SSO. The real hazard is worse and is triggered by the obvious layout. `DefaultAuthenticationFlow.fillListsOfExecutions` sorts a CONDITIONAL subflow into the *required* list, then: ```java if (!requiredList.isEmpty() && !alternativeList.isEmpty()) { logger.warnf("REQUIRED and ALTERNATIVE elements at same level! Those alternative executions will be ignored: %s", alternativeIds); alternativeList.clear(); } ``` Putting the role gate beside `auth-cookie` / `auth-spnego` / `identity-provider-redirector` / forms discards all four. The conditional then evaluates with `context.getUser() == null`, `ConditionalRoleAuthenticator.matchCondition` returns `false`, the subflow is removed as disabled, and `processFlow` returns null with `successful == false` → `AuthenticationFlowError.INTERNAL_ERROR` on every request, for every client bound to that flow. Not a subtle SSO regression; a total outage, whose only trace is one WARN line. Each gating flow is therefore shaped as a top level of exactly two children: ``` browser-spnego <policy> topLevel ├── browser-spnego <policy> auth REQUIRED ← the four ALTERNATIVEs, mirroring browser-spnego │ └── browser-spnego <policy> forms ALTERNATIVE └── browser-spnego <policy> gate CONDITIONAL ← conditional-user-role (negate) + deny-access-authenticator ``` This also fixes the ordering that makes the gate meaningful: the wrapper must succeed before the gate is evaluated, so the user is set. A gate evaluated too early does not deny — it *disables itself*, i.e. **fails open**. Same reason `negate: "true"` is load-bearing: `matchCondition` returns `negate != user.hasRole(role)`, and the subflow runs only when the condition matches, so the deny fires exactly for users lacking the role. Verified against Keycloak 26.7.0 `DefaultAuthenticationFlow.java`, `ConditionalRoleAuthenticator*`, `DenyAccessAuthenticatorFactory`, and `RoleMapperModel.hasRole` (whose contract explicitly includes "this object belongs to a group G which is assigned the role R" — which is what makes group-assigned roles work at all). **2. `full.path` — as the issue said.** The `groups` client scope's `oidc-group-membership-mapper` sets `"full.path": "false"`, so the claim is `Administrators`, not `/Administrators`. `GroupMembershipMapper.useFullPath()` is `"true".equals(...)`, so the string form matters. `id.token.claim` is `"true"`, which is what Authelia's `claims_policies.default` existed to do for Grafana's `role_attribute_path`. **3. Not in the issue: Keycloak rejects unknown scopes.** Nextcloud, Grafana and FreeScout all send `scope=openid profile email groups`, and Keycloak has no built-in `groups` scope. `AuthorizationEndpointChecker.checkValidScope` → `TokenManager.isValidScope` returns false for any scope with no matching client scope → HTTP 400 `invalid_scope`, before the login page renders. So a `groups` client scope is created and assigned. Because `RepresentationToModel.updateClientScopes` treats the union of `defaultClientScopes` + `optionalClientScopes` as the *complete* desired set — it replaces, it does not merge — the built-ins are listed explicitly on every client. `acr` is deliberately left out: it is gated on the STEP_UP_AUTHENTICATION feature and nothing here reads an `acr` claim. ## The access matrix Three realm roles, assigned to the four LDAP groups the existing `group-ldap-mapper` federates, and one flow override per policy. Synapse was `one_factor` in Authelia (any authenticated user) and gets no override, so it keeps the realm's default `browser-spnego`. | Authelia policy | realm role | groups | clients | |---|---|---|---| | `admin-only` | `policy-admin-only` | Administrators | gitea, grafana, freescout | | `internal` | `policy-internal` | Administrators, Mitarbeiter, macher.solutions | nextcloud | | `immich` | `policy-immich` | immich | immich | | *(unrestricted)* | — | — | synapse | Roles rather than group conditions because a conditional subflow ANDs its conditions — it cannot express `internal`'s three-group OR. Roles are *declared* rather than discovered for a related reason: `matchCondition` resolves the role by name and returns `false` if it does not exist, which is the fail-open direction, so the role must not depend on an LDAP sync having happened first. Authelia set `token_endpoint_auth_method = "client_secret_post"` on freescout, synapse and immich. The issue asked for this to be verified rather than assumed: Keycloak needs **nothing** per client. `ClientIdAndSecretAuthenticator` reads `client_id`/`client_secret` from the POST body as well as the Basic header and advertises both methods. There *is* an opt-in restriction — the `client.secret.authentication.allowed.method` attribute — and the correct action is to not set it, which is what the realm file does. ## One deviation: `--import.managed.group=no-delete` The issue asked that `import.managed.*` stay `full`. Everything else does. Groups cannot, and the reason is specific rather than a general loosening. Every other importer that deletes is either state-gated or excludes built-ins. `ClientImportService.deleteClientsMissingInImport` filters on `KeycloakUtil.isDefaultClient` *and* `stateClients.contains(...)`; `RoleImportService.deleteRealmRolesMissingInImport` filters on `isDefaultRole` *and* remote state. `GroupImportService` references `StateService` **zero** times — its deletion pass removes every top-level group not named in the import, with no state gate, no built-in exclusion, and no way to recognise a federated group. Declaring four groups under `full` would delete every other LDAP-synced group on the first converge; the mapper recreates them at the next member login (`GroupLDAPStorageMapper.findKcGroupOrSyncFromLDAP`), and the next converge deletes them again. Perpetual churn, a new group UUID each cycle, and any hand-created Keycloak group silently gone. kcc's group deletion exists to clean up groups kcc created. Ours are always in the import, so under `full` it can only ever delete things it did not create. `no-delete` costs nothing and removes the whole class. Worth recording separately, since the ADR states otherwise: **`import.managed.client-scope` and `import.managed.scope-mapping` are not `full` by default at 6.4.0.** The `@DefaultValue("FULL")` annotations are overridden by the `application.properties` inside the jar, which sets both to `no-delete`. That is why adding a `clientScopes` block deletes nothing — and it is worth not passing `--import.managed.client-scope=full`, which would delete the `service_account` scope (created but never realm-assigned, so outside the exclusion set). ## Verification - **`scripts/test-configuration web-arm` passes.** The pre-commit hook ran on the commit: `:: web-arm OK`. (This container ships no Nix, so it ran against a `nix-portable` `nix-instantiate` — a real evaluation of `hosts/web-arm/configuration.nix` against the pinned `nixos-26.05`, producing `nixos-system-web-arm-26.05.7006.445d861c6d31.drv`.) - The realm file's diff is **541 insertions, 0 deletions** — the header, both original flows and the whole `components` block are byte-identical, checked by parsing both revisions and comparing. - A checker asserts, independently of the file's author: no flow anywhere mixes ALTERNATIVE with REQUIRED/CONDITIONAL at one level; each gating top level is `[REQUIRED auth, CONDITIONAL gate]` in priority order and its auth subflow reproduces `browser-spnego`'s executions exactly; every `flowAlias` resolves and every subflow is referenced exactly once; every authenticator config is referenced exactly once and names a declared realm role with `negate == "true"`; every client secret is a `${vault.…}` reference whose key matches a file `default.nix` actually materialises; and no `pbkdf2` string survives anywhere. - A second checker parses `authelia.nix` and compares: client set, every redirect URI character for character, policy→flow-override mapping, and which clients carry the `groups` scope. All match. **What has not been verified, and cannot be from here:** everything that needs a running Keycloak. The converge itself, the `nextcloud-secrets` field name, and the issue's step 4 (SSO surviving an override flow) are all post-deploy. Step 4 is argued from source above rather than demonstrated — I could not run it, and I am not claiming otherwise. ## After deploy 1. `systemctl status keycloak-config`; console shows six clients and four groups carrying the `policy-*` roles. 2. SPNEGO regression: 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, once per policy (`gitea`, `nextcloud`, `immich`), **ideally with an account outside the relevant group.** A gate that admits everyone is indistinguishable from a working one otherwise, and the two ways this can fail (a role name that does not resolve; a gate evaluated before the user is set) both fail *open*. 4. Step 3 against a gated client from the TGT session must complete with no password prompt. ## ADR `docs/adr/0022` records the consequence the issue asked for: Authelia stored client secrets as one-way pbkdf2 digests, Keycloak stores recoverable material. The vault route keeps our six out of the store, git and the database — but the distinction still holds for anything set through the console or rotated that way, and it is the argument for rotating all six once the migration settles.
Ports Authelia's six surviving OIDC clients into the cloonar realm, along
with an expression of its admin-only/internal/immich access matrix. Nothing
is cut over: every application still points at Authelia, and authelia.nix is
untouched. claude-mcp-forgejo is being retired and is not migrated.

Client secrets never appear in the Nix store. Keycloak 26.6.0+ resolves a
vault reference in a confidential client's secret, so the realm file carries
`${vault.<app>secret}` and the unit materialises the values into the file
vault it already uses for the LDAP bind. Three are read under a second name
from the secret the host already holds; nextcloud's is extracted from the
JSON blob it lives inside.

The access matrix becomes three realm roles mapped onto the four federated
LDAP groups, plus a per-client browser-flow override that denies anyone
lacking the role. Each override wraps browser-spnego's alternatives in a
REQUIRED subflow before the conditional gate: a CONDITIONAL sibling of
ALTERNATIVE executions makes Keycloak discard the alternatives outright,
which would take SPNEGO — and every other login path — with it.

managed.group drops to no-delete. It is the one entity type whose deletion
pass consults neither remote state nor the LDAP federation, so `full` would
delete every synced group the realm file does not name, on every converge.
Author
Owner

[autoland] verdict: pass

This was generated by AI while landing a PR.

PASS.

Signal relied on: PR checks report none — nothing vouched — so I ran the repo's own gate myself. scripts/test-configuration web-arm passes on head 90b084f (nix-instantiate against the pinned nixos-26.05; only pre-existing rename warnings). All six sops keys the module references exist in hosts/web-arm/secrets.yaml.

Load-bearing claims checked against source, not taken on trust:

  • keycloak-config-cli 6.4.0 ClientImportService.updateAuthenticationFlowBindingOverrides resolves authenticationFlowBindingOverrides by alias via authenticationFlowRepository.getByAlias(...).getId(), and the property is in propertiesWithDependencies so it is applied after the flows exist. A bad alias throws KeycloakRepositoryException. The realm file's alias form is correct and cannot fail open.
  • application.properties:44-45 sets import.managed.client-scope=no-delete and scope-mapping=no-delete, overriding the @DefaultValue("FULL") — the clientScopes block deletes nothing, as the PR states.
  • GroupImportService.deleteGroupsMissingInImport references StateService zero times and has no built-in exclusion — the --import.managed.group=no-delete deviation is justified.
  • application.properties:31-32: var-substitution.prefix=$( / suffix=), so ${vault.…} never collides.
  • Keycloak 26.7.0 OIDCClientSecretConfigWrapper.validateSecret(session, secret) resolves session.vault().getStringSecret(secret), reached from ClientIdAndSecretAuthenticator:154. Client-secret-via-vault works at the pinned version, and server_admin/topics/vault.adoc at that tag does list "OIDC client secret".
  • ClientIdAndSecretAuthenticator reads Basic (:78) and POST body (:98) and advertises both (:217-218) — no per-client token_endpoint_auth_method needed.
  • ConditionalRoleAuthenticatorFactory: condUserRole/negate are the right keys; negateOutput != user.hasRole(role) makes negate: "true" load-bearing as described.

Structure, parsed rather than eyeballed: browser-spnego and browser-spnego forms are byte-identical to main; each gating top level is exactly [REQUIRED auth, CONDITIONAL gate]; no flow mixes ALTERNATIVE with REQUIRED/CONDITIONAL at one level; each auth subflow reproduces browser-spnego's four alternatives. Parity against authelia.nix: six clients, redirect URIs character-for-character, policy→flow mapping, groups scope placement, synapse unrestricted, claude-mcp-forgejo excluded. No secret literal or pbkdf2 string anywhere in the realm file.

Conventions: Conventional Commits title, Closes #279, no Claude footers. Scope confined to three files; authelia.nix and mcp-forgejo.nix untouched.

Non-blocking, for the post-deploy watch: the oidc_login_client_secret field name inside nextcloud-secrets is still unverified (no age key available). It fails closed, but the failure takes keycloak.service down rather than just that one client — harmless while nothing is cut over, worth knowing before the deploy window. The PR discloses this.

[autoland] verdict: pass > *This was generated by AI while landing a PR.* **PASS.** **Signal relied on:** PR checks report `none` — nothing vouched — so I ran the repo's own gate myself. `scripts/test-configuration web-arm` passes on head `90b084f` (nix-instantiate against the pinned `nixos-26.05`; only pre-existing rename warnings). All six sops keys the module references exist in `hosts/web-arm/secrets.yaml`. **Load-bearing claims checked against source, not taken on trust:** - keycloak-config-cli 6.4.0 `ClientImportService.updateAuthenticationFlowBindingOverrides` resolves `authenticationFlowBindingOverrides` by **alias** via `authenticationFlowRepository.getByAlias(...).getId()`, and the property is in `propertiesWithDependencies` so it is applied after the flows exist. A bad alias throws `KeycloakRepositoryException`. The realm file's alias form is correct and cannot fail open. - `application.properties:44-45` sets `import.managed.client-scope=no-delete` and `scope-mapping=no-delete`, overriding the `@DefaultValue("FULL")` — the `clientScopes` block deletes nothing, as the PR states. - `GroupImportService.deleteGroupsMissingInImport` references `StateService` zero times and has no built-in exclusion — the `--import.managed.group=no-delete` deviation is justified. - `application.properties:31-32`: `var-substitution.prefix=$(` / `suffix=)`, so `${vault.…}` never collides. - Keycloak 26.7.0 `OIDCClientSecretConfigWrapper.validateSecret(session, secret)` resolves `session.vault().getStringSecret(secret)`, reached from `ClientIdAndSecretAuthenticator:154`. Client-secret-via-vault works at the pinned version, and `server_admin/topics/vault.adoc` at that tag does list "OIDC client secret". - `ClientIdAndSecretAuthenticator` reads Basic (`:78`) and POST body (`:98`) and advertises both (`:217-218`) — no per-client `token_endpoint_auth_method` needed. - `ConditionalRoleAuthenticatorFactory`: `condUserRole`/`negate` are the right keys; `negateOutput != user.hasRole(role)` makes `negate: "true"` load-bearing as described. **Structure, parsed rather than eyeballed:** `browser-spnego` and `browser-spnego forms` are byte-identical to `main`; each gating top level is exactly `[REQUIRED auth, CONDITIONAL gate]`; no flow mixes ALTERNATIVE with REQUIRED/CONDITIONAL at one level; each `auth` subflow reproduces `browser-spnego`'s four alternatives. Parity against `authelia.nix`: six clients, redirect URIs character-for-character, policy→flow mapping, `groups` scope placement, synapse unrestricted, `claude-mcp-forgejo` excluded. No secret literal or `pbkdf2` string anywhere in the realm file. **Conventions:** Conventional Commits title, `Closes #279`, no Claude footers. Scope confined to three files; `authelia.nix` and `mcp-forgejo.nix` untouched. **Non-blocking, for the post-deploy watch:** the `oidc_login_client_secret` field name inside `nextcloud-secrets` is still unverified (no age key available). It fails closed, but the failure takes `keycloak.service` down rather than just that one client — harmless while nothing is cut over, worth knowing before the deploy window. The PR discloses this.
Author
Owner

This was generated by AI while landing a PR.

The one non-blocking item in the approval is resolved. The human confirms nextcloud-secrets is exactly:

{
  "oidc_login_client_secret": "…"
}

So the field name materialiseSecrets extracts with jq -er '.oidc_login_client_secret' is correct, and the fail-closed branch will not fire at deploy. The PR's inference from hosts/web-arm/modules/nextcloud/default.nix — every other oidc_login_* option set in settings, the secret alone left to secretFile — was right.

Verdict stands at PASS, with no remaining concerns.

> *This was generated by AI while landing a PR.* The one non-blocking item in the approval is resolved. The human confirms `nextcloud-secrets` is exactly: ```json { "oidc_login_client_secret": "…" } ``` So the field name `materialiseSecrets` extracts with `jq -er '.oidc_login_client_secret'` is correct, and the fail-closed branch will not fire at deploy. The PR's inference from `hosts/web-arm/modules/nextcloud/default.nix` — every other `oidc_login_*` option set in `settings`, the secret alone left to `secretFile` — was right. Verdict stands at PASS, with no remaining concerns.
Sign in to join this conversation.
No reviewers
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!281
No description provided.