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
@@ -81,6 +81,12 @@ type fakeRepo struct {
|
||||
// just as the PG query does.
|
||||
passkeyCreds map[string]PasskeyCredential
|
||||
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
|
||||
seededUsers []seededUser
|
||||
fakeQuotas map[string]*QuotaView
|
||||
@@ -99,6 +105,15 @@ type fakePasskeyChallenge struct {
|
||||
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
|
||||
// (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
|
||||
@@ -191,7 +206,9 @@ func newFakeRepo() *fakeRepo {
|
||||
holds: map[string]fakeDataHold{},
|
||||
passkeyCreds: map[string]PasskeyCredential{},
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// → ErrConflict (the UNIQUE guard), never a silent rebind.
|
||||
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.
|
||||
lastUser PasskeyUser
|
||||
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) {
|
||||
@@ -477,6 +523,33 @@ func (v *fakePasskeyVerifier) FinishLogin(user PasskeyUser, sessionData []byte,
|
||||
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) {
|
||||
return f.allowlist[n][u], nil
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user