feat(web-arm): define the six OIDC clients and the authorization model in Keycloak #281
No reviewers
Labels
No labels
bug
enhancement
in-progress
needs-info
needs-triage
p0
ready-for-agent
ready-for-human
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/nixos!281
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "afk/279"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #279
M4's first half. The six surviving OIDC clients and Authelia's access matrix now exist in the
cloonarrealm. Nothing is cut over — every application still points at Authelia,authelia.nixis byte-for-byte unchanged, andclaude-mcp-forgejoand 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'ssecret— 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.validateSecretnow wraps the client and resolvessession.vault().getStringSecret(secret)at authentication time, anddocs/.../vault.adocat that tag lists "OIDC client secret" alongside the SMTP password, LDAP bind credential and IdP secret. Greppingsession.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.materialiseSecretswrites 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=falsestays as it is, and #277's caution turns out to have been unnecessary but harmless: keycloak-config-cli 6.4.0'sapplication.propertiessetsimport.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, mirroringkeycloak-ldap-bind; no value is duplicated into a new sops key:gitea-oidc-client-secretfreescout-oidc-client-secretsynapse-oidc-client-secretmas-authelia-client-secret; rotation coupling noted in a commentgrafana-oauth-secretimmich-oidc-client-secretoidc_login_client_secretinsidenextcloud-secretsNextcloud, honestly:
nextcloud-secretsis a single opaque sops string (the NixOSservices.nextcloud.secretFileJSON blob), sosops.secrets.…keycannot 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 namedoidc_login_client_secretis thathosts/web-arm/modules/nextcloud/default.nixsets every otheroidc_login_*option insettingsand not the secret, Nextcloud's Authelia login works today, and that is the config keynextcloud-oidc-loginreads. So it is extracted withjq -erat unit start rather than copied into a new key. If that assumption is wrong,keycloak.servicefails at deploy withnextcloud-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-spnegosilently drops SSO. The real hazard is worse and is triggered by the obvious layout.DefaultAuthenticationFlow.fillListsOfExecutionssorts a CONDITIONAL subflow into the required list, then:Putting the role gate beside
auth-cookie/auth-spnego/identity-provider-redirector/ forms discards all four. The conditional then evaluates withcontext.getUser() == null,ConditionalRoleAuthenticator.matchConditionreturnsfalse, the subflow is removed as disabled, andprocessFlowreturns null withsuccessful == false→AuthenticationFlowError.INTERNAL_ERRORon 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:
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:matchConditionreturnsnegate != 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, andRoleMapperModel.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. Thegroupsclient scope'soidc-group-membership-mappersets"full.path": "false", so the claim isAdministrators, not/Administrators.GroupMembershipMapper.useFullPath()is"true".equals(...), so the string form matters.id.token.claimis"true", which is what Authelia'sclaims_policies.defaultexisted to do for Grafana'srole_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-ingroupsscope.AuthorizationEndpointChecker.checkValidScope→TokenManager.isValidScopereturns false for any scope with no matching client scope → HTTP 400invalid_scope, before the login page renders. So agroupsclient scope is created and assigned. BecauseRepresentationToModel.updateClientScopestreats the union ofdefaultClientScopes+optionalClientScopesas the complete desired set — it replaces, it does not merge — the built-ins are listed explicitly on every client.acris deliberately left out: it is gated on the STEP_UP_AUTHENTICATION feature and nothing here reads anacrclaim.The access matrix
Three realm roles, assigned to the four LDAP groups the existing
group-ldap-mapperfederates, and one flow override per policy. Synapse wasone_factorin Authelia (any authenticated user) and gets no override, so it keeps the realm's defaultbrowser-spnego.admin-onlypolicy-admin-onlyinternalpolicy-internalimmichpolicy-immichRoles 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:matchConditionresolves the role by name and returnsfalseif 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.ClientIdAndSecretAuthenticatorreadsclient_id/client_secretfrom the POST body as well as the Basic header and advertises both methods. There is an opt-in restriction — theclient.secret.authentication.allowed.methodattribute — and the correct action is to not set it, which is what the realm file does.One deviation:
--import.managed.group=no-deleteThe issue asked that
import.managed.*stayfull. 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.deleteClientsMissingInImportfilters onKeycloakUtil.isDefaultClientandstateClients.contains(...);RoleImportService.deleteRealmRolesMissingInImportfilters onisDefaultRoleand remote state.GroupImportServicereferencesStateServicezero 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 underfullwould 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
fullit can only ever delete things it did not create.no-deletecosts nothing and removes the whole class.Worth recording separately, since the ADR states otherwise:
import.managed.client-scopeandimport.managed.scope-mappingare notfullby default at 6.4.0. The@DefaultValue("FULL")annotations are overridden by theapplication.propertiesinside the jar, which sets both tono-delete. That is why adding aclientScopesblock deletes nothing — and it is worth not passing--import.managed.client-scope=full, which would delete theservice_accountscope (created but never realm-assigned, so outside the exclusion set).Verification
scripts/test-configuration web-armpasses. The pre-commit hook ran on the commit::: web-arm OK. (This container ships no Nix, so it ran against anix-portablenix-instantiate— a real evaluation ofhosts/web-arm/configuration.nixagainst the pinnednixos-26.05, producingnixos-system-web-arm-26.05.7006.445d861c6d31.drv.)componentsblock are byte-identical, checked by parsing both revisions and comparing.[REQUIRED auth, CONDITIONAL gate]in priority order and its auth subflow reproducesbrowser-spnego's executions exactly; everyflowAliasresolves and every subflow is referenced exactly once; every authenticator config is referenced exactly once and names a declared realm role withnegate == "true"; every client secret is a${vault.…}reference whose key matches a filedefault.nixactually materialises; and nopbkdf2string survives anywhere.authelia.nixand compares: client set, every redirect URI character for character, policy→flow-override mapping, and which clients carry thegroupsscope. All match.What has not been verified, and cannot be from here: everything that needs a running Keycloak. The converge itself, the
nextcloud-secretsfield 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
systemctl status keycloak-config; console shows six clients and four groups carrying thepolicy-*roles.dominik.polakovicssession still reacheshttps://sso.cloonar.com/realms/cloonar/accountwith no prompt.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.ADR
docs/adr/0022records 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.[autoland] verdict: pass
PASS.
Signal relied on: PR checks report
none— nothing vouched — so I ran the repo's own gate myself.scripts/test-configuration web-armpasses on head90b084f(nix-instantiate against the pinnednixos-26.05; only pre-existing rename warnings). All six sops keys the module references exist inhosts/web-arm/secrets.yaml.Load-bearing claims checked against source, not taken on trust:
ClientImportService.updateAuthenticationFlowBindingOverridesresolvesauthenticationFlowBindingOverridesby alias viaauthenticationFlowRepository.getByAlias(...).getId(), and the property is inpropertiesWithDependenciesso it is applied after the flows exist. A bad alias throwsKeycloakRepositoryException. The realm file's alias form is correct and cannot fail open.application.properties:44-45setsimport.managed.client-scope=no-deleteandscope-mapping=no-delete, overriding the@DefaultValue("FULL")— theclientScopesblock deletes nothing, as the PR states.GroupImportService.deleteGroupsMissingInImportreferencesStateServicezero times and has no built-in exclusion — the--import.managed.group=no-deletedeviation is justified.application.properties:31-32:var-substitution.prefix=$(/suffix=), so${vault.…}never collides.OIDCClientSecretConfigWrapper.validateSecret(session, secret)resolvessession.vault().getStringSecret(secret), reached fromClientIdAndSecretAuthenticator:154. Client-secret-via-vault works at the pinned version, andserver_admin/topics/vault.adocat that tag does list "OIDC client secret".ClientIdAndSecretAuthenticatorreads Basic (:78) and POST body (:98) and advertises both (:217-218) — no per-clienttoken_endpoint_auth_methodneeded.ConditionalRoleAuthenticatorFactory:condUserRole/negateare the right keys;negateOutput != user.hasRole(role)makesnegate: "true"load-bearing as described.Structure, parsed rather than eyeballed:
browser-spnegoandbrowser-spnego formsare byte-identical tomain; each gating top level is exactly[REQUIRED auth, CONDITIONAL gate]; no flow mixes ALTERNATIVE with REQUIRED/CONDITIONAL at one level; eachauthsubflow reproducesbrowser-spnego's four alternatives. Parity againstauthelia.nix: six clients, redirect URIs character-for-character, policy→flow mapping,groupsscope placement, synapse unrestricted,claude-mcp-forgejoexcluded. No secret literal orpbkdf2string anywhere in the realm file.Conventions: Conventional Commits title,
Closes #279, no Claude footers. Scope confined to three files;authelia.nixandmcp-forgejo.nixuntouched.Non-blocking, for the post-deploy watch: the
oidc_login_client_secretfield name insidenextcloud-secretsis still unverified (no age key available). It fails closed, but the failure takeskeycloak.servicedown rather than just that one client — harmless while nothing is cut over, worth knowing before the deploy window. The PR discloses this.The one non-blocking item in the approval is resolved. The human confirms
nextcloud-secretsis exactly:So the field name
materialiseSecretsextracts withjq -er '.oidc_login_client_secret'is correct, and the fail-closed branch will not fire at deploy. The PR's inference fromhosts/web-arm/modules/nextcloud/default.nix— every otheroidc_login_*option set insettings, the secret alone left tosecretFile— was right.Verdict stands at PASS, with no remaining concerns.