--- date: 2026-10-17 branch: feat/keycloak-passkeys title: Passkeys declared for the homelab realm — and the realm import will not apply them --- ## What changed `platform/keycloak/keycloak-realm-import.yaml` now declares passkey (WebAuthn Passwordless) login for the `homelab` realm, in two parts: 1. **The WebAuthn Passwordless policy.** Ten `webAuthnPolicyPasswordless*` fields on the realm: RP ID `${BASE_DOMAIN}`, RP entity name `Homelab SSO`, signature algorithms `[ES256, RS256]`, `requireResidentKey: "Yes"`, `userVerificationRequirement: required`, `authenticatorAttachment: "not specified"`, `attestationConveyancePreference: none`, `acceptableAaguids: []`, `createTimeout: 300`, `avoidSameAuthenticatorRegister: true`. Deliberately the **passwordless** family only; the two-factor `webAuthnPolicy*` family is left at Keycloak's defaults because nothing in this realm uses `webauthn-authenticator`. 2. **The browser flow.** `browser-mfa` keeps its alias and stays bound as `browserFlow`; its interior is restructured so a passkey is an **ALTERNATIVE** beside the password form: ``` browser-mfa (top level, unchanged) browser-mfa-forms ALTERNATIVE auth-username-form REQUIRED <- was auth-username-password-form browser-mfa-passkey-or-password REQUIRED <- NEW sub-flow webauthn-authenticator-passwordless ALTERNATIVE <- NEW: the passkey browser-mfa-password-and-otp ALTERNATIVE <- NEW sub-flow auth-password-form REQUIRED <- was half of the combined form browser-mfa-conditional-otp CONDITIONAL (unchanged, relocated intact) ``` The design decisions, with their reasoning, are written into the manifest comments in this repo's usual density. The ones that matter: * **Additive, not replacing. Enrolment optional, not forced.** This IdP gates Grafana, Gitea, Nextcloud, Headlamp, Semaphore, PiHole, Crafty, the \*arr stack *and* `kubectl` via the `kubernetes` OIDC client. A mandatory passkey flow that misfires is a total lock-out from the platform and the cluster API at once, leaving only the Keycloak bootstrap admin and the break-glass cert kubeconfig. The gain from forcing it (one human stops typing a password) does not price that risk. * **No `requiredActions` block was added, on purpose.** Declaring one would have been the obvious way to add `webauthn-register-passwordless`, and it would have been actively dangerous — see "What I verified". * **RP ID is `${BASE_DOMAIN}`, not `keycloak.${BASE_DOMAIN}`.** A passkey's RP ID is fixed at enrolment and can never be re-keyed, so it must not be bound to a name that can move. This realm already declares an `auth.${BASE_DOMAIN}` redirect URI, and this repo already owns a reconciler built because a hostname move silently failed to reach the realm. Residual risk, unavoidable: changing `BASE_DOMAIN` itself invalidates every enrolled passkey. * **A passkey login is not also asked for TOTP.** `browser-mfa-conditional-otp` hangs off the password branch only. The justification is `userVerificationRequirement: required` — the ceremony already proves possession plus a verified human, phishing-resistantly and origin-bound. Stacking a replayable shared secret on top of that buys friction. The manifest names the exact one-line edit to reverse this decision. * **Login becomes two screens** (username, then password) for every user, passkey or not, because the server must know who is logging in before it can decide whether a passkey exists. Accepted cost; it is the enabling change. * **Usernameless login deliberately not used**, although Keycloak 26.0.6 supports it — see "What I verified" for the measured reason it would have made every login open on a passkey prompt. `platform/keycloak/README.md` gains a `## Passkeys` section: the decision table, the fact that this does not reach the live realm, the two operator paths that would apply it, and the four-step browser verification nobody has run. No secret, key, credential or AAGUID was minted or committed. No file under `clusters/**`, `conformance/**`, `ci/**`, `platform/kyverno/**`, `**/rbac.yaml` or `**/*.sops.yaml` was touched, so this should not trip `ci/approval-policy.sh`'s sensitive-path review requirement. ## What I verified **The live realm import has already completed and will never re-run.** Measured on prod, 2026-10-17: ``` $ kubectl --context k3s-prod -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}' {"conditions":[{"message":"","status":"True","type":"Done"}, {"message":"","status":"False","type":"Started"}, {"message":"","status":"False","type":"HasErrors"}]} ``` `Done=True` / `Started=False`, exactly the one-shot state `platform/keycloak/README.md` already documents for client secrets and client URLs. **So the change in this PR has zero effect on how anyone logs in today**, and the client-secret/client-URL reconciler does not and cannot close the gap: its hard endpoint allow-list makes `/authentication` unreachable by design, and realm-level policy fields are not client fields. **The CRD accepts every field I used.** Measured against the live CRD (not the upstream docs): `kubectl --context k3s-prod get crd keycloakrealmimports.k8s.keycloak.org` lists all twelve `webAuthnPolicyPasswordless*` properties with the types I wrote (`RequireResidentKey`, `RpEntityName`, `RpId`, `UserVerificationRequirement`, `AttestationConveyancePreference`, `AuthenticatorAttachment` as `string`; `SignatureAlgorithms`, `AcceptableAaguids`, `ExtraOrigins` as `array/string`; `CreateTimeout` as `integer`; `AvoidSameAuthenticatorRegister` as `boolean`), plus `authenticationFlows`, `authenticatorConfig` and `requiredActions`. The `spec.realm` schema has **no** `x-kubernetes-preserve-unknown-fields`, so a misspelled field would have been pruned silently rather than rejected — which is why this was checked rather than assumed. **Running Keycloak is 26.0.6.** `quay.io/keycloak/keycloak:26.0.6` on `keycloak-0`/`keycloak-1`, operator `quay.io/keycloak/keycloak-operator:26.0.6`. Every source claim below is read from the `26.0.6` tag, not from memory or from docs. **Every provider ID is quoted from upstream source, not guessed.** | String I used | Where it is defined at tag 26.0.6 | |---|---| | `webauthn-authenticator-passwordless` | `WebAuthnPasswordlessAuthenticatorFactory.PROVIDER_ID`, display "WebAuthn Passwordless Authenticator" | | `auth-username-form` | `UsernameFormFactory.PROVIDER_ID`, display "Username Form" | | `auth-password-form` | `PasswordFormFactory.PROVIDER_ID`, display "Password Form" | | `webauthn-register-passwordless` | `WebAuthnPasswordlessRegisterFactory.PROVIDER_ID` (referenced in comments only) | **`requiredActions` is REPLACE, not merge — measured, and it is why I did not declare one.** `model/storage-private/.../DefaultExportImportManager.java` lines 327-338: ```java if (rep.getRequiredActions() != null) { for (RequiredActionProviderRepresentation action : rep.getRequiredActions()) { ... } DefaultRequiredActions.addDeleteAccountAction(newRealm); } else { DefaultRequiredActions.addActions(newRealm); } ``` Declaring one required action therefore skips the entire default set. `RealmManager` is no backstop: `setupRequiredActions` is `if (realm.getRequiredActionProvidersStream().count() == 0)`, and the count is already non-zero by then (`RepresentationToModel.importRealm` runs at `RealmManager.java:585`, the backstop at `:612`). A realm built that way would have had the passkey action plus `delete_account` and nothing else — **no `CONFIGURE_TOTP`**, which would leave `auth-otp-form` demanding a TOTP that no platform-admin could ever enrol: a hard lock-out delivered by a change whose purpose was to *add* a login method. **And declaring it would have bought nothing.** `DefaultRequiredActions.addWebAuthnPasswordlessRegisterAction` registers `webauthn-register-passwordless` with `setEnabled(true)`, `setDefaultAction(false)`, priority 80, gated on `Profile.Feature.WEB_AUTHN` — which is `Type.DEFAULT` in `common/.../Profile.java:74`, i.e. on unless explicitly disabled. `enabled=true` / `defaultAction=false` *is* the additive-and-optional posture this change wants. **`authenticationFlows` has the opposite semantics, which is why declaring flows is safe.** `importAuthenticationFlows` adds the declared flows, then backfills each unbound binding (browser, registration, direct grant, reset credentials, client auth, first broker login, docker auth) from the built-in defaults individually (`DefaultExportImportManager.java` ~1320-1410). **The graceful degradation to password is measured from the flow engine, not assumed.** For a user with no passkey: `WebAuthnPasswordlessAuthenticator.requiresUser()` returns `false`, so `DefaultAuthenticationFlow.processSingleFlowExecutionModel` takes the `else` branch (`daf:456-462`), whose four conditions all hold — a user is set (from `auth-username-form`), `configuredFor` is false, `WebAuthnPasswordlessAuthenticatorFactory.isUserSetupAllowed()` is `false`, and `WebAuthnAuthenticator implements Authenticator, CredentialValidator<...>` — so it raises `CREDENTIAL_SETUP_REQUIRED`. The ALTERNATIVE loop (`daf:289-302`) catches exactly that, marks the execution `ATTEMPTED` and proceeds to the next alternative, with the upstream comment *"the user, since he has alternatives, should be able to go to another alternative and continue"*. Hence: no passkey, no passkey screen, straight to the password form. **Why usernameless was rejected, measured.** The `configuredFor` guard sits inside the `requiresUser()` branch. With `requiresUser()` false and no user yet in context, that guard is skipped and the engine just calls `authenticate(context)` — so a passkey execution placed before the username form is offered to *everyone*, including users who will never own one. **The policy sentinels are read from the shipped code, and one of my initial assumptions was wrong.** `Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED = "not specified"` is the realm-representation sentinel — but `WebAuthnConstants.OPTION_NOT_SPECIFIED = ""` (empty string) is the WebAuthn-API-level one. I checked both because they disagree. The login theme's `webauthnRegister.js` at 26.0.6 settles the behaviour: ```js if (input.requireResidentKey !== 'not specified') { if (input.requireResidentKey === 'Yes') { authenticatorSelection.requireResidentKey = true; } else { authenticatorSelection.requireResidentKey = false; } } ... if (input.createTimeout !== 0) { publicKey.timeout = input.createTimeout * 1000; } rp: {id: input.rpId, name: input.rpEntityName} ``` So `"Yes"` is a literal string compare (any other non-sentinel value means *false*), `"not specified"` is compared literally for four fields, and `createTimeout` is **seconds** with `0` meaning "send no timeout". It also shows 26.0.6 emits only the legacy `requireResidentKey` member, never `residentKey: "required"` — the client maps that per WebAuthn L2, but the policy cannot express `residentKey: "preferred"` at all. `[ES256, RS256]` is exactly `WebAuthnPolicy.DEFAULT_POLICY` / `Constants.DEFAULT_WEBAUTHN_POLICY_SIGNATURE_ALGORITHMS` — declared explicitly, not changed. `getWebAuthnPolicyPasswordless` was read field by field to confirm `[]` for `acceptableAaguids` means "no restriction" and that `RpEntityName` must be non-empty or the **update** path (`DefaultExportImportManager.java:842`) drops the whole policy silently. **The manifest parses and renders.** `yaml.safe_load` round-trips the file and the flow graph reads back exactly as designed (five flows, the executions and priorities above, `browserFlow: browser-mfa`, `requiredActions` absent). `python3 ci/render-keycloak-client-urls.py --check` → `client-urls.yaml is in sync`, rc 0 (no client URL changed, so no regeneration was needed). `yamllint -c ci/yamllint.yaml platform/keycloak/keycloak-realm-import.yaml` → rc 0; no line exceeds the 160-column ceiling. **`browser-mfa` keeps its alias deliberately.** `conformance/tests/l2-behavioral/keycloak-config-as-code` asserts `kubectl get keycloakrealmimport homelab -o jsonpath={.spec.realm.browserFlow} | grep -q 'browser-mfa'`. Renaming the top-level flow would have failed that test. **Envsubst safety.** `platform-config`'s Flux Kustomization declares `postBuild.substituteFrom: cluster-vars` (`clusters/base/platform-config.yaml:26-29`), so this file is substituted. The only brace token I added is `${BASE_DOMAIN}`, which is declared at `clusters/base/cluster-vars.yaml:42` (`home.keithjackson.dev`). Every comment I wrote avoids braces entirely — the RP-ID discussion says `keycloak.` with angle brackets precisely so an unparseable token cannot fail the whole Kustomization. **Gate result: rc 3, zero failures, `gitea-settings` the only stage the gate counts as not-run** — the documented honest best local outcome. Run in the pinned toolbox from this worktree with all three §3 workarounds (`DEV_KUBECONFIG=/dev/null`, plus an `ENGINE` shim bind-mounting the main checkout's `.git` and the sibling spec repo with `SPEC_REPO=/home-network-k3s`): ``` [ ok ] stage spec-immutability: pass <- NOT skipped; both shim mounts worked, R-TEST-06 covered [ ok ] stage decisions-log: pass <- archive frozen, 48 entries well-formed [ ok ] stage lint: pass [ ok ] stage l0: pass <- incl. flux-postbuild-substitution + keycloak-client-secret-reconciler [ ok ] stage render-golden: pass [ ok ] stage l1: pass [ ok ] stage approval-policy: pass <- no sensitive path touched, so no non-author review needed [skip] stage gitea-settings: SKIPPED <- needs SETTINGS_API_TOKEN, CI-only; expected [skip] stage conformance: SKIPPED <- cluster-gated L2/L3; expected MERGE GATE: UNDETERMINED — Stages that did not run: gitea-settings ``` No lint finding and no check failure names either file I changed. Touched requirements resolved to `R-IDP-01 R-IDP-02 R-IDP-03 R-IDP-04 R-IDP-05 R-IDP-06`; coverage 51/51, 0 gaps. **A note on how that number was obtained, because the first attempt was worthless.** My first gate run returned the same rc 3 and the same seven passes — against a tree that did not contain this change at all. The gate's `tracked_files()` reads **git**, and my edits were still uncommitted, so every stage gated `origin/main`. The tell was in the log and I nearly missed it: the touched-requirement subset reported UNDETERMINED with *"check out the feature branch you mean to gate (not the base branch)"*, and `decisions-log` counted **47** entries rather than 48. **A green gate run on an uncommitted worktree is a gate run on the base branch.** The numbers above are from the second run, after committing and rebasing. **The base moved under me mid-run.** `origin/main` advanced from `9a41a8e` to `1a0b208` (PR #70, oauth2-proxy OIDC startup race) while I was working. This branch is rebased onto `1a0b208`, so the gate above ran on the base CI will actually merge into. The rebase was clean — PR #70 touched `platform/oauth2-proxy/helmrelease.yaml` and its own decisions entry, disjoint from these files. ## What I did NOT verify **This change is unapplied. It changes nothing about the live realm, and I did not attempt to make it.** `Done=True` / `Started=False` was measured, not inferred, and I ran no mutating `kubectl`, no `flux reconcile`, no admin-API call and no `kcadm`. Whether the operator's eventual admin-console reconstruction of this flow matches what is declared here is, by construction, unverifiable from git. **I never enrolled a passkey and never pointed a browser at any of this.** Nothing here has been exercised by a real WebAuthn ceremony on any device. Specifically unverified: * **That the RP ID works.** `home.keithjackson.dev` is a registrable-domain suffix of `keycloak.home.keithjackson.dev`, so it *should* satisfy the browser's check — but I verified that by reading the WebAuthn rule, not by watching a browser accept it. A wrong RP ID fails **silently, in the browser, before Keycloak is involved**, with no server-side log line. If enrolment produces no OS prompt and no error, this is the first suspect. * **That `"not specified"` is the right literal for `authenticatorAttachment` in a realm import.** I traced it to `Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED` and to the theme's string compare, but I have not seen a realm import accept it. Omitting the field would default to the same value; if an import ever rejects the literal, drop the line. * **That `createTimeout: 300` behaves.** Seconds-vs-milliseconds is read from the theme JS. The claim that 300 is long enough for the cross-device QR/hybrid flow, and that Chrome clamps rather than rejects it, is reasoning about browser behaviour, not a measurement. * **Whether a passkey user can still reach the password form.** I argued it from `createAuthenticationSelectionList` producing a "Try another way" option, and I noted that for a user holding both credentials the *first* screen is chosen by the user's own credential ordering rather than by my priorities. Neither was observed. * **The two-screen login regression for ordinary users.** `auth-username-form` + `auth-password-form` replacing `auth-username-password-form` is a visible change for every user including non-passkey family accounts, and nobody has logged in through it. * **That conditional OTP for platform-admins still fires on the password branch.** The sub-flow and its `conditional-otp-platform-admin` config are byte-identical and merely relocated one level deeper, but "identical YAML" is not "still enforced at runtime". This is the highest-value regression check and it is untested. The previous breakage of this exact step (`condUserRole` vs a non-existent property key) 403'd every platform-admin login. * **That a platform-admin authenticating by passkey is not challenged for TOTP.** That is the intended behaviour of the shape I wrote; it is unobserved, and it is also a deliberate relaxation of R-IDP-04 as previously implemented, which the operator may want to reverse. **Whether the realm import reconciles into an existing realm: measured as NO for the completed one-shot, but I did not test a re-import.** I did not delete and recreate the `KeycloakRealmImport`, so I cannot say what a re-run would do to the existing `homelab` realm — whether it would merge the new policy and flows, duplicate flow aliases, or fail. The 2026-09-06 total SSO outage documented in `platform/keycloak/README.md` is the reason I did not experiment. **Treat the re-import path in the README as untested advice, not a runbook.** **Nothing was proved about the live realm's current state.** I did not read the realm's existing required actions, its existing WebAuthn policies, or its live flow executions — doing so needs the admin credential and I did not decrypt or use it. So I cannot confirm that the live realm's required-action set actually contains `webauthn-register-passwordless` today; I can only show that Keycloak 26.0.6's defaults register it and that this repo's import never declared any required actions to suppress them. **That inference has not been checked against the realm.** **L2/L3 conformance did not run** (no cluster from the gate container; `vars.CLUSTER_AVAILABLE` is unset in CI anyway), so the existing `keycloak-config-as-code` behavioural test did not re-execute against this change. I reasoned about its assertion by reading it. **No new conformance test was added.** Nothing in `conformance/` asserts the passkey policy or the new flow shape, so a future edit can silently remove either and no gate stage will notice. That is a real gap this PR leaves open; closing it would touch `conformance/**`, which is a sensitive path needing non-author review. **The quoted gate run predates this file's own gate-result paragraph.** It gated commit `207f5db`; the gate numbers and the rebase note above were then written into this entry, so the pushed tree differs from the gated one by this handoff's prose only — no manifest, no check, no generated file. `decisions-log` is the only stage that reads this file, and it validates structure (frontmatter, the three headings, non-stub bodies), which is unchanged. I did not re-run the full gate for a prose edit; CI will gate the pushed commit. **`gitea-settings` did not run locally** — it needs `SETTINGS_API_TOKEN`, which exists only in CI. I did not re-measure branch protection, the runner, or `required_approvals` against the Gitea API this session; the claims in `CLAUDE.md` §2 are taken on trust here and are explicitly flagged there as needing re-measurement.