Koder ID Passkeys / WebAuthn — RP contract + passkey-first login
Normative contract for WebAuthn/passkeys in Koder ID: the Relying-Party configuration (RPID/origins per environment), the credential lifecycle (registration/enrollment, authentication, sign-count, revocation), and the two authentication modes — passkey as MFA (second factor after password, already shipped) and **passkey-first / passwordless-primary** (login with a passkey and no password, tracked in services/foundation/id/engine#212). Also fixes the wire contract for the `/v1/auth/webauthn-only/*` endpoints so SDKs and apps integrate against a stable surface. Multi-tenant by construction (credentials are per-user under the tenant's identity store, RLS-scoped).
Status of the world (2026-06-04): passkeys ship today as MFA (second factor after password):
BeginWebAuthnrequiresflow.Status == FlowStatusMFARequired. The passkey-first / passwordless-primary mode (R4 below) is the gap tracked in#212. This spec is the normative target for both modes.
R1 — Relying Party configuration
- R1.1 The RP is Koder ID.
RPIDMUST be the registrable domain thatscopes the credential:
id.koder.devin production,stg.id.koder.devin staging,localhostin dev. A credential registered under one RPID is NOT usable under another — never register under a per-app host. - R1.2
RPOriginsMUST list every exact origin that initiates ceremonies forthat RPID (e.g.
https://id.koder.dev). Native apps use the platform attestation origins (android:apk-key-hash:…, the iOS associated-domain). - R1.3 RP config is environment-scoped (
WebAuthnConfig) and MUST come fromconfig/env, never hardcoded per build.
R2 — Credential lifecycle (enrollment)
- R2.1 Registration is a normal authenticated action: a signed-in user enrolls
a passkey via
BeginRegistration→ authenticator →FinishRegistration. - R2.2 Stored per credential, in the identity store, *LS-scoped to the
tenant*(existing columns):
webauthn_credential_id,webauthn_public_key,webauthn_aaguid,webauthn_sign_count,webauthn_transports. - R2.3
sign_countMUST be verified non-decreasing on each assertion (clonedetection); a regression MUST fail the login and raise an audit event.
- R2.4 A user MAY have multiple passkeys. Revocation removes the credential row;
erasure cascades per
policies/identity-data-retention.kmd(see#093 erasure-cascade … passkeys).
R3 — Passkey as MFA (shipped)
- R3.1 After a successful first factor that sets
FlowStatusMFARequired, theclient MAY complete the flow with a WebAuthn assertion (
BeginWebAuthn/FinishWebAuthn). This path is unchanged by this spec.
R4 — Passkey-first / passwordless-primary (#212)
- R4.1 A user with ≥1 registered passkey MAY authenticate *ithout a password
as the first factor* via a dedicated entry path. Endpoints:
POST /v1/auth/webauthn-only/start— body{ "identifier": "<email|username>" }(+
X-Tenant-ID). Resolves the user (perspecs/identity/login-resolution.kmd); if the user has ≥1 passkey, returns the WebAuthn assertion challenge (CredentialAssertionJSON) and an opaque flow/session handle. MUST NOT require or accept a password.POST /v1/auth/webauthn-only/verify— body `{ "handle": "...","assertion": ParsedCredentialAssertionData }
. Verifies viaWebAuthnManager.FinishLogin; on success creates the session and issues the OIDC artifacts exactly as the password path does (peroauth-flow.kmd`).
- R4.2 Enumeration resistance:
startMUST return a uniform response shapewhether or not the identifier exists / has a passkey (return a syntactically valid challenge for unknown users, or a generic error) — never reveal passkey existence. (T-suite must cover this.)
- R4.3 The flow MUST be a separate, additive path — it MUST NOT weaken or
bypass the existing password→MFA state machine. Passkey-first is a parallel first-factor, not a removal of the password option.
- R4.4 Conditional UI: web/app clients SHOULD offer passkey autofill
(
mediation: "conditional") where the platform supports it. - R4.5 Step-up: a passkey-first session satisfies MFA recency (it is a
phishing-resistant factor);
/v1/auth/step-up/check(#189) MUST treat a fresh passkey assertion as a strong, recent factor.
R5 — Session state store
- R5.1 In-flight WebAuthn
SessionDataMUST be persisted in a shared store(kdb / Redis), keyed by the flow/handle with a short TTL — NOT the current process-local
var webauthnSessions = map[...](which breaks across the 10-instance deployment and on restart). This is a prerequisite for R4 in prod.
R6 — Surfaces
- R6.1 The Koder ID app (
services/foundation/id/app) MUST offer passkeyenrollment (Security → Passkeys —
passkeys_page.dart, shipped) and, once R4 lands, a passkey-first login entry on the sign-in screen. - R6.2 Consumer components that delegate to Koder ID get passkey-first for free
(the issuer handles it); they MUST NOT implement their own WebAuthn RP.
Tests (per oauth-flow-test-template.kmd, extend)
- T-PK1 enroll → passkey-first login succeeds without password (webmobiledesktop).
- T-PK2 unknown identifier → uniform response (R4.2 enumeration resistance).
- T-PK3 sign-count regression → login fails + audit event (R2.3).
- T-PK4 password→MFA path still works unchanged (R4.3 non-regression).
- T-PK5 multi-instance: session created on instance A, verified on instance B
(R5.1 shared session store).