Files
home-network-k3s-impl/decisions/2026-10-17-keycloak-passkeys.md
jakman85andClaude Opus 5 16894b7b73
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
feat(keycloak): declare passkey (WebAuthn passwordless) login for the homelab realm
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]>
2026-10-17 23:23:36 -05:00

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:

  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:

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.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.