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:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
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:
|
/api/v1/auth/email/start:
|
||||||
post:
|
post:
|
||||||
tags: [auth]
|
tags: [auth]
|
||||||
|
|||||||
@@ -282,6 +282,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
{Method: "GET", Pattern: "/api/v1/auth/setup/status", SetupAllowed: true, h: a.handleSetupStatus},
|
{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/begin", Public: true, h: a.handlePasskeyLoginBegin},
|
||||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, h: a.handlePasskeyLoginFinish},
|
{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/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/email/verify", Public: true, h: a.handleLoginEmailVerify},
|
||||||
{Method: "POST", Pattern: "/api/v1/auth/op-login/start", Public: true, h: a.handleOpLoginStart},
|
{Method: "POST", Pattern: "/api/v1/auth/op-login/start", Public: true, h: a.handleOpLoginStart},
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ type fakeRepo struct {
|
|||||||
// just as the PG query does.
|
// just as the PG query does.
|
||||||
passkeyCreds map[string]PasskeyCredential
|
passkeyCreds map[string]PasskeyCredential
|
||||||
passkeyChallenges map[string]*fakePasskeyChallenge
|
passkeyChallenges map[string]*fakePasskeyChallenge
|
||||||
|
// discoverable ("usernameless") login challenge store (task #40), keyed by opaque handle id
|
||||||
|
// with no user key, mirroring migration 0013. discoverableFull forces the capped-out path
|
||||||
|
// (ErrTooManyDiscoverableChallenges) so the begin 429 branch is reachable without inserting
|
||||||
|
// thousands of rows.
|
||||||
|
discoverableChallenges map[string]*fakeDiscoverableChallenge
|
||||||
|
discoverableFull bool
|
||||||
// user admin fakes
|
// user admin fakes
|
||||||
seededUsers []seededUser
|
seededUsers []seededUser
|
||||||
fakeQuotas map[string]*QuotaView
|
fakeQuotas map[string]*QuotaView
|
||||||
@@ -99,6 +105,15 @@ type fakePasskeyChallenge struct {
|
|||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fakeDiscoverableChallenge mirrors a webauthn_discoverable_challenges row (task #40): no user
|
||||||
|
// or purpose (a from-zero begin has neither), just the opaque stashed SessionData, its expiry,
|
||||||
|
// and single-use via consumed. Keyed by the opaque handle in the map, like the real table's id.
|
||||||
|
type fakeDiscoverableChallenge struct {
|
||||||
|
sessionData []byte
|
||||||
|
expiresAt time.Time
|
||||||
|
consumed bool
|
||||||
|
}
|
||||||
|
|
||||||
// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
|
// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
|
||||||
// (write-only) layer exercises: which name/data was stashed for the squatter UUID
|
// (write-only) layer exercises: which name/data was stashed for the squatter UUID
|
||||||
// and when the 30-day window ends. reclaimed_by_user_id/reclaimed_at have no fake
|
// and when the 30-day window ends. reclaimed_by_user_id/reclaimed_at have no fake
|
||||||
@@ -191,7 +206,9 @@ func newFakeRepo() *fakeRepo {
|
|||||||
holds: map[string]fakeDataHold{},
|
holds: map[string]fakeDataHold{},
|
||||||
passkeyCreds: map[string]PasskeyCredential{},
|
passkeyCreds: map[string]PasskeyCredential{},
|
||||||
passkeyChallenges: map[string]*fakePasskeyChallenge{},
|
passkeyChallenges: map[string]*fakePasskeyChallenge{},
|
||||||
fakeQuotas: map[string]*QuotaView{},
|
|
||||||
|
discoverableChallenges: map[string]*fakeDiscoverableChallenge{},
|
||||||
|
fakeQuotas: map[string]*QuotaView{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +383,31 @@ func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purp
|
|||||||
return live.sessionData, nil
|
return live.sessionData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateDiscoverableChallenge / ConsumeDiscoverableChallenge mirror PGRepo's non-user-keyed
|
||||||
|
// contract (task #40): begin reaps expired/consumed rows then stashes under the opaque handle,
|
||||||
|
// and consume redeems by handle, single-use, expiry checked. discoverableFull forces the capped
|
||||||
|
// path so the begin 429 branch is reachable without inserting thousands of rows.
|
||||||
|
func (f *fakeRepo) CreateDiscoverableChallenge(_ context.Context, id string, sessionData []byte, now, expiresAt time.Time) error {
|
||||||
|
if f.discoverableFull {
|
||||||
|
return ErrTooManyDiscoverableChallenges
|
||||||
|
}
|
||||||
|
for k, c := range f.discoverableChallenges { // reap (DELETE ... expires_at<=now OR consumed_at NOT NULL)
|
||||||
|
if c.consumed || !c.expiresAt.After(now) {
|
||||||
|
delete(f.discoverableChallenges, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.discoverableChallenges[id] = &fakeDiscoverableChallenge{sessionData: sessionData, expiresAt: expiresAt}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) ConsumeDiscoverableChallenge(_ context.Context, id string, now time.Time) ([]byte, error) {
|
||||||
|
c, ok := f.discoverableChallenges[id]
|
||||||
|
if !ok || c.consumed || !c.expiresAt.After(now) {
|
||||||
|
return nil, ErrPasskeyChallengeInvalid
|
||||||
|
}
|
||||||
|
c.consumed = true
|
||||||
|
return c.sessionData, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreatePasskeyCredential mirrors PGRepo: a credential_id already bound to ANY account
|
// CreatePasskeyCredential mirrors PGRepo: a credential_id already bound to ANY account
|
||||||
// → ErrConflict (the UNIQUE guard), never a silent rebind.
|
// → ErrConflict (the UNIQUE guard), never a silent rebind.
|
||||||
func (f *fakeRepo) CreatePasskeyCredential(_ context.Context, c PasskeyCredential) error {
|
func (f *fakeRepo) CreatePasskeyCredential(_ context.Context, c PasskeyCredential) error {
|
||||||
@@ -436,6 +478,10 @@ type fakePasskeyVerifier struct {
|
|||||||
// stashed SessionData round-trips and the existing credentials reach the verifier.
|
// stashed SessionData round-trips and the existing credentials reach the verifier.
|
||||||
lastUser PasskeyUser
|
lastUser PasskeyUser
|
||||||
lastSession []byte
|
lastSession []byte
|
||||||
|
// discoverableUserHandle is the userHandle the fake feeds to FinishDiscoverableLogin's
|
||||||
|
// resolver, so a handler test drives the userHandle → UserByID → session-mint wiring for a
|
||||||
|
// chosen account (or an unknown handle, to exercise the resolve-fails branch).
|
||||||
|
discoverableUserHandle []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *fakePasskeyVerifier) BeginRegistration(user PasskeyUser) (json.RawMessage, []byte, error) {
|
func (v *fakePasskeyVerifier) BeginRegistration(user PasskeyUser) (json.RawMessage, []byte, error) {
|
||||||
@@ -477,6 +523,33 @@ func (v *fakePasskeyVerifier) FinishLogin(user PasskeyUser, sessionData []byte,
|
|||||||
return v.assertion, nil
|
return v.assertion, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *fakePasskeyVerifier) BeginDiscoverableLogin() (json.RawMessage, []byte, error) {
|
||||||
|
if v.beginLoginErr != nil {
|
||||||
|
return nil, nil, v.beginLoginErr
|
||||||
|
}
|
||||||
|
opts := v.options
|
||||||
|
if opts == nil {
|
||||||
|
opts = json.RawMessage(`{"publicKey":{"challenge":"ZGlzYw"}}`)
|
||||||
|
}
|
||||||
|
return opts, []byte("disc-session"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *fakePasskeyVerifier) FinishDiscoverableLogin(resolveUser func([]byte) (PasskeyUser, error), sessionData []byte, _ io.Reader) (VerifiedAssertion, error) {
|
||||||
|
v.lastSession = sessionData
|
||||||
|
if v.failErr != nil {
|
||||||
|
return VerifiedAssertion{}, v.failErr
|
||||||
|
}
|
||||||
|
// Drive the resolver with the configured user handle so the handler's userHandle → UserByID
|
||||||
|
// → session-mint wiring runs end to end; a resolve error (unknown handle) fails the ceremony
|
||||||
|
// exactly as the real ValidateDiscoverableLogin would when the handler cannot be resolved.
|
||||||
|
u, err := resolveUser(v.discoverableUserHandle)
|
||||||
|
if err != nil {
|
||||||
|
return VerifiedAssertion{}, err
|
||||||
|
}
|
||||||
|
v.lastUser = u
|
||||||
|
return v.assertion, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) {
|
func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) {
|
||||||
return f.allowlist[n][u], nil
|
return f.allowlist[n][u], nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ var (
|
|||||||
// guard and gets a 409 instead of a raw unique-violation 500. Distinct from
|
// guard and gets a 409 instead of a raw unique-violation 500. Distinct from
|
||||||
// ErrConflict so the message can name the cause (the email is spoken for).
|
// ErrConflict so the message can name the cause (the email is spoken for).
|
||||||
ErrEmailTaken = errors.New("email already verified on another account")
|
ErrEmailTaken = errors.New("email already verified on another account")
|
||||||
|
// ErrTooManyDiscoverableChallenges means the non-user-keyed discoverable ("usernameless")
|
||||||
|
// login challenge store is at its hard cap of live rows (task #40, migration 0013).
|
||||||
|
// Unlike the user-keyed enrollment/login challenges — which self-bound via a per-user
|
||||||
|
// supersede — a from-zero begin has no principal to key a fair per-caller limit on, so the
|
||||||
|
// table is capped globally and a begin over the cap is refused. Distinct from the other
|
||||||
|
// sentinels so the handler answers 429 (a transient "too busy, retry" — the cap self-clears
|
||||||
|
// as challenges expire), never a 400 that invites an immediate retry.
|
||||||
|
ErrTooManyDiscoverableChallenges = errors.New("too many discoverable login challenges in flight")
|
||||||
)
|
)
|
||||||
|
|
||||||
// apiError is a handler-level error carrying an HTTP status and a stable,
|
// apiError is a handler-level error carrying an HTTP status and a stable,
|
||||||
|
|||||||
@@ -40,15 +40,20 @@ import (
|
|||||||
// (0010_verified_email_unique.sql) plus UserByEmail gave the door the typable handle
|
// (0010_verified_email_unique.sql) plus UserByEmail gave the door the typable handle
|
||||||
// it keys on: begin resolves email → account → its bound passkeys.
|
// it keys on: begin resolves email → account → its bound passkeys.
|
||||||
//
|
//
|
||||||
// This is an EMAIL-first assertion, not a usernameless one. The system's returning-player
|
// That EMAIL-first assertion is one of TWO login doors this subsystem now offers. The other,
|
||||||
// root of trust is still re-link (control of the in-game identity — handlers_onboard.go
|
// the TRULY from-zero door, is discoverable ("usernameless") login (handlers_passkey_discoverable.go,
|
||||||
// re-mints a session through the bind-code flow even after passkey/OTP are bound); the
|
// task #40): the browser calls navigator.credentials.get() with an EMPTY allowCredentials, the
|
||||||
// email and passkey login doors are convenience layered on top, never the root. The real
|
// authenticator offers a resident credential it holds, and the account is resolved from the
|
||||||
// enabler for a TRULY from-zero passkey login (no identifier typed at all) is discoverable
|
// userHandle inside the signed assertion — no identifier typed at all. It reshaped enrollment
|
||||||
// ("usernameless") credentials, which sidestep even the email handle but reshape enrollment
|
// (ResidentKey=Preferred in the verifier) and added a non-user-keyed challenge store (migration
|
||||||
// (residentKey) and need a non-user-keyed challenge store — a future migration and its own
|
// 0013). Two honest limits frame it: (1) the from-zero door partly bypasses the returning-player
|
||||||
// checkpoint, task #40 (that door partly bypasses the in-game-identity root of trust). The
|
// root of trust — control of the in-game identity, which handlers_onboard.go re-mints a session
|
||||||
// adapter crypto is verified now so that slice inherits correct crypto.
|
// through even after passkey/OTP are bound — but it stands on the same footing as the email door
|
||||||
|
// (#72): a passkey is a possession+UV two-factor authenticator strong enough to stand alone; and
|
||||||
|
// (2) whether an authenticator actually STORES a resident key is a device property no server
|
||||||
|
// request compels, so a credential enrolled before this slice, or on hardware that declines
|
||||||
|
// residency, stays username-first (BeginLogin) — the from-zero door is inert for it until its
|
||||||
|
// owner enrolls a new passkey. The assertion crypto for both doors is Oracle-verified.
|
||||||
//
|
//
|
||||||
// The cryptographic half is a seam (PasskeyVerifier) so this package never imports
|
// The cryptographic half is a seam (PasskeyVerifier) so this package never imports
|
||||||
// go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation
|
// go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation
|
||||||
@@ -102,6 +107,21 @@ type PasskeyVerifier interface {
|
|||||||
// the browser posts back; sessionData is the blob BeginLogin returned. A failed
|
// the browser posts back; sessionData is the blob BeginLogin returned. A failed
|
||||||
// verification returns a non-nil error; the handler maps it to 400.
|
// verification returns a non-nil error; the handler maps it to 400.
|
||||||
FinishLogin(user PasskeyUser, sessionData []byte, assertion io.Reader) (VerifiedAssertion, error)
|
FinishLogin(user PasskeyUser, sessionData []byte, assertion io.Reader) (VerifiedAssertion, error)
|
||||||
|
// BeginDiscoverableLogin starts a USERNAMELESS assertion ceremony (task #40): there is no
|
||||||
|
// user yet, so no allowCredentials — the authenticator offers a resident (discoverable)
|
||||||
|
// credential it holds for this RP and reveals the account only in the signed response. It
|
||||||
|
// returns the {"publicKey": {...}} request options for navigator.credentials.get() and the
|
||||||
|
// opaque SessionData the handler stashes under an opaque handle (not a user id) and replays
|
||||||
|
// at finish.
|
||||||
|
BeginDiscoverableLogin() (options json.RawMessage, sessionData []byte, err error)
|
||||||
|
// FinishDiscoverableLogin verifies a usernameless assertion. resolveUser is called with the
|
||||||
|
// authenticator-revealed user handle so the caller loads the account and its bound
|
||||||
|
// credentials WITHOUT any client-supplied identifier; the verifier then checks the asserted
|
||||||
|
// credential id is one that user holds and verifies the signature. A resolveUser error
|
||||||
|
// (unknown handle) fails the ceremony closed; the handle is the account's stable user id, so
|
||||||
|
// resolveUser is a direct id lookup. A failed verification returns a non-nil error the
|
||||||
|
// handler maps to 400.
|
||||||
|
FinishDiscoverableLogin(resolveUser func(userHandle []byte) (PasskeyUser, error), sessionData []byte, assertion io.Reader) (VerifiedAssertion, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasskeyUser is the relying-party view of the enrolling principal the verifier needs:
|
// PasskeyUser is the relying-party view of the enrolling principal the verifier needs:
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Discoverable ("usernameless") passkey login (spec §14, task #40) — the TRULY from-zero
|
||||||
|
// console.<root_domain> door. Its email-first sibling (handlers_passkey.go) still needs a typed
|
||||||
|
// email to resolve the account before offering its passkeys; this door needs nothing typed at
|
||||||
|
// all. The browser calls navigator.credentials.get() with an EMPTY allowCredentials, the
|
||||||
|
// authenticator offers a resident credential it holds for this RP, and the account is revealed
|
||||||
|
// only by the userHandle inside the signed assertion. Because there is no identifier at begin,
|
||||||
|
// the challenge cannot be user-keyed: it is stashed under an opaque server-minted handle
|
||||||
|
// (login_id) in the non-user-keyed store (migration 0013) and echoed back at finish. Email-OTP
|
||||||
|
// and username-first passkey remain the fallbacks, so an authenticator that stored no resident
|
||||||
|
// key is never locked out — only its from-zero convenience is unavailable.
|
||||||
|
//
|
||||||
|
// Anti-abuse divergence from the email-first door: that door reserves a per-recipient cooldown
|
||||||
|
// (a.otpLimiter) keyed on the typed email. A usernameless begin has no recipient OR principal to
|
||||||
|
// key a fair per-caller limit on, so — matching the stance in handlers_auth_email.go (behind
|
||||||
|
// Cloudflare RemoteAddr is the proxy; CGNAT false-positives) — volumetric per-source limiting is
|
||||||
|
// left to the edge, and the server-side bound is a hard global cap on live challenges enforced
|
||||||
|
// atomically in CreateDiscoverableChallenge (ErrTooManyDiscoverableChallenges → 429).
|
||||||
|
|
||||||
|
// handlePasskeyLoginDiscoverableBegin starts a usernameless assertion ceremony (Public,
|
||||||
|
// pre-session). It has no request body — the whole point is that the caller supplies no
|
||||||
|
// identifier — but requires the JSON Content-Type as the same cross-origin CSRF guard the other
|
||||||
|
// pre-session doors use. It asks the verifier for assertion options with an empty
|
||||||
|
// allowCredentials + opaque SessionData, stashes the SessionData under a fresh opaque handle in
|
||||||
|
// the capped non-user-keyed store, and returns the options with that handle merged in as
|
||||||
|
// login_id for the browser to echo at finish.
|
||||||
|
func (a *API) handlePasskeyLoginDiscoverableBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !localAuthEnabled(r.Context(), a.Repo) {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
|
||||||
|
"session login is disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.Passkey == nil {
|
||||||
|
writeError(w, r, errPasskeyUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requireJSONContentType(r); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options, sessionData, err := a.Passkey.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed",
|
||||||
|
"could not start passkey login"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := newPasskeyID()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := a.now()
|
||||||
|
if err := a.Repo.CreateDiscoverableChallenge(r.Context(), id, sessionData, now, now.Add(passkeyChallengeTTL)); err != nil {
|
||||||
|
if errors.Is(err, ErrTooManyDiscoverableChallenges) {
|
||||||
|
writeError(w, r, newError(http.StatusTooManyRequests, "too_many_challenges",
|
||||||
|
"too many passkey logins in progress; try again shortly"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Merge the opaque login handle into the options envelope so the response is a single
|
||||||
|
// {"publicKey": {...}, "login_id": "..."} document. The browser passes publicKey to
|
||||||
|
// navigator.credentials.get() and echoes login_id back at finish (the challenge is never
|
||||||
|
// user-keyed, so this handle is the only link between begin and finish).
|
||||||
|
envelope, err := mergeLoginID(options, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkeyDiscoverableFinishRequest is the finish body: the opaque login_id that begin returned
|
||||||
|
// (the only link to the stashed challenge, since it is not user-keyed) and the raw
|
||||||
|
// navigator.credentials.get() assertion. Assertion is RawMessage so the exact bytes the browser
|
||||||
|
// produced reach the verifier without a re-encode that could perturb the signed payload.
|
||||||
|
type passkeyDiscoverableFinishRequest struct {
|
||||||
|
LoginID string `json:"login_id"`
|
||||||
|
Assertion json.RawMessage `json:"assertion"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePasskeyLoginDiscoverableFinish verifies a usernameless assertion and mints a session
|
||||||
|
// (Public, pre-session). It consumes the stashed challenge under login_id (a missing/expired/
|
||||||
|
// consumed handle → 400), then verifies the assertion — the verifier resolves the account from
|
||||||
|
// the authenticator-revealed userHandle via the resolve callback below, WITHOUT any
|
||||||
|
// client-supplied identifier. On success the session is minted for the account the assertion
|
||||||
|
// actually resolved AND verified to (the resolved user is hoisted out of the callback), never
|
||||||
|
// anything the client named. All rejection branches collapse to one passkey_login_invalid
|
||||||
|
// envelope so finish is never an existence/state oracle.
|
||||||
|
func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !localAuthEnabled(r.Context(), a.Repo) {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
|
||||||
|
"session login is disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.Passkey == nil {
|
||||||
|
writeError(w, r, errPasskeyUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requireJSONContentType(r); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req passkeyDiscoverableFinishRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.LoginID) == "" {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "login_id is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Assertion) == 0 {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData, err := a.Repo.ConsumeDiscoverableChallenge(r.Context(), req.LoginID, a.now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrPasskeyChallengeInvalid) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||||
|
"passkey login could not be completed; begin again"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verifier hands the authenticator-revealed userHandle to this resolver; it loads the
|
||||||
|
// account and its bound credentials so ValidateDiscoverableLogin can check the asserted
|
||||||
|
// credential belongs to that user and verify the signature. The userHandle IS the account's
|
||||||
|
// stable id (WebAuthnID), so this is a direct id lookup. The resolved user is hoisted here so
|
||||||
|
// the session below is minted for the account the assertion actually resolved AND verified to
|
||||||
|
// — not anything the client supplied (the body carries only a challenge handle).
|
||||||
|
var resolved *StaffUser
|
||||||
|
resolve := func(userHandle []byte) (PasskeyUser, error) {
|
||||||
|
u, err := a.Repo.UserByID(r.Context(), string(userHandle))
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyUser{}, err
|
||||||
|
}
|
||||||
|
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyUser{}, err
|
||||||
|
}
|
||||||
|
resolved = u
|
||||||
|
// Name/DisplayName are cosmetic at assertion time (nothing is shown to the user); use the
|
||||||
|
// stable username so a nil email never matters.
|
||||||
|
return PasskeyUser{ID: u.ID, Name: u.Username, DisplayName: u.Username, Credentials: creds}, nil
|
||||||
|
}
|
||||||
|
if _, err := a.Passkey.FinishDiscoverableLogin(resolve, sessionData, bytes.NewReader(req.Assertion)); err != nil {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||||
|
"passkey login could not be completed; begin again"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A verified assertion guarantees resolve ran and set resolved: go-webauthn calls the handler
|
||||||
|
// to obtain the user BEFORE checking the signature, and a resolve error would have failed
|
||||||
|
// FinishDiscoverableLogin above. Guard anyway so a future verifier that could return success
|
||||||
|
// without invoking the resolver fails closed rather than nil-dereferencing.
|
||||||
|
if resolved == nil {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||||
|
"passkey login could not be completed; begin again"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := newSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expires := a.now().Add(sessionTTL)
|
||||||
|
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), resolved.ID, expires); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, token, expires)
|
||||||
|
a.audit(r, resolved.Username, "auth.passkey_login_discoverable", "")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"user_id": resolved.ID,
|
||||||
|
"role": resolved.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeLoginID returns options with an added top-level "login_id" member, so a discoverable
|
||||||
|
// begin can hand the browser one {"publicKey": {...}, "login_id": "..."} document. It parses the
|
||||||
|
// options into a generic envelope (they are already a JSON object with a publicKey member) and
|
||||||
|
// re-marshals with the handle added; a malformed options blob surfaces as an error rather than a
|
||||||
|
// silently unmergeable response.
|
||||||
|
func mergeLoginID(options json.RawMessage, id string) (json.RawMessage, error) {
|
||||||
|
var envelope map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(options, &envelope); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
idJSON, err := json.Marshal(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
envelope["login_id"] = idJSON
|
||||||
|
return json.Marshal(envelope)
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-session DISCOVERABLE ("usernameless") passkey login tests (spec §14, task #40 — the
|
||||||
|
// truly from-zero console.<root_domain> door). These drive the two Public routes against the
|
||||||
|
// fakeRepo's non-user-keyed challenge store and a fake PasskeyVerifier, so what they PROVE is
|
||||||
|
// the handler + login state machine (opaque-handle stash → consume → userHandle-resolve →
|
||||||
|
// session mint), NOT the pgrepo SQL nor the cryptographic assertion verification (the latter is
|
||||||
|
// the parity subject of internal/passkey/verifier_test.go's TestDiscoverableLoginRoundTrip).
|
||||||
|
// The load-bearing properties, in flow order:
|
||||||
|
//
|
||||||
|
// - No identifier crosses the wire: begin has no request body and finish carries only the
|
||||||
|
// opaque login_id + the assertion. The account is revealed solely by the userHandle the
|
||||||
|
// verifier surfaces, resolved server-side via UserByID — never anything the client names.
|
||||||
|
// - Session-data round-trip: the stashed SessionData reaches FinishDiscoverableLogin only via
|
||||||
|
// store-stash → consume (the finish body has no session data), so it is never client-echoed.
|
||||||
|
// - Fail-closed anti-enumeration on finish: a bogus/expired/consumed handle, a failed
|
||||||
|
// assertion, AND a userHandle that resolves to no account all collapse to ONE
|
||||||
|
// passkey_login_invalid envelope — finish is never an existence/state oracle.
|
||||||
|
// - Volumetric bound: begin has no per-caller identity to rate-limit (that is delegated to the
|
||||||
|
// edge), so the server-side guard is the hard global cap → 429 too_many_challenges.
|
||||||
|
|
||||||
|
// seedDiscoverableLoginAPI wires the public from-zero door: local sessions enabled, a single
|
||||||
|
// verified player "player" (id u1) with one bound passkey, and a verifier primed with fixed
|
||||||
|
// options, a verified assertion, and a discoverableUserHandle of "u1" — so the default resolve
|
||||||
|
// path surfaces that account exactly as a real resident credential's userHandle would. Both
|
||||||
|
// routes are Public (no External principal), proving they are truly pre-session.
|
||||||
|
func seedDiscoverableLoginAPI(t *testing.T) (*API, *fakeRepo, *fakePasskeyVerifier) {
|
||||||
|
t.Helper()
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
repo.staff["player"] = &StaffUser{
|
||||||
|
ID: "u1", Username: "player", Email: "[email protected]",
|
||||||
|
Role: "user", EmailVerified: true,
|
||||||
|
}
|
||||||
|
repo.passkeyCreds["row1"] = PasskeyCredential{
|
||||||
|
ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow,
|
||||||
|
}
|
||||||
|
v := &fakePasskeyVerifier{
|
||||||
|
options: json.RawMessage(`{"publicKey":{"challenge":"ZGlzYw"}}`),
|
||||||
|
assertion: VerifiedAssertion{CredentialID: "cred-1", UserVerified: true},
|
||||||
|
discoverableUserHandle: []byte("u1"),
|
||||||
|
}
|
||||||
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
api.Passkey = v
|
||||||
|
return api, repo, v
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyDiscoverableLoginVertical walks the whole from-zero slice across the external face:
|
||||||
|
// begin stashes one challenge under an opaque login_id and returns the assertion options with
|
||||||
|
// that handle merged in; finish consumes the handle, verifies the assertion against the
|
||||||
|
// SERVER-STASHED session data, resolves the account from the authenticator-revealed userHandle
|
||||||
|
// (NOT from anything typed), and mints the same host-only felis_session as the other doors. The
|
||||||
|
// decisive assertion is the session-data round-trip: the finish body carries only login_id +
|
||||||
|
// assertion, so the only path for the stashed blob into FinishDiscoverableLogin is store-stash →
|
||||||
|
// consume — the challenge is never client-echoed.
|
||||||
|
func TestPasskeyDiscoverableLoginVertical(t *testing.T) {
|
||||||
|
api, repo, v := seedDiscoverableLoginAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
// 1) begin: usernameless — no request body by design (the caller supplies no identifier),
|
||||||
|
// only the JSON Content-Type CSRF guard. The response is one {"publicKey":{...},"login_id":
|
||||||
|
// "..."} document, and exactly one challenge is stashed, keyed by the returned handle.
|
||||||
|
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := acctBody(t, w)
|
||||||
|
if body["publicKey"] == nil {
|
||||||
|
t.Errorf("begin must return the assertion options verbatim, got %s", w.Body.String())
|
||||||
|
}
|
||||||
|
loginID, _ := body["login_id"].(string)
|
||||||
|
if loginID == "" {
|
||||||
|
t.Fatalf("begin must return a non-empty login_id, got %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.discoverableChallenges) != 1 {
|
||||||
|
t.Fatalf("begin must stash exactly one discoverable challenge, got %d", len(repo.discoverableChallenges))
|
||||||
|
}
|
||||||
|
if _, ok := repo.discoverableChallenges[loginID]; !ok {
|
||||||
|
t.Errorf("the stashed challenge must be keyed by the returned login_id %q", loginID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) finish: the body carries ONLY the login_id and the assertion — no identifier and no
|
||||||
|
// session data. The account is revealed by the userHandle the verifier surfaces (u1).
|
||||||
|
w = do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish",
|
||||||
|
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// THE security assertion: the session data the verifier saw is exactly what begin stashed —
|
||||||
|
// it travelled store-stash → consume, never the client (the finish body has no session data).
|
||||||
|
if !bytes.Equal(v.lastSession, []byte("disc-session")) {
|
||||||
|
t.Fatalf("finish session data = %q, want the server-stashed %q (challenge must not be client-echoed)",
|
||||||
|
v.lastSession, "disc-session")
|
||||||
|
}
|
||||||
|
vb := acctBody(t, w)
|
||||||
|
if vb["user_id"] != "u1" || vb["role"] != "user" {
|
||||||
|
t.Fatalf("finish body = %v, want user_id:u1 role:user", vb)
|
||||||
|
}
|
||||||
|
// The host-only HttpOnly cookie is the whole point — same contract as the other doors.
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
||||||
|
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
|
||||||
|
}
|
||||||
|
s, ok := repo.sessions[hashCookie(cookies[0].Value)]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no session row for the issued cookie (must be stored hashed)")
|
||||||
|
}
|
||||||
|
if s.userID != "u1" {
|
||||||
|
t.Errorf("session userID = %q, want u1", s.userID)
|
||||||
|
}
|
||||||
|
if want := frozenNow.Add(sessionTTL); !s.expiresAt.Equal(want) {
|
||||||
|
t.Errorf("session expiresAt = %v, want now+sessionTTL = %v", s.expiresAt, want)
|
||||||
|
}
|
||||||
|
// Audited once, by the RESOLVED account's username (there is no principal yet); begin is silent.
|
||||||
|
if n := len(repo.audits); n != 1 {
|
||||||
|
t.Fatalf("want exactly 1 audit (passkey_login_discoverable), got %d: %+v", n, repo.audits)
|
||||||
|
}
|
||||||
|
if repo.audits[0].Action != "auth.passkey_login_discoverable" || repo.audits[0].Actor != "player" {
|
||||||
|
t.Errorf("audit = %+v, want auth.passkey_login_discoverable by player", repo.audits[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) single-use: the consumed login_id buys nothing a second time.
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish",
|
||||||
|
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
|
||||||
|
t.Fatalf("replay of consumed login_id: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyDiscoverableLoginBeginCapped pins the server-side volumetric bound: with the store
|
||||||
|
// at its hard cap, begin answers 429 too_many_challenges and stashes nothing. This is the only
|
||||||
|
// per-server brake on the usernameless begin (there is no recipient/principal to key a per-caller
|
||||||
|
// cooldown on, so volumetric per-source limiting is delegated to the edge) — a reap alone cannot
|
||||||
|
// bound a burst, since freshly-inserted rows are not yet expired.
|
||||||
|
func TestPasskeyDiscoverableLoginBeginCapped(t *testing.T) {
|
||||||
|
api, repo, _ := seedDiscoverableLoginAPI(t)
|
||||||
|
repo.discoverableFull = true
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "too_many_challenges" {
|
||||||
|
t.Fatalf("capped begin: code = %d body %s, want 429 too_many_challenges", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.discoverableChallenges) != 0 {
|
||||||
|
t.Errorf("a capped begin must stash nothing, got %d", len(repo.discoverableChallenges))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyDiscoverableLoginBeginVerifierError pins the verifier-fault path: a
|
||||||
|
// BeginDiscoverableLogin failure is a server-side fault, answered passkey_login_failed, and
|
||||||
|
// stashes no challenge (there is nothing to stash — the ceremony never started).
|
||||||
|
func TestPasskeyDiscoverableLoginBeginVerifierError(t *testing.T) {
|
||||||
|
api, repo, v := seedDiscoverableLoginAPI(t)
|
||||||
|
v.beginLoginErr = errors.New("cannot begin discoverable")
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" {
|
||||||
|
t.Fatalf("verifier error: code = %d body %s, want 400 passkey_login_failed", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.discoverableChallenges) != 0 {
|
||||||
|
t.Errorf("a failed begin must stash nothing, got %d", len(repo.discoverableChallenges))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyDiscoverableLoginGates covers the shared front doors of both halves: the
|
||||||
|
// fail-closed local-auth toggle, graceful degradation when no verifier is wired, the CSRF
|
||||||
|
// Content-Type guard (these are Public, credential-minting routes), and the finish input gates
|
||||||
|
// that must reject before any consume or resolve.
|
||||||
|
func TestPasskeyDiscoverableLoginGates(t *testing.T) {
|
||||||
|
const beginPath = "/api/v1/auth/passkey/login/discoverable/begin"
|
||||||
|
const finishPath = "/api/v1/auth/passkey/login/discoverable/finish"
|
||||||
|
const goodFinish = `{"login_id":"x","assertion":{"id":"cred-1"}}`
|
||||||
|
|
||||||
|
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
|
||||||
|
api.Passkey = &fakePasskeyVerifier{}
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
if w := do(eh, "POST", beginPath, `{}`, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
|
||||||
|
t.Errorf("begin: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
|
||||||
|
t.Errorf("finish: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no verifier wired -> 503 passkey_unavailable on both halves", func(t *testing.T) {
|
||||||
|
api, _, _ := seedDiscoverableLoginAPI(t)
|
||||||
|
api.Passkey = nil // unwire it: the degraded path must be a clean 503, not a panic
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
if w := do(eh, "POST", beginPath, `{}`, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
|
||||||
|
t.Errorf("begin: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
|
||||||
|
t.Errorf("finish: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-JSON content type -> 415 on both halves", func(t *testing.T) {
|
||||||
|
api, _, _ := seedDiscoverableLoginAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
|
||||||
|
if w := do(eh, "POST", beginPath, `{}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("begin with Content-Type %q: code = %d, want 415", ct, w.Code)
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", finishPath, goodFinish, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("finish with Content-Type %q: code = %d, want 415", ct, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("finish missing inputs -> 400 bad_request, nothing minted", func(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"missing login_id": `{"assertion":{"id":"cred-1"}}`,
|
||||||
|
"empty login_id": `{"login_id":"","assertion":{"id":"cred-1"}}`,
|
||||||
|
"missing assertion": `{"login_id":"x"}`,
|
||||||
|
}
|
||||||
|
for name, body := range cases {
|
||||||
|
api, repo, _ := seedDiscoverableLoginAPI(t)
|
||||||
|
w := do(api.ExternalHandler(), "POST", finishPath, body, jsonHeader)
|
||||||
|
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
|
||||||
|
t.Errorf("%s: code = %d body %s, want 400 bad_request", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.sessions) != 0 {
|
||||||
|
t.Errorf("%s: a rejected finish must mint no session (%d)", name, len(repo.sessions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyDiscoverableLoginFinishRejections is the redeem-side failure matrix and the anchor
|
||||||
|
// for from-zero anti-enumeration: a bogus handle, an expired challenge, an assertion that fails
|
||||||
|
// verification, AND a userHandle that resolves to no account must ALL answer the byte-identical
|
||||||
|
// passkey_login_invalid envelope (code AND message) and mint no session. The last case is the
|
||||||
|
// one unique to this door — the account is chosen by the authenticator, so an unresolvable
|
||||||
|
// handle must fail exactly like a bad signature, never leaking that the handle was well-formed.
|
||||||
|
func TestPasskeyDiscoverableLoginFinishRejections(t *testing.T) {
|
||||||
|
const finishPath = "/api/v1/auth/passkey/login/discoverable/finish"
|
||||||
|
finish := func(eh http.Handler, loginID string) *httptest.ResponseRecorder {
|
||||||
|
return do(eh, "POST", finishPath,
|
||||||
|
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
loginID string
|
||||||
|
setup func(repo *fakeRepo, v *fakePasskeyVerifier)
|
||||||
|
}{
|
||||||
|
{"no live challenge", "ghost", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
|
||||||
|
{"expired challenge", "ex", func(repo *fakeRepo, v *fakePasskeyVerifier) {
|
||||||
|
repo.discoverableChallenges["ex"] = &fakeDiscoverableChallenge{
|
||||||
|
sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(-time.Second),
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{"assertion fails verification", "live", func(repo *fakeRepo, v *fakePasskeyVerifier) {
|
||||||
|
repo.discoverableChallenges["live"] = &fakeDiscoverableChallenge{
|
||||||
|
sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(passkeyChallengeTTL),
|
||||||
|
}
|
||||||
|
v.failErr = errors.New("bad assertion")
|
||||||
|
}},
|
||||||
|
{"userHandle resolves to no account", "live", func(repo *fakeRepo, v *fakePasskeyVerifier) {
|
||||||
|
repo.discoverableChallenges["live"] = &fakeDiscoverableChallenge{
|
||||||
|
sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(passkeyChallengeTTL),
|
||||||
|
}
|
||||||
|
v.discoverableUserHandle = []byte("nonexistent")
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelopes [][2]string
|
||||||
|
for _, c := range cases {
|
||||||
|
api, repo, v := seedDiscoverableLoginAPI(t)
|
||||||
|
c.setup(repo, v)
|
||||||
|
w := finish(api.ExternalHandler(), c.loginID)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
code, msg := errEnvelope(t, w)
|
||||||
|
if code != "passkey_login_invalid" {
|
||||||
|
t.Errorf("%s: error code = %q, want passkey_login_invalid", c.name, code)
|
||||||
|
}
|
||||||
|
if len(repo.sessions) != 0 {
|
||||||
|
t.Errorf("%s: a rejected finish must mint no session (got %d)", c.name, len(repo.sessions))
|
||||||
|
}
|
||||||
|
if len(w.Result().Cookies()) != 0 {
|
||||||
|
t.Errorf("%s: a rejected finish must set no cookie", c.name)
|
||||||
|
}
|
||||||
|
envelopes = append(envelopes, [2]string{code, msg})
|
||||||
|
}
|
||||||
|
// The anchor: every envelope is identical (code AND message), so no branch — including the
|
||||||
|
// unresolvable-handle branch — is distinguishable from another.
|
||||||
|
for i := 1; i < len(envelopes); i++ {
|
||||||
|
if envelopes[i] != envelopes[0] {
|
||||||
|
t.Errorf("envelope for %q %v differs from %q %v — all rejections must be identical",
|
||||||
|
cases[i].name, envelopes[i], cases[0].name, envelopes[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyDiscoverableLoginFaceSeparation enforces that both halves are web-only: the
|
||||||
|
// internal (service-token) face must 404 them, never serve them.
|
||||||
|
func TestPasskeyDiscoverableLoginFaceSeparation(t *testing.T) {
|
||||||
|
api, _, _ := seedDiscoverableLoginAPI(t)
|
||||||
|
ih := api.InternalHandler()
|
||||||
|
if w := do(ih, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("begin on internal face: code = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
if w := do(ih, "POST", "/api/v1/auth/passkey/login/discoverable/finish", `{"login_id":"x","assertion":{"id":"y"}}`, jsonHeader); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1004,6 +1004,91 @@ func (p *PGRepo) ConsumePasskeyChallengeByUser(ctx context.Context, userID, purp
|
|||||||
return sessionData, nil
|
return sessionData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- discoverable ("usernameless") passkey login (task #40, migration 0013) ----
|
||||||
|
|
||||||
|
// maxLiveDiscoverableChallenges hard-bounds the non-user-keyed discoverable-login challenge
|
||||||
|
// store. webauthn_challenges self-bounds via a per-(user,purpose) supersede; a from-zero begin
|
||||||
|
// has no such key, so the table is capped: once this many LIVE (unexpired, unconsumed) rows
|
||||||
|
// exist, a new begin is refused (ErrTooManyDiscoverableChallenges → 429). The cap is generous —
|
||||||
|
// a login challenge lives only passkeyChallengeTTL (5 min) and each row is ~1 KB — so real
|
||||||
|
// concurrency never approaches it, while an abusive begin-flood is bounded to a few MB instead
|
||||||
|
// of growing without limit. Volumetric per-IP limiting is the edge's job (handlers_auth_email.go):
|
||||||
|
// behind Cloudflare RemoteAddr is the proxy, and a usernameless door has no recipient to key a
|
||||||
|
// fair per-caller limit on.
|
||||||
|
const maxLiveDiscoverableChallenges = 4096
|
||||||
|
|
||||||
|
// CreateDiscoverableChallenge stashes a discoverable-login ceremony under an opaque handle,
|
||||||
|
// bounding the table in one transaction (see the Repo interface for the full contract). It
|
||||||
|
// reaps expired/consumed rows first — the non-user-keyed analog of CreatePasskeyChallenge's
|
||||||
|
// supersede — then refuses over the cap rather than inserting. Because the reap ran first, the
|
||||||
|
// COUNT is exactly the live-row count, so the cap bounds an adversarial begin-flood (which a
|
||||||
|
// reap alone cannot: a burst inside the TTL leaves every fresh row live).
|
||||||
|
func (p *PGRepo) CreateDiscoverableChallenge(ctx context.Context, id string, sessionData []byte, now, expiresAt time.Time) error {
|
||||||
|
tx, err := p.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM webauthn_discoverable_challenges WHERE expires_at <= $1 OR consumed_at IS NOT NULL`,
|
||||||
|
now); err != nil {
|
||||||
|
return fmt.Errorf("reap discoverable challenges: %w", err)
|
||||||
|
}
|
||||||
|
var live int
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM webauthn_discoverable_challenges`).Scan(&live); err != nil {
|
||||||
|
return fmt.Errorf("count discoverable challenges: %w", err)
|
||||||
|
}
|
||||||
|
if live >= maxLiveDiscoverableChallenges {
|
||||||
|
return ErrTooManyDiscoverableChallenges
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
id, sessionData, expiresAt); err != nil {
|
||||||
|
return fmt.Errorf("insert discoverable challenge: %w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeDiscoverableChallenge redeems the challenge under handle id, single-use (see the Repo
|
||||||
|
// interface for the contract). The row is taken FOR UPDATE so a concurrent finish cannot
|
||||||
|
// double-spend it; expiry is checked before consuming. No live row → ErrPasskeyChallengeInvalid.
|
||||||
|
func (p *PGRepo) ConsumeDiscoverableChallenge(ctx context.Context, id string, now time.Time) ([]byte, error) {
|
||||||
|
tx, err := p.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessionData []byte
|
||||||
|
expiresAt time.Time
|
||||||
|
)
|
||||||
|
switch err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT session_data, expires_at FROM webauthn_discoverable_challenges
|
||||||
|
WHERE id = $1 AND consumed_at IS NULL FOR UPDATE`,
|
||||||
|
id).Scan(&sessionData, &expiresAt); {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return nil, ErrPasskeyChallengeInvalid
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !expiresAt.After(now) {
|
||||||
|
return nil, ErrPasskeyChallengeInvalid
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE webauthn_discoverable_challenges SET consumed_at = $2 WHERE id = $1`, id, now); err != nil {
|
||||||
|
return nil, fmt.Errorf("consume discoverable challenge: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sessionData, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreatePasskeyCredential stores a freshly verified passkey (enrollment). Only public
|
// CreatePasskeyCredential stores a freshly verified passkey (enrollment). Only public
|
||||||
// attestation material is written; a credential_id already bound to ANY account is left
|
// attestation material is written; a credential_id already bound to ANY account is left
|
||||||
// untouched (ON CONFLICT DO NOTHING) and reported as ErrConflict via a zero RowsAffected,
|
// untouched (ON CONFLICT DO NOTHING) and reported as ErrConflict via a zero RowsAffected,
|
||||||
|
|||||||
+18
-2
@@ -350,9 +350,25 @@ type Repo interface {
|
|||||||
// returns the stashed SessionData so finish can validate the attestation against
|
// returns the stashed SessionData so finish can validate the attestation against
|
||||||
// it. No live challenge → ErrPasskeyChallengeInvalid. Single-use: a second finish
|
// it. No live challenge → ErrPasskeyChallengeInvalid. Single-use: a second finish
|
||||||
// for the same ceremony finds nothing live and fails. now is the API clock so
|
// for the same ceremony finds nothing live and fails. now is the API clock so
|
||||||
// expiry is testable. Bound to user_id — enrollment always has a principal, so
|
// expiry is testable. Bound to user_id — enrollment and username-first login both
|
||||||
// there is no usernameless consume-by-hash variant (login is a deferred slice).
|
// know the principal at begin; the usernameless from-zero door instead uses the
|
||||||
|
// non-user-keyed pair below.
|
||||||
ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose string, now time.Time) (sessionData []byte, err error)
|
ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose string, now time.Time) (sessionData []byte, err error)
|
||||||
|
// CreateDiscoverableChallenge persists a DISCOVERABLE ("usernameless") login ceremony
|
||||||
|
// (task #40, migration 0013), keyed by an opaque server-minted handle id — NOT a user,
|
||||||
|
// since a from-zero begin has no principal. In one transaction it reaps expired/consumed
|
||||||
|
// rows (the non-user-keyed analog of CreatePasskeyChallenge's supersede) and then, if the
|
||||||
|
// live count is at the hard cap, refuses with ErrTooManyDiscoverableChallenges rather than
|
||||||
|
// inserting — the cap, not the reap, bounds an adversarial begin-flood, since a burst
|
||||||
|
// inside the TTL leaves every fresh row live. now and expiresAt are both the API clock
|
||||||
|
// (now drives the reap; expiresAt = now + TTL drives liveness).
|
||||||
|
CreateDiscoverableChallenge(ctx context.Context, id string, sessionData []byte, now, expiresAt time.Time) error
|
||||||
|
// ConsumeDiscoverableChallenge redeems the discoverable challenge under handle id,
|
||||||
|
// atomically and single-use (mirrors ConsumePasskeyChallengeByUser without the user key):
|
||||||
|
// it takes the row FOR UPDATE, checks expiry against now, stamps consumed_at, and returns
|
||||||
|
// the stashed SessionData. An unknown, expired, or already-consumed handle →
|
||||||
|
// ErrPasskeyChallengeInvalid, so the finish door never doubles as a state oracle.
|
||||||
|
ConsumeDiscoverableChallenge(ctx context.Context, id string, now time.Time) (sessionData []byte, err error)
|
||||||
// CreatePasskeyCredential stores a freshly verified passkey for a user (Phase 6
|
// CreatePasskeyCredential stores a freshly verified passkey for a user (Phase 6
|
||||||
// enrollment). It writes only public attestation material (credential_id,
|
// enrollment). It writes only public attestation material (credential_id,
|
||||||
// public_key, sign_count, aaguid) plus the caller's nickname. A credential_id
|
// public_key, sign_count, aaguid) plus the caller's nickname. A credential_id
|
||||||
|
|||||||
@@ -6,15 +6,19 @@
|
|||||||
// this package imports api for the seam types; api never imports this package, which is
|
// this package imports api for the seam types; api never imports this package, which is
|
||||||
// what keeps the seam (and the api test suite's fake verifier) honest.
|
// what keeps the seam (and the api test suite's fake verifier) honest.
|
||||||
//
|
//
|
||||||
// Scope: the full WebAuthn ceremony crypto — both the credential-creation (enrollment:
|
// Scope: the full WebAuthn ceremony crypto across three halves, each Oracle-verified in
|
||||||
// BeginRegistration/FinishRegistration over go-webauthn's BeginRegistration/CreateCredential)
|
// verifier_test.go against a virtual authenticator:
|
||||||
// and the assertion (login: BeginLogin/FinishLogin over BeginLogin/ValidateLogin) halves.
|
//
|
||||||
// Both are Oracle-verified in verifier_test.go against a virtual authenticator. Only the
|
// - enrollment (BeginRegistration/FinishRegistration over go-webauthn's
|
||||||
// enrollment half is wired to HTTP handlers today (handlers_passkey.go); the login
|
// BeginRegistration/CreateCredential),
|
||||||
// handlers, session minting, and the panel.* relying-party boundary are a deferred slice,
|
// - username-first login (BeginLogin/FinishLogin over BeginLogin/ValidateLogin), where the
|
||||||
// so BeginLogin/FinishLogin here have no api-package caller yet. They are added to the
|
// account is known and its bound credentials scope allowCredentials, and
|
||||||
// concrete adapter (not the api.PasskeyVerifier interface) precisely so the crypto is
|
// - discoverable, "usernameless" login (BeginDiscoverableLogin/FinishDiscoverableLogin over
|
||||||
// built and verified now while the interface grows only when a handler consumes it.
|
// BeginDiscoverableLogin/ValidateDiscoverableLogin), where the account is unknown at begin
|
||||||
|
// and revealed only by the userHandle inside the signed assertion (task #40).
|
||||||
|
//
|
||||||
|
// All three are in the api.PasskeyVerifier interface and consumed by handlers today (enrollment
|
||||||
|
// + login in handlers_passkey.go, from-zero login in handlers_passkey_discoverable.go).
|
||||||
package passkey
|
package passkey
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -68,6 +72,17 @@ func New(rpID, displayName string, origins []string) (*Verifier, error) {
|
|||||||
// email-OTP factor (migration 0004); no one is locked out.
|
// email-OTP factor (migration 0004); no one is locked out.
|
||||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||||
UserVerification: protocol.VerificationRequired,
|
UserVerification: protocol.VerificationRequired,
|
||||||
|
// Prefer a discoverable (resident) credential so a passkey can later be asserted
|
||||||
|
// usernamelessly (task #40 from-zero login): the authenticator stores the credential
|
||||||
|
// and can present it with no identifier typed. PREFERRED, not Required, keeps the
|
||||||
|
// no-lockout ethos — an authenticator that cannot make a resident key still binds a
|
||||||
|
// working username-first passkey (BeginLogin) and falls back to email-OTP; only the
|
||||||
|
// from-zero convenience is unavailable. This shapes only the creation options a browser
|
||||||
|
// receives (a server-side request, asserted in TestEnrollmentRequestsResidentKey);
|
||||||
|
// whether a real authenticator honors it — actually storing a resident key — is a device
|
||||||
|
// property no unit test can prove, so already-bound non-resident credentials stay
|
||||||
|
// username-first until their owner enrolls a new passkey.
|
||||||
|
ResidentKey: protocol.ResidentKeyRequirementPreferred,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -200,6 +215,68 @@ func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, asserti
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BeginDiscoverableLogin starts a USERNAMELESS assertion ceremony (task #40): the caller is
|
||||||
|
// not yet identified, so — unlike BeginLogin — there is no user and no allowCredentials. The
|
||||||
|
// authenticator picks a resident (discoverable) credential it holds for this RP and reveals
|
||||||
|
// the account only inside the signed response at finish. It returns the {"publicKey": {...}}
|
||||||
|
// request options for navigator.credentials.get() and the opaque, marshaled SessionData the
|
||||||
|
// handler stashes under an opaque handle (migration 0013's non-user-keyed store) and replays
|
||||||
|
// at finish. User verification is required, matching enrollment, so a from-zero login still
|
||||||
|
// proves possession AND user.
|
||||||
|
func (v *Verifier) BeginDiscoverableLogin() (json.RawMessage, []byte, error) {
|
||||||
|
assertion, session, err := v.wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
// CredentialAssertion marshals to {"publicKey": {...}} with an EMPTY allowCredentials —
|
||||||
|
// exactly the usernameless document the browser hands to navigator.credentials.get().
|
||||||
|
options, err := json.Marshal(assertion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
// As with the other ceremonies, we stash the marshaled SessionData verbatim and let the
|
||||||
|
// challenge row's TTL be the sole authority on liveness (no expiry inside SessionData).
|
||||||
|
sessionData, err := json.Marshal(session)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return options, sessionData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishDiscoverableLogin verifies a usernameless assertion (task #40). go-webauthn hands the
|
||||||
|
// authenticator-revealed user handle to resolveUser, which the caller uses to load the account
|
||||||
|
// and its bound credentials WITHOUT any client-supplied identifier; go-webauthn then checks
|
||||||
|
// the asserted credential id is one that user holds and verifies the signature against its
|
||||||
|
// stored COSE public key. The user handle is the account's stable id (webauthnUser.WebAuthnID),
|
||||||
|
// so resolveUser is a direct id lookup. A resolveUser error (unknown handle) fails the ceremony
|
||||||
|
// closed. resolveUser is a plain api-typed callback so the api package still never imports
|
||||||
|
// go-webauthn: the adapter wraps it into go-webauthn's DiscoverableUserHandler here.
|
||||||
|
func (v *Verifier) FinishDiscoverableLogin(resolveUser func(userHandle []byte) (api.PasskeyUser, error), sessionData []byte, assertion io.Reader) (api.VerifiedAssertion, error) {
|
||||||
|
var session webauthn.SessionData
|
||||||
|
if err := json.Unmarshal(sessionData, &session); err != nil {
|
||||||
|
return api.VerifiedAssertion{}, err
|
||||||
|
}
|
||||||
|
parsed, err := protocol.ParseCredentialRequestResponseBody(assertion)
|
||||||
|
if err != nil {
|
||||||
|
return api.VerifiedAssertion{}, err
|
||||||
|
}
|
||||||
|
handler := func(_, userHandle []byte) (webauthn.User, error) {
|
||||||
|
u, err := resolveUser(userHandle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return webauthnUser{u: u}, nil
|
||||||
|
}
|
||||||
|
cred, err := v.wa.ValidateDiscoverableLogin(handler, session, parsed)
|
||||||
|
if err != nil {
|
||||||
|
return api.VerifiedAssertion{}, err
|
||||||
|
}
|
||||||
|
return api.VerifiedAssertion{
|
||||||
|
CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID),
|
||||||
|
SignCount: cred.Authenticator.SignCount,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// excludeDescriptors turns the principal's already-bound passkeys into the
|
// excludeDescriptors turns the principal's already-bound passkeys into the
|
||||||
// excludeCredentials list for a creation ceremony. A stored credential id that does not
|
// excludeCredentials list for a creation ceremony. A stored credential id that does not
|
||||||
// decode as base64url is skipped rather than aborting the whole ceremony — a single
|
// decode as base64url is skipped rather than aborting the whole ceremony — a single
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package passkey
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -333,3 +334,179 @@ func TestLoginUnknownCredentialRejected(t *testing.T) {
|
|||||||
t.Fatal("FinishLogin accepted an assertion from an unbound credential; want rejection")
|
t.Fatal("FinishLogin accepted an assertion from an unbound credential; want rejection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDiscoverableLoginRoundTrip is the PARITY check for the usernameless (from-zero) half
|
||||||
|
// (task #40), and the proof its VERIFY path is real crypto rather than a stub. It differs from
|
||||||
|
// TestLoginRoundTrip in the two ways that define discoverable login: the begin names no user
|
||||||
|
// (so the request's allowCredentials must be EMPTY), and the account is revealed only by the
|
||||||
|
// userHandle the authenticator embeds in the signed assertion — the verifier hands that handle
|
||||||
|
// to a resolve callback that stands in for the handler's userHandle → UserByID lookup. Chained
|
||||||
|
// onto a REAL enrollment so the assertion validates against a genuine COSE key, and the
|
||||||
|
// authenticator's counter is advanced first so the surfaced SignCount is proven real, not a
|
||||||
|
// hardcoded 0. What this does NOT prove: that a real authenticator actually STORED a resident
|
||||||
|
// key — that residency is a device property (see TestEnrollmentRequestsResidentKey for the only
|
||||||
|
// thing a unit test can pin, the request the browser receives).
|
||||||
|
func TestDiscoverableLoginRoundTrip(t *testing.T) {
|
||||||
|
v := newTestVerifier(t)
|
||||||
|
rp := virtualRP()
|
||||||
|
authenticator := virtualwebauthn.NewAuthenticator()
|
||||||
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||||
|
|
||||||
|
stored := enrollCredential(t, v, rp, authenticator, cred)
|
||||||
|
|
||||||
|
// The authenticator returns the user handle in the assertion — this is what a resident
|
||||||
|
// credential does and what lets the account be resolved from nothing typed. It is the
|
||||||
|
// account's stable user id (WebAuthnID), so the resolver must receive exactly these bytes.
|
||||||
|
authenticator.Options.UserHandle = []byte(testUserID)
|
||||||
|
// Advance the counter so a real (non-zero, strictly increasing) SignCount must survive.
|
||||||
|
cred.Counter = 9
|
||||||
|
|
||||||
|
options, sessionData, err := v.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginDiscoverableLogin: %v", err)
|
||||||
|
}
|
||||||
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAssertionOptions: %v (options=%s)", err, options)
|
||||||
|
}
|
||||||
|
if assertionOpts.RelyingPartyID != testRPID {
|
||||||
|
t.Fatalf("options RP id = %q, want %q", assertionOpts.RelyingPartyID, testRPID)
|
||||||
|
}
|
||||||
|
// The defining property of a usernameless request: no credential is named. If this were
|
||||||
|
// non-empty the ceremony would be username-first and the test would prove nothing about #40.
|
||||||
|
if len(assertionOpts.AllowCredentials) != 0 {
|
||||||
|
t.Fatalf("allowCredentials = %v, want empty (usernameless request names no credential)", assertionOpts.AllowCredentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
|
||||||
|
|
||||||
|
// resolve stands in for the handler's userHandle → UserByID lookup: it records the handle
|
||||||
|
// it was handed (to prove the account is revealed by the authenticator, not the client) and
|
||||||
|
// returns the stored credential so ValidateDiscoverableLogin can verify the signature.
|
||||||
|
var gotHandle []byte
|
||||||
|
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
|
||||||
|
gotHandle = userHandle
|
||||||
|
return testUser(stored), nil
|
||||||
|
}
|
||||||
|
va, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FinishDiscoverableLogin: %v", err)
|
||||||
|
}
|
||||||
|
if string(gotHandle) != testUserID {
|
||||||
|
t.Errorf("resolver received userHandle %q, want %q (the account is revealed by the assertion)", gotHandle, testUserID)
|
||||||
|
}
|
||||||
|
if va.CredentialID != stored.CredentialID {
|
||||||
|
t.Errorf("asserted CredentialID = %q, want %q", va.CredentialID, stored.CredentialID)
|
||||||
|
}
|
||||||
|
if va.SignCount != 9 {
|
||||||
|
t.Errorf("SignCount = %d, want 9 (the authenticator's advanced counter)", va.SignCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscoverableLoginResolveFailsClosed proves the from-zero door fails CLOSED when the
|
||||||
|
// authenticator-revealed account cannot be resolved: a resolve callback that returns an error
|
||||||
|
// (the handler's UserByID found nothing — a handle for a deleted/unknown account) must abort
|
||||||
|
// the ceremony, never mint an assertion. Without this the usernameless path could be coaxed
|
||||||
|
// into treating an unresolvable handle as success. Pairs with the round-trip above so the
|
||||||
|
// resolver neither over- nor under-blocks.
|
||||||
|
func TestDiscoverableLoginResolveFailsClosed(t *testing.T) {
|
||||||
|
v := newTestVerifier(t)
|
||||||
|
rp := virtualRP()
|
||||||
|
authenticator := virtualwebauthn.NewAuthenticator()
|
||||||
|
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||||
|
|
||||||
|
enrollCredential(t, v, rp, authenticator, cred)
|
||||||
|
authenticator.Options.UserHandle = []byte("nonexistent-account")
|
||||||
|
|
||||||
|
options, sessionData, err := v.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginDiscoverableLogin: %v", err)
|
||||||
|
}
|
||||||
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAssertionOptions: %v", err)
|
||||||
|
}
|
||||||
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
|
||||||
|
|
||||||
|
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
|
||||||
|
return api.PasskeyUser{}, api.ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)); err == nil {
|
||||||
|
t.Fatal("FinishDiscoverableLogin accepted an assertion whose account could not be resolved; want rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiscoverableLoginUnboundCredentialRejected proves the credential-ownership binding for
|
||||||
|
// the usernameless door — the defense unique to it. In username-first login the server names
|
||||||
|
// allowCredentials, so an assertion must match a credential the server itself offered. The
|
||||||
|
// from-zero door names NOTHING: the authenticator reveals BOTH the userHandle and the signing
|
||||||
|
// credential, so the ONLY barrier stopping an attacker from signing with their own resident key
|
||||||
|
// while embedding a victim's userHandle is go-webauthn's check that the asserted credential id
|
||||||
|
// belongs to the resolved user. Here the resolve callback succeeds (the handle names a REAL
|
||||||
|
// account, u1, holding credential A) — unlike TestDiscoverableLoginResolveFailsClosed where it
|
||||||
|
// resolves to nothing — but the assertion is signed by credential B, never bound to u1.
|
||||||
|
// FinishDiscoverableLogin must reject: a good signature over the right challenge under a valid
|
||||||
|
// userHandle is still not enough without membership. This is the exact guard the "account is
|
||||||
|
// revealed by the assertion, never named by the client" claim leans on.
|
||||||
|
func TestDiscoverableLoginUnboundCredentialRejected(t *testing.T) {
|
||||||
|
v := newTestVerifier(t)
|
||||||
|
rp := virtualRP()
|
||||||
|
authenticator := virtualwebauthn.NewAuthenticator()
|
||||||
|
credA := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||||
|
stored := enrollCredential(t, v, rp, authenticator, credA)
|
||||||
|
|
||||||
|
// A valid handle: it resolves to the real account u1, which holds credential A.
|
||||||
|
authenticator.Options.UserHandle = []byte(testUserID)
|
||||||
|
|
||||||
|
options, sessionData, err := v.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginDiscoverableLogin: %v", err)
|
||||||
|
}
|
||||||
|
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAssertionOptions: %v", err)
|
||||||
|
}
|
||||||
|
// Sign with a fresh credential never bound to u1. The resolver still returns u1's real
|
||||||
|
// credential set (credential A) — so the ONLY thing that can reject this is the check that
|
||||||
|
// the asserted credential (B) is among the resolved user's credentials.
|
||||||
|
credB := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
|
||||||
|
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credB, *assertionOpts)
|
||||||
|
|
||||||
|
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
|
||||||
|
return testUser(stored), nil
|
||||||
|
}
|
||||||
|
if _, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)); err == nil {
|
||||||
|
t.Fatal("FinishDiscoverableLogin accepted an assertion signed by a credential not bound to the resolved user; want rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnrollmentRequestsResidentKey pins the ONLY server-side half of the from-zero enabler a
|
||||||
|
// unit test can prove: that enrollment ASKS the browser for a resident (discoverable) key, i.e.
|
||||||
|
// the creation options carry authenticatorSelection.residentKey = "preferred". Whether a real
|
||||||
|
// authenticator honors the request — actually persisting a resident key so it can later be
|
||||||
|
// asserted usernamelessly — is a device property no unit test can reach, which is exactly why
|
||||||
|
// the from-zero door is inert for a credential until its owner enrolls a NEW passkey against
|
||||||
|
// these options. "preferred" (not "required") is deliberate: an authenticator that cannot store
|
||||||
|
// a resident key still binds a working username-first passkey and falls back to email-OTP, so
|
||||||
|
// no one is locked out — asserting the exact string guards against a silent drop to "" (ask for
|
||||||
|
// nothing) or a tightening to "required" (which would break the no-lockout ethos).
|
||||||
|
func TestEnrollmentRequestsResidentKey(t *testing.T) {
|
||||||
|
v := newTestVerifier(t)
|
||||||
|
options, _, err := v.BeginRegistration(testUser())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginRegistration: %v", err)
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
PublicKey struct {
|
||||||
|
AuthenticatorSelection struct {
|
||||||
|
ResidentKey string `json:"residentKey"`
|
||||||
|
} `json:"authenticatorSelection"`
|
||||||
|
} `json:"publicKey"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(options, &doc); err != nil {
|
||||||
|
t.Fatalf("unmarshal creation options: %v (options=%s)", err, options)
|
||||||
|
}
|
||||||
|
if got := doc.PublicKey.AuthenticatorSelection.ResidentKey; got != "preferred" {
|
||||||
|
t.Errorf("authenticatorSelection.residentKey = %q, want %q", got, "preferred")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Phase 6 passkey — the challenge store for DISCOVERABLE ("usernameless") login (spec §14,
|
||||||
|
-- task #40). webauthn_challenges (0007) is keyed by (user_id, purpose) because both
|
||||||
|
-- enrollment and email-first login already know WHO is authenticating before the ceremony
|
||||||
|
-- starts. A from-zero passkey login does not: the browser calls navigator.credentials.get()
|
||||||
|
-- with an EMPTY allowCredentials list, the authenticator offers a resident credential it
|
||||||
|
-- holds for this RP, and the account is revealed only inside the signed assertion at finish.
|
||||||
|
-- So this challenge cannot be keyed by user — it is keyed by an opaque, server-minted handle
|
||||||
|
-- (login_id) the browser echoes back at finish, and the marshaled WebAuthn SessionData is the
|
||||||
|
-- only server-held ceremony state. This is exactly the "non-user-keyed challenge store, a
|
||||||
|
-- future migration" that 0007's own comment anticipated.
|
||||||
|
--
|
||||||
|
-- Bounding. webauthn_challenges self-bounds via a per-(user,purpose) supersede-DELETE on each
|
||||||
|
-- begin — one live row per user+purpose. That key does not exist here (there is no user at
|
||||||
|
-- begin), so this table is bounded two ways instead, both inside CreateDiscoverableChallenge's
|
||||||
|
-- one transaction: (1) every begin first reaps rows that already expired or were consumed by a
|
||||||
|
-- prior finish, and (2) a hard cap (maxLiveDiscoverableChallenges) refuses a new begin once the
|
||||||
|
-- live count is reached, so an abusive begin-flood is bounded to trivial storage rather than
|
||||||
|
-- growing without limit. A reap alone does NOT bound a burst — freshly inserted rows have a
|
||||||
|
-- future expiry, so N begins inside the TTL leave N live rows — which is why the cap, not the
|
||||||
|
-- reap, is the real ceiling. Volumetric per-source (client-IP) limiting is deliberately left to
|
||||||
|
-- the edge, the same stance handlers_auth_email.go documents (behind Cloudflare RemoteAddr is
|
||||||
|
-- the proxy, and CGNAT would false-positive) and unavoidable here since a usernameless door has
|
||||||
|
-- neither a principal NOR a typed recipient to key a fair per-caller limit on.
|
||||||
|
--
|
||||||
|
-- The expires_at index serves the reap's WHERE clause; consumed_at (nullable) makes the row
|
||||||
|
-- single-use, stamped at finish and swept by a later begin's reap.
|
||||||
|
CREATE TABLE webauthn_discoverable_challenges (
|
||||||
|
id text PRIMARY KEY, -- opaque login handle (login_id): 128-bit hex
|
||||||
|
session_data bytea NOT NULL, -- marshaled webauthn.SessionData (the challenge lives here)
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
consumed_at timestamptz, -- single-use: NULL until finish stamps it
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX webauthn_discoverable_challenges_expires_idx
|
||||||
|
ON webauthn_discoverable_challenges (expires_at);
|
||||||
Reference in new issue
Block a user