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]>
20 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-17 | feat/keycloak-passkeys | 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:
-
The WebAuthn Passwordless policy. Ten
webAuthnPolicyPasswordless*fields on the realm: RP ID${BASE_DOMAIN}, RP entity nameHomelab 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-factorwebAuthnPolicy*family is left at Keycloak's defaults because nothing in this realm useswebauthn-authenticator. -
The browser flow.
browser-mfakeeps its alias and stays bound asbrowserFlow; 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
kubectlvia thekubernetesOIDC 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
requiredActionsblock was added, on purpose. Declaring one would have been the obvious way to addwebauthn-register-passwordless, and it would have been actively dangerous — see "What I verified". - RP ID is
${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 name that can move. This realm already declares anauth.${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: changingBASE_DOMAINitself invalidates every enrolled passkey. - A passkey login is not also asked for TOTP.
browser-mfa-conditional-otphangs off the password branch only. The justification isuserVerificationRequirement: 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:
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:
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.<BASE_DOMAIN> 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.devis a registrable-domain suffix ofkeycloak.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 forauthenticatorAttachmentin a realm import. I traced it toConstants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIEDand 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: 300behaves. 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
createAuthenticationSelectionListproducing 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-formreplacingauth-username-password-formis 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-adminconfig 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 (condUserRolevs 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.