From 16894b7b734a64d60fe763eee2bcb02120e78172 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Sat, 17 Oct 2026 23:21:02 -0500 Subject: [PATCH] feat(keycloak): declare passkey (WebAuthn passwordless) login for the homelab realm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the realm's WebAuthn Passwordless policy and restructures the browser-mfa flow so a passkey is an ALTERNATIVE beside the password form. Additive and optional by design: this IdP gates every SSO app plus kubectl, so a mandatory passkey flow that misfires is a total lock-out. Enrolment is not forced. Policy: RP ID is ${BASE_DOMAIN} (not the keycloak host) because a passkey's RP ID is fixed at enrolment and must not be bound to a name that can move; resident key required (a non-discoverable credential is a second factor, not a passkey); user verification required (without it, possession alone is a complete login). Flow: auth-username-password-form is split into auth-username-form + auth-password-form, because the server must know who is logging in before it can decide whether a passkey exists. Conditional OTP for platform-admins is relocated intact onto the password branch only — a UV-required passkey is already two phishing-resistant factors. No requiredActions block is declared, deliberately: Keycloak 26.0.6 treats requiredActions as REPLACE not merge, so declaring one would delete the default set including CONFIGURE_TOTP and lock every platform-admin out of the OTP step this flow still enforces. webauthn-register-passwordless is already registered enabled-but-not-default by Keycloak's own defaults. UNAPPLIED: the realm import is a one-shot resting at Done=True/Started=False, so this reaches the live realm never. See the handoff entry and the README section. Co-Authored-By: Claude Opus 5 (1M context) --- decisions/2026-10-17-keycloak-passkeys.md | 311 +++++++++++++++++++ platform/keycloak/README.md | 68 ++++ platform/keycloak/keycloak-realm-import.yaml | 293 ++++++++++++++++- 3 files changed, 664 insertions(+), 8 deletions(-) create mode 100644 decisions/2026-10-17-keycloak-passkeys.md diff --git a/decisions/2026-10-17-keycloak-passkeys.md b/decisions/2026-10-17-keycloak-passkeys.md new file mode 100644 index 0000000..32f45ae --- /dev/null +++ b/decisions/2026-10-17-keycloak-passkeys.md @@ -0,0 +1,311 @@ +--- +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. diff --git a/platform/keycloak/README.md b/platform/keycloak/README.md index 53cd00e..a17424f 100644 --- a/platform/keycloak/README.md +++ b/platform/keycloak/README.md @@ -154,6 +154,74 @@ NEW client, a new protocol mapper or a changed scope still needs the realm impor (it will not on its own) or a deliberate admin-API/`kcadm` change. `Done=True` is never evidence that a realm edit landed. +## Passkeys (WebAuthn Passwordless) — DECLARED IN GIT, NOT YET IN THE REALM (2026-10-17) + +`keycloak-realm-import.yaml` now declares passkey login: the realm's **WebAuthn Passwordless** +policy (`webAuthnPolicyPasswordless*`), and a `browser-mfa` flow that offers a passkey as an +**ALTERNATIVE** beside the password form. Design decisions and their reasoning are in the manifest +comments, not repeated here. The headline choices: + +| Decision | Value | One-line reason | +|---|---|---| +| Additive or replacing? | **Additive** — passkey *or* password | A misfiring mandatory flow locks the single operator out of every SSO app *and* kubectl | +| Enrolment | **Optional**, never a default action | Same reason; `webauthn-register-passwordless` is already enabled-not-forced by default | +| Relying-party ID | `${BASE_DOMAIN}` (not `keycloak.${BASE_DOMAIN}`) | An unre-keyable credential must not be bound to a hostname that can move | +| User verification | `required` | Without it, mere possession of an unlocked device is a complete login | +| Resident key | `Yes` | A non-discoverable credential is a second factor, not a passkey | +| OTP after a passkey? | **No** — conditional OTP stays on the password branch | A UV-required passkey is already two factors, and a phishing-resistant pair at that | + +### This change does NOT reach the live realm. At all. + +Same one-shot failure this file already documents twice above, third field over. Measured on prod +2026-10-17: + +``` +$ kubectl --context k3s-prod -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}' +{"conditions":[{"status":"True","type":"Done"},{"status":"False","type":"Started"}, + {"status":"False","type":"HasErrors"}]} +``` + +`Done=True` / `Started=False` — the import has completed and **will not re-run**, so a policy block +and three new sub-flows added to this file change nothing about how anyone logs in. The +client-secret/client-URL reconciler does **not** close this gap and was never meant to: its hard +endpoint allow-list makes `/authentication` unreachable **by design** (see the table above), and +realm-level policy fields are not client fields. This is exactly the "a NEW client, a new protocol +mapper or a changed scope still needs the realm import to actually re-run" case named above. + +**So the manifest is the declared source of truth and a DR-rebuild input, and nothing more, until +an operator applies it by hand.** Two paths, both operator actions: + +1. **Admin console (expected path).** *Authentication → Policies → WebAuthn Passwordless Policy* + for the policy block, then *Authentication → Flows → browser-mfa* to rebuild the executions to + match the manifest. The flow edit is the fiddly half: split the existing Username Password Form + into Username Form + Password Form and add the two sub-flows exactly as declared. +2. **A deliberate re-import.** Deleting and recreating the `KeycloakRealmImport` re-runs it against + the **existing** realm. Do not reach for this casually — it is the operation whose failure mode + is the 2026-09-06 total SSO outage documented above, and it has not been rehearsed here. + +Either way the realm is then configured by hand and git is only the record of intent, which is a +live exception to R-IDP-03 (config as code) — not a new one, but one more instance of it. + +### Verifying it actually works (nobody has, as of 2026-10-17) + +No passkey has been enrolled and no browser has been pointed at this. The checks that would prove +it, in order: + +```sh +# 1. the policy is live (RP ID must read as the base domain, NOT the keycloak host, NOT a URL) +# Authentication -> Policies -> WebAuthn Passwordless Policy, in the admin console. +# 2. enrol: account console (https://keycloak.${BASE_DOMAIN}/realms/homelab/account) -> Signing in +# -> Passwordless -> set up. A browser/OS passkey prompt must appear; if it does not, the RP ID +# is wrong for the origin and NOTHING will say so. +# 3. log in: a fresh private window to any SSO-gated app. Expect username screen, then a passkey +# prompt. A platform-admin must NOT be asked for TOTP on this path. +# 4. regression (the important one): a user with NO passkey must still get the password screen, +# and a platform-admin must still be asked for TOTP there. +``` + +A failed passkey ceremony is usually silent in the server log — the browser refuses before Keycloak +is involved — so step 2 failing with no log entry is the expected signature of a wrong RP ID. + ## Exceptions that keep native auth (do not force SSO) — ADR-0014 Plex/Seerr (Plex auth), Minecraft (Mojang/Microsoft), UniFi (native). diff --git a/platform/keycloak/keycloak-realm-import.yaml b/platform/keycloak/keycloak-realm-import.yaml index cba2312..d666556 100644 --- a/platform/keycloak/keycloak-realm-import.yaml +++ b/platform/keycloak/keycloak-realm-import.yaml @@ -74,6 +74,142 @@ spec: bruteForceProtected: true permanentLockout: false sslRequired: external + # =========================================================================================== + # PASSKEYS — the WebAuthn PASSWORDLESS policy (R-IDP-04). 2026-10-17. + # =========================================================================================== + # Read this together with the browser-mfa flow at the bottom of the file: THIS block decides + # what a passkey must BE, the FLOW decides when one is OFFERED. Keycloak keeps TWO independent + # WebAuthn policies per realm and they are not interchangeable: + # + # webAuthnPolicy* -> the TWO-FACTOR policy, consumed by the `webauthn-authenticator` + # authenticator (a security key presented AFTER a password). + # webAuthnPolicyPasswordless* -> the PASSWORDLESS policy, consumed by + # `webauthn-authenticator-passwordless` (a passkey presented + # INSTEAD of a password). This is the one passkeys use. + # + # Setting the wrong family is a silent no-op: the passwordless authenticator reads + # getWebAuthnPolicyPasswordless() only (WebAuthnPasswordlessAuthenticator.getWebAuthnPolicy), + # so a policy written into the two-factor fields is simply never consulted by a passkey login. + # Only the passwordless family is declared here; the two-factor family is deliberately left at + # Keycloak's defaults because nothing in this realm uses `webauthn-authenticator` — TOTP + # (auth-otp-form) is the second factor for platform-admins, see the flow section. + # + # ADDITIVE AND OPTIONAL — THE DECISION THIS BLOCK EXISTS TO MAKE + # ------------------------------------------------------------- + # Passkeys are offered ALONGSIDE the password, never instead of it, and enrolment is NOT forced. + # This is a single-operator homelab whose IdP gates Grafana, Gitea, Nextcloud, Headlamp, + # Semaphore, PiHole, Crafty, the *arr stack AND kubectl itself (the `kubernetes` client below). + # A mandatory passkey flow that misfires is therefore not an inconvenience, it is a total + # lock-out from the whole platform AND from the cluster API in one step — and the only repair + # paths left would be the Keycloak bootstrap admin and the break-glass cert kubeconfig. The + # expected gain from forcing enrolment (one human stops typing a password) is small; the + # expected loss is the platform. So: additive, optional, and password login keeps working for + # every user who never enrols. + # + # Concretely that means: no `requiredActions` block is declared anywhere in this file (see the + # long note in the flow section for why declaring one would be actively DANGEROUS), and the + # passkey step sits as an ALTERNATIVE beside the password form rather than ahead of it. + # + # RELYING-PARTY ID — THE FIELD THAT SILENTLY BREAKS EVERY PASSKEY IF IT IS WRONG + # ------------------------------------------------------------------------------ + # The RP ID is baked into every credential at enrolment and can never be changed afterwards: + # a passkey created under one RP ID is unusable under another, with no error the user can act + # on — the browser simply reports that no credential is available. It is also browser-enforced, + # so it cannot be "fixed" server-side: per WebAuthn, the RP ID must be equal to, or a + # registrable-domain suffix of, the origin's effective domain. Keycloak is served at + # keycloak. (httproute.yaml, and the Keycloak CR's hostname), so the legal choices + # are keycloak., , or the registrable domain one level up. It is NOT + # the full URL and NOT a scheme — "https://..." here would fail every ceremony. + # + # We choose BASE_DOMAIN, the shared platform suffix, NOT the keycloak host, for one concrete + # reason drawn from this repo's own history: a credential that cannot be re-keyed must not be + # bound to a name that can move. The oauth2-proxy client below ALREADY declares a redirect URI + # on auth., i.e. this platform already contemplates serving auth on a second + # hostname; and the client-URL reconciler exists precisely because moving a hostname in git + # silently fails to reach the realm (see README, "Client URLs: the same one-shot failure"). + # Binding passkeys to the keycloak subdomain would mean that relocating Keycloak invalidates + # every enrolled passkey; binding them to BASE_DOMAIN survives it. The cost is scope: any + # future WebAuthn relying party under this base domain could assert the same RP ID. In a + # homelab where every such party is already behind THIS IdP that is not a trust boundary worth + # buying. Note the residual, unavoidable risk: changing BASE_DOMAIN itself invalidates every + # passkey, which is a re-enrolment, not a repair. + # + # Written as the cluster-var so it is substituted by the same Flux postBuild pass, with the same + # value, as every hostname in this file — there is no second place the domain is decided. + webAuthnPolicyPasswordlessRpId: ${BASE_DOMAIN} + # The human-readable name the browser/OS shows in the passkey prompt and stores beside the + # credential ("Save a passkey for Homelab SSO?"). Matches the realm displayName above so the + # OS keychain entry is recognisable. It is also load-bearing on the UPDATE path: Keycloak's + # updateRealm applies the passwordless policy ONLY when this field is non-empty + # (DefaultExportImportManager line ~842), so an admin-API realm update that omits it drops the + # whole policy silently. + webAuthnPolicyPasswordlessRpEntityName: "Homelab SSO" + # ES256 then RS256 — exactly Keycloak's own DEFAULT_POLICY, declared explicitly rather than + # inherited so the realm's crypto is auditable from git. ES256 is what every platform + # authenticator (Touch ID/Face ID, Windows Hello, Android) and every FIDO2 key produces; + # RS256 is the fallback for older Windows Hello TPM-backed credentials. EdDSA/Ed25519 is + # deliberately NOT offered: authenticator and browser support is uneven, and an authenticator + # that picks an algorithm the verifier handles badly fails at enrolment, which is the worst + # moment to discover it. Order is a preference list, not an allow-list ordering requirement. + webAuthnPolicyPasswordlessSignatureAlgorithms: + - ES256 + - RS256 + # DISCOVERABLE CREDENTIAL REQUIRED. "Yes" is a Keycloak sentinel, not a boolean, and the login + # theme compares it as the literal string: webauthnRegister.js maps 'Yes' to + # authenticatorSelection.requireResidentKey=true and ANY other non-sentinel value to false. + # This is not optional for passkeys — a non-discoverable credential cannot be found by the + # browser without the server first naming it, so it is a second factor, not a passkey. + # (Keycloak 26.0.6 emits only the LEGACY requireResidentKey member, not the newer + # residentKey:"required"; per WebAuthn L2 the client maps requireResidentKey=true onto + # residentKey="required", so discoverable credentials are genuinely requested — but it also + # means this policy cannot express residentKey:"preferred" at all.) + webAuthnPolicyPasswordlessRequireResidentKey: "Yes" + # USER VERIFICATION REQUIRED. The authenticator must verify the human (biometric or PIN) and + # say so in the assertion. This is the field that makes a passkey login at least as strong as + # the password it replaces: with verification merely "preferred", possession of an unlocked + # phone or an unattended security key would be a COMPLETE login to every SSO-gated app — a + # single-factor possession check, strictly weaker than the password+TOTP path beside it. + # Required makes the passkey branch inherently two-factor (something you have + something you + # are/know), which is the premise the flow section relies on when it does NOT ask a + # passkey-authenticated platform-admin for TOTP as well. + webAuthnPolicyPasswordlessUserVerificationRequirement: required + # "not specified" is Keycloak's sentinel for "either kind of authenticator" + # (Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED; the theme JS omits the WebAuthn member + # entirely when it sees this exact string). Deliberately NOT narrowed: "platform" would reject + # every YubiKey and every phone used as a cross-device passkey, and "cross-platform" would + # reject Touch ID and Windows Hello — i.e. either choice silently halves the usable hardware. + # The operator should be able to enrol a laptop AND a phone AND a hardware key. + webAuthnPolicyPasswordlessAuthenticatorAttachment: "not specified" + # ATTESTATION: none. Requesting "direct" would hand Keycloak an attestation statement it has + # no trust anchors to validate here (no metadata service is configured) and nothing to compare + # it against, since the AAGUID allow-list below is deliberately empty — so the statement would + # be collected, unverifiable, and would add an extra "share model information?" consent prompt + # on Safari/iOS for no security gain. Attestation is how you ENFORCE an approved-hardware list; + # we do not have one, so asking for it is cost without benefit. + webAuthnPolicyPasswordlessAttestationConveyancePreference: none + # Empty = accept any authenticator model. An AAGUID allow-list is a hardware whitelist: it + # would have to be maintained by hand, and every new phone, laptop or key the operator buys + # would be rejected at enrolment with a message that does not explain itself. It is also moot + # while attestation is "none" (above) — with no attestation statement there is no trustworthy + # AAGUID to match against in the first place. + webAuthnPolicyPasswordlessAcceptableAaguids: [] + # Ceremony timeout in SECONDS (the login theme multiplies by 1000; 0 means "send no timeout and + # let the browser decide"). 300s is chosen over a tighter value because the slowest LEGITIMATE + # path is the cross-device hybrid flow — Keycloak renders a QR code, the operator unlocks a + # phone, scans it, and completes a biometric check — which routinely takes over a minute. A + # 60-120s timeout looks tidy and converts that flow into an unexplained failure. Browsers clamp + # this value (Chrome to at most 10 minutes), so 300 is honoured rather than reinterpreted. + webAuthnPolicyPasswordlessCreateTimeout: 300 + # Send the user's existing passkeys as excludeCredentials at enrolment, so re-enrolling an + # authenticator the user already registered is refused by the authenticator itself instead of + # producing a second, indistinguishable credential in the account console. Cheap, and it keeps + # "which passkey is which" answerable — which matters when the recovery story is "delete the + # lost one". + webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister: true + # webAuthnPolicyPasswordlessExtraOrigins is intentionally NOT set. It exists to admit non-web + # origins (native app facet IDs, e.g. android:apk-key-hash:...). Every ceremony here happens in + # a browser on Keycloak's own origin, which is already covered by the RP ID above; adding + # origins is only ever a widening of who may ask for these credentials. # --- Authorization model (R-IDP-06): groups drive access; platform-admins carry a realm role. --- roles: realm: @@ -528,14 +664,105 @@ spec: "access.token.claim": "true" "userinfo.token.claim": "true" "claim.name": groups - # --- MFA for platform-admins (R-IDP-04): a browser flow whose OTP step is CONDITIONAL on the - # platform-admin role. Non-admins log in without OTP; platform-admins must enroll/present TOTP. - # Set as the realm browserFlow below. VERIFY at bring-up (see runbook): a malformed flow fails - # the whole import — if so, the fallback is to comment this block + browserFlow and configure - # the conditional-OTP-by-role flow via the admin console. --- + # =========================================================================================== + # MFA for platform-admins (R-IDP-04) + PASSKEY login (2026-10-17). The realm browserFlow is + # bound to browser-mfa at the bottom of this file. + # =========================================================================================== + # Non-admins log in without OTP; platform-admins must enroll/present TOTP. VERIFY at bring-up + # (see runbook): a malformed flow fails the whole import — if so, the fallback is to comment + # this block + browserFlow and configure the flow via the admin console. + # + # THE SHAPE, AND WHY IT IS THIS SHAPE + # ----------------------------------- + # browser-mfa (top level, bound as browserFlow) + # auth-cookie ALTERNATIVE already-authenticated SSO cookie + # identity-provider-redirector ALTERNATIVE (no IdPs configured; kept for parity) + # browser-mfa-forms ALTERNATIVE the interactive path: + # auth-username-form REQUIRED WHO are you (no password yet) + # browser-mfa-passkey-or-password REQUIRED ...and PROVE it, one of two ways: + # webauthn-authenticator-passwordless ALTERNATIVE a passkey, or + # browser-mfa-password-and-otp ALTERNATIVE the password path: + # auth-password-form REQUIRED password, then + # browser-mfa-conditional-otp CONDITIONAL TOTP iff platform-admin + # + # WHY THE USERNAME/PASSWORD SCREEN HAD TO BE SPLIT IN TWO. The previous flow used the combined + # `auth-username-password-form`, which collects both on one page. A passkey branch cannot live + # behind that: the server must know WHICH user is logging in before it can decide whether a + # passkey is even available, and the combined form has already taken the password by the time + # it knows. So it is replaced by the `auth-username-form` / `auth-password-form` pair — the + # same decomposition Keycloak's own passwordless recipe uses. THIS IS A VISIBLE UX CHANGE FOR + # EVERY USER, passkey or not: login becomes two screens (username, then password) instead of + # one. That cost is accepted deliberately — it is the enabling change, it is what Google and + # Microsoft logins already do, and the alternative (below) is worse. + # + # WHY NOT USERNAMELESS ("just tap your passkey", no username screen at all). Keycloak 26.0.6 + # does support it: WebAuthnPasswordlessAuthenticator.requiresUser() returns FALSE, so the + # authenticator can legally run as a first step with no user in context. We deliberately do not + # do that, because of how the flow engine then behaves. In DefaultAuthenticationFlow the + # "does this user actually have this credential?" guard sits INSIDE the requiresUser() branch; + # with requiresUser() false and no user yet set, the engine skips that guard and simply invokes + # the authenticator. A passkey execution placed before the username form is therefore offered + # to EVERYONE, so every login — including every password-only user and every family member who + # will never own a passkey — would open on a passkey prompt they must dismiss. That is the + # opposite of additive. Keeping the passkey step AFTER auth-username-form means a user is in + # context, which is what makes the graceful degradation below actually work. + # + # WHY THE PASSKEY BRANCH DEGRADES CLEANLY TO A PASSWORD (the property the whole "additive" + # claim rests on). With a user in context, the engine takes the non-requiresUser branch and + # raises CREDENTIAL_SETUP_REQUIRED when the user has no credential of this type and the + # authenticator's factory does not allow self-setup — and WebAuthnPasswordlessAuthenticatorFactory + # .isUserSetupAllowed() is false, and the authenticator is a CredentialValidator, so both + # conditions hold. That exception is raised INSIDE the ALTERNATIVE loop, which catches it, + # marks the execution ATTEMPTED and proceeds to the next alternative. Net effect: a user with + # no passkey never sees a passkey screen and lands on the password form; a user WITH one gets + # the passkey screen and keeps the password as a "Try another way" option. Note that for a + # user holding BOTH, which screen appears FIRST is decided by Keycloak's authentication + # selection list (the user's own credential ordering), not by the priorities below. + # + # WHY A PASSKEY LOGIN IS NOT ALSO ASKED FOR TOTP. browser-mfa-conditional-otp hangs off the + # PASSWORD branch only, so a platform-admin who authenticates with a passkey is not challenged + # for TOTP. That is intentional, and it rests on the policy above: userVerification is + # `required`, so the authenticator has already proved possession of hardware AND verified the + # human (biometric or PIN) in one phishing-resistant, origin-bound ceremony. Stacking TOTP on + # top would add a replayable, phishable shared secret to a credential that is neither, for + # friction on every login. If a future operator disagrees, the change is small and local: move + # the browser-mfa-conditional-otp reference out of browser-mfa-password-and-otp and into + # browser-mfa-passkey-or-password's parent as a REQUIRED step, so it applies to both branches. + # + # WHY THERE IS NO `requiredActions` BLOCK IN THIS FILE — DO NOT ADD ONE + # -------------------------------------------------------------------- + # Enrolment uses Keycloak's `webauthn-register-passwordless` required action, and the obvious + # move is to declare it here. DO NOT. In Keycloak 26.0.6 the realm import treats + # requiredActions as REPLACE, NOT MERGE: DefaultExportImportManager.importRealm branches on + # `if (rep.getRequiredActions() != null)` and, when the representation declares ANY, imports + # exactly those plus delete_account — the `else` branch that installs the full default set + # (DefaultRequiredActions.addActions) never runs. RealmManager.setupRequiredActions is no + # backstop either: it fires only when the realm has ZERO required-action providers. + # + # So declaring one action would create this realm with that action and delete_account and + # NOTHING ELSE — no CONFIGURE_TOTP, no UPDATE_PASSWORD, no VERIFY_EMAIL, no delete_credential, + # no UPDATE_PROFILE. The first casualty would be the MFA requirement this very flow enforces: + # with CONFIGURE_TOTP unregistered, a platform-admin who does not yet have TOTP could never + # enrol one, while auth-otp-form below still demands it — a hard lock-out of every + # platform-admin from every SSO-gated app, including Headlamp and kubectl, delivered by a + # change whose stated purpose was to ADD a login method. + # + # And the declaration buys nothing, because the default set ALREADY CONTAINS IT. + # DefaultRequiredActions registers `webauthn-register-passwordless` with enabled=true, + # defaultAction=false, priority 80, gated on Profile.Feature.WEB_AUTHN — which is Type.DEFAULT + # in 26.0.6, i.e. on unless explicitly disabled. enabled=true / defaultAction=false IS the + # additive-and-optional posture this change wants: the action exists and can be triggered for + # a user on demand, but it is not forced on anyone at next login. Declaring it would restate + # the default while deleting ten siblings. + # + # (The symmetric question for authenticationFlows has the opposite answer, which is why THIS + # block is safe: importAuthenticationFlows adds the declared flows and then backfills each + # unbound binding — browser, registration, direct grant, reset credentials, client auth, first + # broker login, docker auth — from the built-in defaults individually. Declaring flows does not + # suppress the built-ins; declaring required actions does suppress them.) authenticationFlows: - alias: browser-mfa - description: "Browser flow with conditional OTP required for platform-admins." + description: "Browser flow: passkey or password, with conditional OTP for platform-admins." providerId: basic-flow topLevel: true builtIn: false @@ -555,13 +782,63 @@ spec: priority: 30 flowAlias: browser-mfa-forms userSetupAllowed: false + # Identify the user FIRST (username only), then prove it. Both steps are REQUIRED: the + # username is never optional, and neither is proving possession of the account. - alias: browser-mfa-forms - description: "Username/password then conditional OTP." + description: "Username, then passkey or password (+ conditional OTP)." providerId: basic-flow topLevel: false builtIn: false authenticationExecutions: - - authenticator: auth-username-password-form + # auth-username-form = Keycloak's "Username Form" (UsernameFormFactory) — resolves the + # user WITHOUT taking a password, which is the prerequisite for offering a passkey. + # Replaces the previous auth-username-password-form; see the header for the UX cost. + - authenticator: auth-username-form + authenticatorFlow: false + requirement: REQUIRED + priority: 10 + userSetupAllowed: false + - authenticatorFlow: true + requirement: REQUIRED + priority: 20 + flowAlias: browser-mfa-passkey-or-password + userSetupAllowed: false + # THE FORK. Two ALTERNATIVEs, so satisfying either one completes the flow. Priority puts the + # passkey first for users who have one; users who do not fall through to the password branch + # automatically (CREDENTIAL_SETUP_REQUIRED is caught by the ALTERNATIVE loop — see header). + - alias: browser-mfa-passkey-or-password + description: "A passkey (WebAuthn passwordless), or password with conditional OTP." + providerId: basic-flow + topLevel: false + builtIn: false + authenticationExecutions: + # webauthn-authenticator-passwordless = "WebAuthn Passwordless Authenticator" + # (WebAuthnPasswordlessAuthenticatorFactory). It reads the realm's PASSWORDLESS policy + # declared above — NOT the two-factor webAuthnPolicy* family. Keep it ALTERNATIVE: as + # REQUIRED it would force a passkey on every user, which is the lock-out this change is + # explicitly designed not to create. + - authenticator: webauthn-authenticator-passwordless + authenticatorFlow: false + requirement: ALTERNATIVE + priority: 10 + userSetupAllowed: false + - authenticatorFlow: true + requirement: ALTERNATIVE + priority: 20 + flowAlias: browser-mfa-password-and-otp + userSetupAllowed: false + # The unchanged pre-passkey behaviour, relocated intact into the password branch: password, + # then TOTP iff the user holds the platform-admin role. Nothing about the OTP condition + # changed; it simply no longer sits on the path a passkey login takes. + - alias: browser-mfa-password-and-otp + description: "Password, then conditional OTP for platform-admins." + providerId: basic-flow + topLevel: false + builtIn: false + authenticationExecutions: + # auth-password-form = Keycloak's "Password Form" (PasswordFormFactory) — the password + # half of the old combined form, now that the username has already been collected. + - authenticator: auth-password-form authenticatorFlow: false requirement: REQUIRED priority: 10