Koder ID Passkeys / WebAuthn — RP contract + passkey-first login

mandatory

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): BeginWebAuthn requires flow.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. RPID MUST be the registrable domain that

    scopes the credential: id.koder.dev in production, stg.id.koder.dev in staging, localhost in dev. A credential registered under one RPID is NOT usable under another — never register under a per-app host.

  • R1.2 RPOrigins MUST list every exact origin that initiates ceremonies for

    that 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 from

    config/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_count MUST be verified non-decreasing on each assertion (clone

    detection); 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, the

    client 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 (per specs/identity/login-resolution.kmd); if the user has ≥1 passkey, returns the WebAuthn assertion challenge (CredentialAssertion JSON) and an opaque flow/session handle. MUST NOT require or accept a password.

    • POST /v1/auth/webauthn-only/verify — body `{ "handle": "...",

      "assertion": ParsedCredentialAssertionData }. Verifies via WebAuthnManager.FinishLogin; on success creates the session and issues the OIDC artifacts exactly as the password path does (per oauth-flow.kmd`).

  • R4.2 Enumeration resistance: start MUST return a uniform response shape

    whether 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 SessionData MUST 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 passkey

    enrollment (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).