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 andkubectl; 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
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.
"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
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)
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]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Adds passkey (WebAuthn passwordless) login to the
homelabrealm.Design decisions
kubectl; a misfiring mandatory flow is a total lock-out leaving only the bootstrap admin and the break-glass cert kubeconfig.${BASE_DOMAIN}, notkeycloak.${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 anauth.${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.auth-username-password-formis split intoauth-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-mfakeeps its alias — the L2 test greps for it.requiresUser()is false and the engine'sconfiguredForguard 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
requiredActionsis REPLACE, not merge (DefaultExportImportManager.importRealm:327-338). Declaring any required action skips theelsebranch that installs the defaults — so declaring justwebauthn-register-passwordlesswould have produced a realm with that plusdelete_accountand noCONFIGURE_TOTP, leavingauth-otp-formdemanding a TOTP nobody could enrol: a hard lock-out shipped by a change meant to add a login method. It also buys nothing, sinceDefaultRequiredActionsalready registers it enabled underProfile.Feature.WEB_AUTHN. So norequiredActionsblock is declared, deliberately.authenticationFlowshas the opposite (backfilling) semantics, which is why declaring flows is safe."not specified"vs""—WebAuthnConstants.OPTION_NOT_SPECIFIED = ""butConstants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED = "not specified"; they disagree and the realm layer wants the latter.Not verified
webauthn-register-passwordless" is an inference from Keycloak defaults, unchecked.conformance/**(sensitive path).Local gate: rc 3, zero failures —
spec-immutability,decisions-log(48 entries),lint,l0,render-golden,l1,approval-policyall pass; onlygitea-settings(CI-only token) and L2/L3conformanceskipped. No sensitive path touched.Full detail:
decisions/2026-10-17-keycloak-passkeys.md🤖 Generated with Claude Code
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]>