feat(auth): add discoverable (usernameless) passkey login
A from-zero login door: the browser calls navigator.credentials.get() with an empty allowCredentials, the authenticator returns an assertion carrying the resident credential's userHandle, and the server resolves the account from that handle alone — nothing is typed or client-named. Routes (both Public): POST /api/v1/auth/passkey/login/discoverable/begin POST /api/v1/auth/passkey/login/discoverable/finish Begin stashes the ceremony SessionData server-side keyed by an opaque login_id under a global cap; finish consumes it single-use, hands the authenticator-revealed userHandle to a UserByID resolver, and mints a session only for the account the assertion actually verified to. Every finish rejection — no live challenge, expired, bad assertion, unresolvable handle — collapses to one passkey_login_invalid envelope, so finish is never an existence/state oracle. SignCount is surfaced but not yet consumed, exactly as the username-first door, so the from-zero path offers no clone-detection bypass. The discoverable VERIFY path is Oracle-verified end to end against a virtual authenticator (internal/passkey): it resolves the account from the signed userHandle, fails closed when the handle names no account, and rejects an assertion signed by a credential not bound to the resolved user — the impersonation guard unique to usernameless login. Enrollment now requests a resident key (authenticatorSelection.residentKey=preferred), the only server-side half a unit test can pin. Whether an authenticator actually stores a resident key is a device property no test can reach, so this door is INERT for a credential until its owner enrolls a NEW passkey against these options; "preferred" (not "required") preserves the no-lockout fallback to username-first + email-OTP.
This commit is contained in:
12 files changed
+1193
-21
No files matched your search
@@ -1709,6 +1709,153 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
/api/v1/auth/passkey/login/discoverable/begin:
|
||||
post:
|
||||
tags: [auth]
|
||||
operationId: passkeyLoginDiscoverableBegin
|
||||
summary: Begin a usernameless (discoverable) passkey login (spec §14, §B, task #40).
|
||||
description: >-
|
||||
First leg of the truly from-zero passkey door: unlike the email-first sibling
|
||||
above, the caller supplies NO identifier — the request has no body (only the
|
||||
application/json Content-Type is required as the cross-origin CSRF guard). The
|
||||
response is the WebAuthn PublicKeyCredentialRequestOptions with an EMPTY
|
||||
allowCredentials, plus an opaque login_id: the authenticator picks a resident
|
||||
credential it holds for this RP and the account is revealed only by the
|
||||
userHandle inside the signed assertion at finish. The challenge cannot be
|
||||
user-keyed, so it is stashed under login_id in a non-user-keyed store and echoed
|
||||
back at finish. Mounted Public and gated on local_auth_enabled. There is no
|
||||
recipient or principal to key a per-caller cooldown on (that volumetric limiting
|
||||
is delegated to the edge), so the server-side brake is a hard global cap on live
|
||||
challenges (429 too_many_challenges). Inert for a credential until its owner
|
||||
enrolls a resident passkey; email-OTP and username-first passkey remain the
|
||||
fallbacks, so no authenticator is ever locked out.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: public
|
||||
security: []
|
||||
requestBody:
|
||||
required: false
|
||||
description: >-
|
||||
No body is read — the whole point is that the caller supplies no identifier —
|
||||
but the application/json Content-Type is required (415 otherwise).
|
||||
content:
|
||||
application/json:
|
||||
schema: { type: object }
|
||||
responses:
|
||||
'200':
|
||||
description: >-
|
||||
The WebAuthn assertion options (PublicKeyCredentialRequestOptions) with an
|
||||
empty allowCredentials, passed through verbatim for the browser to consume,
|
||||
plus an opaque login_id the caller echoes at finish. The publicKey member is
|
||||
the WebAuthn standard shape and is not modelled here.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [publicKey, login_id]
|
||||
properties:
|
||||
publicKey: { type: object, additionalProperties: true }
|
||||
login_id: { type: string }
|
||||
'400':
|
||||
description: The authenticator library could not start the ceremony (passkey_login_failed).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'403':
|
||||
description: Local session login is disabled on this deployment (local_auth_disabled).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'415':
|
||||
description: Request Content-Type was not application/json.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'429':
|
||||
description: >-
|
||||
Too many discoverable logins are in flight server-wide; the global cap is hit
|
||||
(too_many_challenges). No per-recipient signal is leaked — the cap is global.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'503':
|
||||
description: No passkey verifier is wired on this deployment (passkey_unavailable).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
/api/v1/auth/passkey/login/discoverable/finish:
|
||||
post:
|
||||
tags: [auth]
|
||||
operationId: passkeyLoginDiscoverableFinish
|
||||
summary: Complete a usernameless (discoverable) passkey login and mint a session (spec §14, §B, task #40).
|
||||
description: >-
|
||||
Second leg of the from-zero door: the caller returns the opaque login_id from
|
||||
begin (the only link to the stashed challenge, since it is not user-keyed) and
|
||||
the raw navigator.credentials.get() assertion — and NOTHING that names an
|
||||
account. The stashed challenge is consumed atomically and the assertion is
|
||||
verified against it; the account is resolved from the authenticator-revealed
|
||||
userHandle (the account's stable id), never from anything the client supplied,
|
||||
and the session is minted for the account the assertion actually resolved AND
|
||||
verified to. Both players and staff may log in this way. Every failure mode — a
|
||||
missing/expired/consumed login_id, a bad assertion, AND a userHandle that
|
||||
resolves to no account — collapses into one uniform passkey_login_invalid, so
|
||||
the door reveals nothing (not even whether the handle was well-formed).
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: public
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [login_id, assertion]
|
||||
properties:
|
||||
login_id:
|
||||
type: string
|
||||
description: The opaque handle returned by discoverable/begin.
|
||||
assertion:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: >-
|
||||
The raw PublicKeyCredential from navigator.credentials.get(),
|
||||
passed to the verifier verbatim (WebAuthn standard shape). Its
|
||||
userHandle selects the account server-side.
|
||||
responses:
|
||||
'200':
|
||||
description: Assertion verified; a host-only session cookie is set on the response.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id, role]
|
||||
properties:
|
||||
user_id: { type: string }
|
||||
role: { type: string, enum: [user, admin] }
|
||||
'400':
|
||||
description: >-
|
||||
Missing login_id or assertion (bad_request); or the login could not be
|
||||
completed — no live/expired/consumed challenge, a failed assertion, or a
|
||||
userHandle that resolves to no account, all uniform (passkey_login_invalid).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'403':
|
||||
description: Local session login is disabled on this deployment (local_auth_disabled).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'415':
|
||||
description: Request body was not application/json.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'503':
|
||||
description: No passkey verifier is wired on this deployment (passkey_unavailable).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
/api/v1/auth/email/start:
|
||||
post:
|
||||
tags: [auth]
|
||||
|
||||
Reference in new issue
Block a user