feat(keycloak): add passkey (WebAuthn passwordless) login, additive and optional #72

Merged
jakman85 merged 1 commits from feat/keycloak-passkeys into main 2026-10-18 05:44:04 +00:00
Owner

Adds passkey (WebAuthn passwordless) login to the homelab realm.

⚠️ This change has ZERO effect until applied by hand — measured, not assumed

$ kubectl -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}'
{"conditions":[{"status":"True","type":"Done"},{"status":"False","type":"Started"},...]}

Done=True / Started=False — the import is a one-shot that has already completed and will not re-run. Merging this does not change how anyone logs in. The client-secret/URL reconciler cannot help either: its allow-list makes /authentication unreachable by design, and these are realm-level, not client, fields.

Applying it means the admin console, or a deliberate re-import — which was not tested, and whose failure mode is the 2026-09-06 total SSO outage.

Design decisions

  • Additive and optional, not mandatory. This IdP gates every SSO app and kubectl; a misfiring mandatory flow is a total lock-out leaving only the bootstrap admin and the break-glass cert kubeconfig.
  • RP ID = ${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 hostname that can move — and this realm already declares an auth.${BASE_DOMAIN} redirect URI.
  • requireResidentKey: "Yes" + userVerificationRequirement: required. Without discoverability it is a second factor, not a passkey; without UV, possession alone is a complete login.
  • Flow: auth-username-password-form is split into auth-username-form + auth-password-form (the server must know who before it can offer a passkey), then an ALTERNATIVE fork: passkey, or password + conditional OTP. browser-mfa keeps its alias — the L2 test greps for it.
  • No OTP after a passkey — a UV-required passkey is already two phishing-resistant factors. The manifest names the one-line reversal.
  • Usernameless rejected despite 26.0.6 supporting it: requiresUser() is false and the engine's configuredFor guard sits inside that branch, so a passkey step before the username form would be offered to everyone.

Two traps found in upstream 26.0.6 source

  1. requiredActions is REPLACE, not merge (DefaultExportImportManager.importRealm:327-338). Declaring any required action skips the else branch that installs the defaults — so declaring just webauthn-register-passwordless would have produced a realm with that plus delete_account and no CONFIGURE_TOTP, leaving auth-otp-form demanding a TOTP nobody could enrol: a hard lock-out shipped by a change meant to add a login method. It also buys nothing, since DefaultRequiredActions already registers it enabled under Profile.Feature.WEB_AUTHN. So no requiredActions block is declared, deliberately. authenticationFlows has the opposite (backfilling) semantics, which is why declaring flows is safe.
  2. "not specified" vs "" — WebAuthnConstants.OPTION_NOT_SPECIFIED = "" but Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED = "not specified"; they disagree and the realm layer wants the latter.

Not verified

  • Nothing is applied and no browser has touched this. No passkey enrolled; the RP ID is unproven against a real ceremony, and a wrong RP ID fails silently in the browser with no server log.
  • The conditional-OTP regression is untested — the sub-flow is byte-identical but relocated one level deeper. Highest-value check before applying; identical YAML is not "still enforced", and the last breakage of that exact step 403'd every platform-admin.
  • The two-screen login change affects every user, passkey or not, and nobody has logged in through it.
  • The live realm's actual required actions, WebAuthn policies and flow executions were not read (that needs the admin credential, which was not decrypted). "The realm already has webauthn-register-passwordless" is an inference from Keycloak defaults, unchecked.
  • No conformance test asserts any of this, so a future edit could silently remove the policy or the flow with no gate stage noticing. Closing that would touch conformance/** (sensitive path).

Local gate: rc 3, zero failures — spec-immutability, decisions-log (48 entries), lint, l0, render-golden, l1, approval-policy all pass; only gitea-settings (CI-only token) and L2/L3 conformance skipped. No sensitive path touched.

Full detail: decisions/2026-10-17-keycloak-passkeys.md

🤖 Generated with Claude Code

Adds **passkey (WebAuthn passwordless)** login to the `homelab` realm. > ## ⚠️ This change has ZERO effect until applied by hand — measured, not assumed > > ``` > $ kubectl -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}' > {"conditions":[{"status":"True","type":"Done"},{"status":"False","type":"Started"},...]} > ``` > > `Done=True` / `Started=False` — the import is a **one-shot that has already completed and will not re-run.** Merging this does not change how anyone logs in. The client-secret/URL reconciler cannot help either: its allow-list makes `/authentication` unreachable by design, and these are realm-level, not client, fields. > > Applying it means the admin console, or a deliberate re-import — **which was not tested, and whose failure mode is the 2026-09-06 total SSO outage.** ## Design decisions - **Additive and optional**, not mandatory. This IdP gates every SSO app *and* `kubectl`; a misfiring mandatory flow is a total lock-out leaving only the bootstrap admin and the break-glass cert kubeconfig. - **RP ID = `${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 hostname that can move — and this realm already declares an `auth.${BASE_DOMAIN}` redirect URI. - **`requireResidentKey: "Yes"` + `userVerificationRequirement: required`.** Without discoverability it is a second factor, not a passkey; without UV, possession alone is a complete login. - **Flow:** `auth-username-password-form` is split into `auth-username-form` + `auth-password-form` (the server must know *who* before it can offer a passkey), then an ALTERNATIVE fork: passkey, or password + conditional OTP. `browser-mfa` keeps its alias — the L2 test greps for it. - **No OTP after a passkey** — a UV-required passkey is already two phishing-resistant factors. The manifest names the one-line reversal. - **Usernameless rejected** despite 26.0.6 supporting it: `requiresUser()` is false and the engine's `configuredFor` guard sits *inside* that branch, so a passkey step before the username form would be offered to everyone. ## Two traps found in upstream 26.0.6 source 1. **`requiredActions` is REPLACE, not merge** (`DefaultExportImportManager.importRealm:327-338`). Declaring any required action skips the `else` branch that installs the defaults — so declaring just `webauthn-register-passwordless` would have produced a realm with that plus `delete_account` and **no `CONFIGURE_TOTP`**, leaving `auth-otp-form` demanding a TOTP nobody could enrol: a hard lock-out shipped by a change meant to *add* a login method. It also buys nothing, since `DefaultRequiredActions` already registers it enabled under `Profile.Feature.WEB_AUTHN`. **So no `requiredActions` block is declared, deliberately.** `authenticationFlows` has the opposite (backfilling) semantics, which is why declaring flows is safe. 2. **`"not specified"` vs `""`** — `WebAuthnConstants.OPTION_NOT_SPECIFIED = ""` but `Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED = "not specified"`; they disagree and the realm layer wants the latter. ## Not verified - **Nothing is applied and no browser has touched this.** No passkey enrolled; the RP ID is unproven against a real ceremony, and **a wrong RP ID fails silently in the browser with no server log**. - **The conditional-OTP regression is untested** — the sub-flow is byte-identical but relocated one level deeper. Highest-value check before applying; identical YAML is not "still enforced", and the last breakage of that exact step 403'd every platform-admin. - **The two-screen login change affects every user**, passkey or not, and nobody has logged in through it. - The live realm's actual required actions, WebAuthn policies and flow executions were **not read** (that needs the admin credential, which was not decrypted). "The realm already has `webauthn-register-passwordless`" is an inference from Keycloak defaults, unchecked. - **No conformance test asserts any of this**, so a future edit could silently remove the policy or the flow with no gate stage noticing. Closing that would touch `conformance/**` (sensitive path). Local gate: **rc 3, zero failures** — `spec-immutability`, `decisions-log` (48 entries), `lint`, `l0`, `render-golden`, `l1`, `approval-policy` all pass; only `gitea-settings` (CI-only token) and L2/L3 `conformance` skipped. No sensitive path touched. Full detail: `decisions/2026-10-17-keycloak-passkeys.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea-admin added 1 commit 2026-10-18 04:26:00 +00:00
feat(keycloak): declare passkey (WebAuthn passwordless) login for the homelab realm
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
16894b7b73
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) <[email protected]>
jakman85 merged commit 4d489f2059 into main 2026-10-18 05:44:04 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Home-Network/home-network-k3s-impl#72