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:
flyemoji committed 2026-07-05 04:05:56 +09:00
1 parent 7db57b9fff
commit ec468baef9
12 files changed
+1193 -21

No files matched your search

+86 -9
View File
@@ -6,15 +6,19 @@
// 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.
//
// Scope: the full WebAuthn ceremony crypto — both the credential-creation (enrollment:
// BeginRegistration/FinishRegistration over go-webauthn's BeginRegistration/CreateCredential)
// 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 half is wired to HTTP handlers today (handlers_passkey.go); the login
// handlers, session minting, and the panel.* relying-party boundary are a deferred slice,
// so BeginLogin/FinishLogin here have no api-package caller yet. They are added to the
// concrete adapter (not the api.PasskeyVerifier interface) precisely so the crypto is
// built and verified now while the interface grows only when a handler consumes it.
// Scope: the full WebAuthn ceremony crypto across three halves, each Oracle-verified in
// verifier_test.go against a virtual authenticator:
//
// - enrollment (BeginRegistration/FinishRegistration over go-webauthn's
// BeginRegistration/CreateCredential),
// - username-first login (BeginLogin/FinishLogin over BeginLogin/ValidateLogin), where the
// account is known and its bound credentials scope allowCredentials, and
// - discoverable, "usernameless" login (BeginDiscoverableLogin/FinishDiscoverableLogin over
// 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
import (
@@ -68,6 +72,17 @@ func New(rpID, displayName string, origins []string) (*Verifier, error) {
// email-OTP factor (migration 0004); no one is locked out.
AuthenticatorSelection: protocol.AuthenticatorSelection{
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 {
@@ -200,6 +215,68 @@ func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, asserti
}, 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
// 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