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
@@ -282,6 +282,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
{Method: "GET", Pattern: "/api/v1/auth/setup/status", SetupAllowed: true, h: a.handleSetupStatus},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/begin", Public: true, h: a.handlePasskeyLoginBegin},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, h: a.handlePasskeyLoginFinish},
|
||||
// Discoverable ("usernameless") passkey login (task #40): the from-zero sibling of the
|
||||
// email-first pair above — no identifier typed, the account is resolved from the
|
||||
// userHandle inside the signed assertion (handlers_passkey_discoverable.go).
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/begin", Public: true, h: a.handlePasskeyLoginDiscoverableBegin},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/finish", Public: true, h: a.handlePasskeyLoginDiscoverableFinish},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/email/start", Public: true, h: a.handleLoginEmailStart},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/email/verify", Public: true, h: a.handleLoginEmailVerify},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/op-login/start", Public: true, h: a.handleOpLoginStart},
|
||||
|
||||
Reference in new issue
Block a user