Documentation
¶
Overview ¶
Package passkey implements WebAuthn-based passkey registration and authentication.
It wraps github.com/go-webauthn/webauthn to provide a higher-level API with pluggable storage for credentials and challenges.
Index ¶
- Variables
- type ChallengeStore
- type Credential
- type DeleteOptions
- type Option
- type Service
- func (s *Service) BeginDiscoverableLogin(ctx context.Context) (*protocol.CredentialAssertion, string, error)
- func (s *Service) BeginLogin(ctx context.Context, user *User) (*protocol.CredentialAssertion, string, error)
- func (s *Service) BeginRegistration(ctx context.Context, user *User) (*protocol.CredentialCreation, error)
- func (s *Service) DeleteCredential(ctx context.Context, userID, id string, opts DeleteOptions) error
- func (s *Service) FinishDiscoverableLogin(ctx context.Context, ceremonyID string, r *http.Request) (*Credential, error)
- func (s *Service) FinishDiscoverableLoginResponse(ctx context.Context, ceremonyID string, body []byte) (*Credential, error)
- func (s *Service) FinishLogin(ctx context.Context, user *User, ceremonyID string, r *http.Request) (*Credential, error)
- func (s *Service) FinishLoginResponse(ctx context.Context, user *User, ceremonyID string, body []byte) (*Credential, error)
- func (s *Service) FinishRegistration(ctx context.Context, user *User, r *http.Request) (*Credential, error)
- func (s *Service) FinishRegistrationResponse(ctx context.Context, user *User, body []byte) (*Credential, error)
- type Store
- type User
- type WebAuthnConfig
Constants ¶
This section is empty.
Variables ¶
var ( ErrPasskeyNotFound = errors.New("passkey: credential not found") ErrChallengeFailed = errors.New("passkey: challenge verification failed") ErrChallengeExpired = errors.New("passkey: challenge expired or not found") ErrCloneWarning = errors.New("passkey: credential clone detected (sign count anomaly)") // ErrCeremonyBodyTooLarge is returned when a ceremony response body // exceeds the configured WithMaxCeremonyBody limit. See that option's // GoDoc for why the cap exists. ErrCeremonyBodyTooLarge = errors.New("passkey: ceremony response body too large") // ErrLastCredential is returned by Service.DeleteCredential (via the // atomic guard in Store.DeleteCredential) when id names the only // credential userID has registered and DeleteOptions.AllowLast was not // set. See DeleteOptions for why this package can't decide that on its // own, and Store.DeleteCredential for why the guard must be atomic. ErrLastCredential = errors.New("passkey: cannot delete the account's last credential without DeleteOptions.AllowLast") )
Functions ¶
This section is empty.
Types ¶
type ChallengeStore ¶
type ChallengeStore interface {
SaveChallenge(ctx context.Context, key string, sessionData []byte) error
// ConsumeChallenge atomically fetches and deletes the challenge data
// stored under key in a single operation, so that only one caller can
// ever receive a given challenge: two concurrent finishes of the same
// ceremony must not both succeed in retrieving it. Reference
// implementations: Redis GETDEL, or SQL "DELETE ... RETURNING" (or an
// equivalent SELECT ... FOR UPDATE followed by DELETE inside a single
// transaction).
//
// Returns an implementation-defined not-found error if no challenge is
// stored under key (already consumed, expired, or never saved); the
// caller normalizes any error from this method to ErrChallengeExpired.
ConsumeChallenge(ctx context.Context, key string) ([]byte, error)
}
ChallengeStore handles the transient WebAuthn challenge/session data needed between the begin and finish steps of registration/authentication.
Keys are opaque strings scoped by ceremony (e.g. "register:<userID>", "login:<ceremonyID>", or "discover:<ceremonyID>"), not bare user IDs, so that concurrent ceremonies for the same user cannot clobber each other's saved challenge. Implementations should expire entries after roughly 5 minutes, matching the lifetime of a WebAuthn ceremony.
type Credential ¶
type Credential struct {
ID string
UserID string
CredentialID []byte
PublicKey []byte
AttestationType string
AAGUID []byte
SignCount uint32
// Name is caller-supplied display metadata for this credential (e.g.
// "YubiKey 5C", "Work laptop"). passkey never generates, infers, or
// validates it — it starts empty and is only ever set through
// Store.RenameCredential. A management UI should fall back to something
// derived from CreatedAt/Transports when Name is empty.
Name string
// Transports lists the transports the client reported the authenticator
// supports (e.g. "usb", "nfc", "ble", "hybrid", "internal") — the
// registration response's "transports" field, populated from
// waCredential.Transport in FinishRegistration. It reflects what the
// client reported once, at registration time, and is not re-verified or
// refreshed on subsequent logins.
Transports []protocol.AuthenticatorTransport
// BackupEligible reports whether the credential's authenticator is
// capable of being backed up or synced across devices (the "BE" bit in
// the authenticator data). Unlike Discoverable this is a verified
// property, not merely client-reported: go-webauthn derives it from the
// signed authenticator data on every ceremony and — critically —
// re-checks it for consistency on every subsequent login. The value
// persisted here must be fed back into every later ceremony's
// credential list (see toWebAuthnCreds), or a genuinely backup-eligible
// credential's next login is rejected with "Backup Eligible flag
// inconsistency detected".
BackupEligible bool
// BackupState reports whether the credential is *currently* backed up
// (the "BS" bit). Unlike BackupEligible this can change over a
// credential's lifetime — e.g. a synced passkey moves in or out of a
// device's keychain backup — so it is re-derived and re-persisted on
// every successful login (see Store.UpdateCredentialAfterLogin), not
// only set once at registration.
BackupState bool
// LastUsedAt records when this credential last completed a successful
// login assertion — FinishLogin, FinishDiscoverableLogin, or their
// []byte cores. It is nil until the credential's first
// post-registration login: registering a credential does not count as
// using it.
LastUsedAt *time.Time
// Discoverable records whether the authenticator created a client-side
// discoverable ("resident key") credential — the kind
// Service.BeginDiscoverableLogin's usernameless login needs in order to
// find a credential without the caller supplying a username first.
//
// It is populated from the client's "credProps" extension output
// (credProps.rk) on the registration response: true only when the
// client explicitly reported rk == true, false otherwise (extension
// absent, or credProps.rk == false). This is the only place
// go-webauthn v0.17.4 surfaces a resident-key signal at all — the
// finished credential's own Authenticator/Flags fields (UserPresent,
// UserVerified, BackupEligible, BackupState) have no such bit, and
// BackupEligible is a related but distinct property (can the
// credential be synced/backed up, not whether it's discoverable
// without a credential ID).
//
// LIMITATION: credProps is a client-reported (browser) extension
// output, not part of the signed attestation object — it is not
// cryptographically verified, and an older browser or authenticator
// may omit it entirely even for a credential that is, in fact,
// discoverable. Treat a false value as "not confirmed discoverable",
// not as proof the credential isn't; this under-reports rather than
// over-reports, so BeginDiscoverableLogin may simply fail to offer such
// a credential rather than something being accepted that shouldn't be.
Discoverable bool
CreatedAt time.Time
}
Credential represents a registered WebAuthn/passkey credential.
type DeleteOptions ¶
type DeleteOptions struct {
// AllowLast permits removing a user's only remaining credential.
// passkey has no visibility into whether userID's application account
// has any other way to authenticate (a password, another second
// factor) — it only knows about its own credentials — so the caller
// must set this to true only after establishing, through its own
// re-authentication or explicit confirmation flow, that removing the
// account's last passkey is intentional and the account will remain
// reachable afterward.
AllowLast bool
}
DeleteOptions configures Service.DeleteCredential.
type Option ¶
type Option func(*serviceConfig)
Option configures a passkey Service.
func WithMaxCeremonyBody ¶
WithMaxCeremonyBody caps how many bytes of a ceremony response body FinishRegistration, FinishLogin, and FinishDiscoverableLogin (and their []byte-taking counterparts) will read or accept, in bytes. max must be > 0.
The default is 64 KiB. go-webauthn's own body decoding (protocol.decodeBody, in its unexported decoder.go) is a bare json.NewDecoder(body).Decode(v) with no limit at all — an attacker who can reach a Finish endpoint could otherwise send an arbitrarily large body and have it read fully into memory before any validation runs. Because sulis owns the *http.Request in the http.go wrappers, it can — and does — impose this limit itself via http.MaxBytesReader; the same limit is also enforced in the []byte-taking core methods so a caller that bypasses net/http entirely gets the same bound.
func WithResidentKey ¶
func WithResidentKey(rk protocol.ResidentKeyRequirement) Option
WithResidentKey sets whether registration asks the authenticator to create a client-side discoverable ("resident key") credential — the kind BeginDiscoverableLogin's usernameless login depends on being able to find without the relying party supplying a credential ID first.
The default is protocol.ResidentKeyRequirementRequired, and it should stay there for any Service that offers BeginDiscoverableLogin: a passkey that isn't discoverable can't be found by usernameless login, so registration would silently produce credentials the feature can't use, and the fallback to typing a username trains users back onto the thing passkeys are meant to replace. Use protocol.ResidentKeyRequirementPreferred or ...Discouraged only when usernameless login is not offered and every caller of BeginLogin always supplies an identified user first.
func WithUserVerification ¶
func WithUserVerification(uv protocol.UserVerificationRequirement) Option
WithUserVerification sets whether the authenticator must verify the user — a PIN, a biometric — rather than merely confirming that someone is present.
The default is protocol.VerificationRequired, and it should stay there for a passwordless passkey: user verification is what makes the credential two factors ("something you have" plus "something you are") instead of bare possession of an unlocked device. protocol.VerificationDiscouraged is only defensible when the passkey is a SECOND factor behind a verified password.
This must be set for the check to happen at all: go-webauthn only verifies the UV flag when the ceremony's session data says VerificationRequired.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service manages WebAuthn passkey registration and authentication.
func NewService ¶
func NewService(store Store, challenges ChallengeStore, cfg WebAuthnConfig, opts ...Option) (*Service, error)
NewService creates a new passkey service with the given stores and configuration.
func (*Service) BeginDiscoverableLogin ¶
func (s *Service) BeginDiscoverableLogin(ctx context.Context) (*protocol.CredentialAssertion, string, error)
BeginDiscoverableLogin starts a usernameless ("discoverable") WebAuthn authentication ceremony: the caller does not need to know the user's identity up front, since the authenticator itself supplies it during FinishDiscoverableLogin. Returns the credential assertion options to send to the client and a ceremony ID that the caller must round-trip to FinishDiscoverableLogin.
func (*Service) BeginLogin ¶
func (s *Service) BeginLogin(ctx context.Context, user *User) (*protocol.CredentialAssertion, string, error)
BeginLogin starts the WebAuthn authentication ceremony. Returns the credential assertion options to send to the client and a ceremony ID that the caller must round-trip to FinishLogin. The challenge is keyed by this ceremony ID rather than by user ID so that a second login ceremony started for the same user (e.g. from a different device) cannot clobber the first ceremony's saved challenge.
func (*Service) BeginRegistration ¶
func (s *Service) BeginRegistration(ctx context.Context, user *User) (*protocol.CredentialCreation, error)
BeginRegistration starts the WebAuthn registration ceremony. Returns the credential creation options to send to the client. The options' excludeCredentials list is populated from the store's existing credentials for this user, so the authenticator can tell the browser "you already registered this key" instead of silently creating a duplicate credential.
func (*Service) DeleteCredential ¶
func (s *Service) DeleteCredential(ctx context.Context, userID, id string, opts DeleteOptions) error
DeleteCredential removes the credential identified by id (a Credential.ID value — the store's own opaque ID, not the raw WebAuthn Credential.CredentialID) after confirming it belongs to userID.
Deleting a user's only remaining credential is rejected with ErrLastCredential unless opts.AllowLast is set — see DeleteOptions for why this package can't make that call on its own and what the caller must establish before setting it. The guard only ever sees passkey's own credential count for userID: it has no way to know whether the account has a password or another second factor, so a caller whose account can only ever authenticate via passkey must set AllowLast with particular care.
This method is a thin wrapper: the guard itself — the membership check, the remaining-count check, and the removal — is implemented entirely by Store.DeleteCredential as one atomic operation. It does not live here as a separate "load, check, then delete" sequence, because that would reopen exactly the race the guard exists to close: two concurrent calls for two different credentials of the same last-two-credential user could each load the pre-deletion count before either delete lands, both pass a Service-level check, and both succeed — see Store.DeleteCredential's GoDoc for the full reasoning and reference implementations.
func (*Service) FinishDiscoverableLogin ¶
func (s *Service) FinishDiscoverableLogin(ctx context.Context, ceremonyID string, r *http.Request) (*Credential, error)
FinishDiscoverableLogin completes a usernameless WebAuthn authentication ceremony started by BeginDiscoverableLogin, reading its request body the same way FinishRegistration does. ceremonyID must be the value returned by the matching BeginDiscoverableLogin call.
The http.Request must contain the authenticator's response body.
func (*Service) FinishDiscoverableLoginResponse ¶
func (s *Service) FinishDiscoverableLoginResponse(ctx context.Context, ceremonyID string, body []byte) (*Credential, error)
FinishDiscoverableLoginResponse completes a usernameless WebAuthn authentication ceremony started by BeginDiscoverableLogin, from the raw response body — the []byte-taking core that FinishDiscoverableLogin (in http.go) wraps for net/http callers, and the entry point for callers that don't use net/http at all. ceremonyID must be the value returned by the matching BeginDiscoverableLogin call. The user is resolved from the credential's stored owner rather than being supplied by the caller.
body must not exceed the Service's configured WithMaxCeremonyBody limit (default 64 KiB); a larger body is rejected up front, before the challenge is consumed or any JSON parsing happens, with ErrCeremonyBodyTooLarge.
Returns the credential that was used for authentication.
The challenge is consumed before verification runs, so a failed verification still burns it — the safe direction, same policy as sulis's consumeToken: a rejected assertion cannot be retried against the same challenge.
func (*Service) FinishLogin ¶
func (s *Service) FinishLogin(ctx context.Context, user *User, ceremonyID string, r *http.Request) (*Credential, error)
FinishLogin completes the WebAuthn authentication ceremony started by BeginLogin, reading its request body the same way FinishRegistration does. ceremonyID must be the value returned by the matching BeginLogin call.
The http.Request must contain the authenticator's response body.
func (*Service) FinishLoginResponse ¶
func (s *Service) FinishLoginResponse(ctx context.Context, user *User, ceremonyID string, body []byte) (*Credential, error)
FinishLoginResponse completes the WebAuthn authentication ceremony started by BeginLogin, from the raw response body — the []byte-taking core that FinishLogin (in http.go) wraps for net/http callers, and the entry point for callers that don't use net/http at all. ceremonyID must be the value returned by the matching BeginLogin call.
body must not exceed the Service's configured WithMaxCeremonyBody limit (default 64 KiB); a larger body is rejected up front, before the challenge is consumed or any JSON parsing happens, with ErrCeremonyBodyTooLarge.
Returns the credential that was used for authentication.
The challenge is consumed before verification runs, so a failed verification still burns it — the safe direction, same policy as sulis's consumeToken: a rejected assertion cannot be retried against the same challenge.
func (*Service) FinishRegistration ¶
func (s *Service) FinishRegistration(ctx context.Context, user *User, r *http.Request) (*Credential, error)
FinishRegistration completes the WebAuthn registration ceremony from an *http.Request. It is a thin wrapper around FinishRegistrationResponse: the request body is read through readCeremonyBody, which caps it at the Service's configured WithMaxCeremonyBody limit (default 64 KiB) via http.MaxBytesReader — so an oversized body is rejected as it's being read, never buffered into memory in full.
The http.Request must contain the authenticator's response body.
func (*Service) FinishRegistrationResponse ¶
func (s *Service) FinishRegistrationResponse(ctx context.Context, user *User, body []byte) (*Credential, error)
FinishRegistrationResponse completes the WebAuthn registration ceremony from the raw response body — the []byte-taking core that FinishRegistration (in http.go) wraps for net/http callers, and the entry point for callers that don't use net/http at all.
body must not exceed the Service's configured WithMaxCeremonyBody limit (default 64 KiB); a larger body is rejected up front, before the challenge is consumed or any JSON parsing happens, with ErrCeremonyBodyTooLarge.
The challenge is consumed before verification runs, so a failed verification still burns it — the safe direction, same policy as sulis's consumeToken: a rejected registration cannot be retried against the same challenge.
type Store ¶
type Store interface {
SaveCredential(ctx context.Context, cred *Credential) error
GetCredentialsByUserID(ctx context.Context, userID string) ([]Credential, error)
GetCredentialByID(ctx context.Context, credentialID []byte) (*Credential, error)
// UpdateCredentialAfterLogin persists the bookkeeping that must change
// on every successful login assertion: SignCount (clone detection),
// BackupState (can flip independently of BackupEligible — see
// Credential.BackupState), and LastUsedAt. go-webauthn's own storage
// guidance (the "Storage" section of the
// github.com/go-webauthn/webauthn/webauthn package doc) says sign
// count, clone-warning, and BackupState-when-BackupEligible MUST be
// written back on every successful FinishLogin/ValidateLogin so the
// next ceremony observes current values; bundling all three in one
// store call keeps that invariant enforceable in one place, rather
// than splitting it across calls a caller could apply out of order or
// only partially. This replaces the narrower UpdateCredentialSignCount.
UpdateCredentialAfterLogin(ctx context.Context, credentialID []byte, signCount uint32, backupState bool, lastUsedAt time.Time) error
// DeleteCredential removes the credential identified by id (a
// Credential.ID value — the store's own opaque ID, not the raw
// WebAuthn Credential.CredentialID) if it belongs to userID.
//
// If id is userID's only remaining credential and allowLast is false,
// the store MUST refuse the deletion and return ErrLastCredential
// instead of removing it. The membership check, the remaining-count
// check, and the removal itself MUST happen as a single atomic
// operation with respect to any concurrent call for the same userID —
// this is the same requirement ChallengeStore.ConsumeChallenge,
// TokenStore.ConsumeToken, and recovery.Store.ConsumeCode already place
// on their own check-and-mutate operations, for the same reason: a
// separate read-then-write lets two concurrent callers each observe the
// pre-mutation state before either mutation lands. Concretely, without
// atomicity here, two goroutines each deleting one of a user's last two
// credentials could both read count==2, both pass the guard with
// allowLast==false, and both succeed — leaving the user with zero
// credentials, exactly the lockout state this guard exists to prevent,
// reached through the guarded path.
//
// Reference implementations: SQL — run the count check and the DELETE
// inside one transaction after locking the user's credential rows
// (SELECT ... FOR UPDATE), or express both in one statement, e.g.
// "DELETE FROM credentials WHERE id = $1 AND user_id = $2 AND
// ($3 OR (SELECT COUNT(*) FROM credentials WHERE user_id = $2) > 1)"
// and check the affected-row count to distinguish "deleted" from
// "refused" (also handling "id didn't exist" — see below). A
// single-threaded or mutex-guarded in-memory store can simply perform
// the check and the removal while holding the same lock.
//
// Returns ErrPasskeyNotFound if id does not name a credential owned by
// userID.
//
// Service.DeleteCredential is a thin wrapper around this method: the
// last-credential guard lives here, in the store, not in Service,
// specifically so the check and the mutation cannot be split across
// two separate calls the way a Service-level "load, check, then
// delete" would split them.
DeleteCredential(ctx context.Context, userID, id string, allowLast bool) error
// DeleteCredentialsByUserID removes every credential owned by userID —
// e.g. as part of deleting the user's whole account. It does not apply
// the last-credential guard Service.DeleteCredential enforces:
// deleting an entire account is a stronger action that the caller has
// presumably already gated on its own, and silently leaving one
// credential behind because it "happened to be last" would be
// surprising here.
DeleteCredentialsByUserID(ctx context.Context, userID string) error
// RenameCredential sets Credential.Name — caller-supplied display
// metadata that passkey itself never generates or validates. Returns
// ErrPasskeyNotFound if id does not match a stored credential.
RenameCredential(ctx context.Context, id, name string) error
}
Store defines the persistence operations for passkey credentials.
type User ¶
User identifies a consumer's user account to the passkey Service. Consumers create this from their own user type when calling Service methods.
type WebAuthnConfig ¶
type WebAuthnConfig struct {
RPDisplayName string // human-readable name, e.g. "My Application"
RPID string // relying party ID, e.g. "example.com"
RPOrigins []string // allowed origins, e.g. ["https://example.com"]
}
WebAuthnConfig holds the configuration for the WebAuthn relying party.