webauthn

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 8, 2026 License: AGPL-3.0 Imports: 14 Imported by: 0

Documentation

Overview

Package webauthn provides passkey registration and login over github.com/go-webauthn/webauthn, and the ceremony store that makes it work on more than one replica.

The ceremony is stateful, and that state is the hard part

A WebAuthn ceremony spans two round trips. The server issues a challenge, the authenticator signs it, and the server verifies the signature against the challenge it issued — which means the challenge, and the rest of the SessionData beside it, has to still be there when the second request arrives. The reference examples for every Go WebAuthn library keep that in a map or in a cookie-backed session, and both are wrong the moment there is a second replica: the challenge is issued by one instance and verified by another, and the login fails intermittently in a way that looks like a client bug.

SessionStore is that state, behind an interface with two implementations — authentication/webauthn/database, which is a table, and authentication/webauthn/cache, which is a cache.Cache. Both survive a second replica as long as what backs them does; a memory cache does not, and its doc says so.

The user is yours

This package names no user type. User is go-webauthn's own interface — a handle, a name, a display name, and the credentials — and adapting an application's user to it is twenty lines the application writes, because only the application knows where its credentials are stored. What is here is the protocol half: the ceremonies, their state, and the deadline that bounds them.

Storing credentials is likewise the application's: a Credential returned by FinishRegistration is a value to persist, and the sign count on the one returned by FinishLogin is a value to write back.

One deadline

Config.CeremonyTimeout is the only expiry knob, and it lands in three places that would otherwise be three settings able to disagree: the timeout the browser is asked to honor, the expiry the library enforces server-side when it verifies, and the TTL the ceremony's row is stored under. A ceremony that has run out of time therefore fails the same way wherever it is noticed.

A challenge is used once

SessionStore.Consume fetches and removes in one operation, so an assertion cannot be replayed inside the ceremony window by sending it twice. That is the interface's whole reason for having Consume rather than a Get and a Delete: a store that hands the same challenge to two callers is a store that has to be remembered about, and this one cannot be forgotten.

Usage

store, err := webauthndatabase.NewSessionStore(&webauthndatabase.Config{}, db,
	webauthndatabase.WithSweeper(ctx, 5*time.Minute))
// ...

rp, err := webauthn.NewRelyingParty(ctx, &webauthn.Config{
	RPID:          "example.com",
	RPDisplayName: "Example",
	RPOrigins:     []string{"https://example.com"},
}, store)
// ...

// Registration, first request.
creation, err := rp.BeginRegistration(ctx, user)
// ... write creation to the response.

// Registration, second request.
credential, err := rp.FinishRegistration(ctx, user, req)
// ... store credential against the user.

authentication/webauthn/config assembles all of that from environment configuration, and registers it with a do.Injector — over a cache. The provider string that chooses between a cache and the SQL table above lives one level down from the table, in authentication/webauthn/database/config, and that package's doc.go says why.

Index

Constants

View Source
const (
	// UserVerificationRequired refuses a ceremony the authenticator did not
	// verify the user for — a PIN, a fingerprint, a face. This is what makes a
	// passkey a second factor as well as a first.
	UserVerificationRequired = string(protocol.VerificationRequired)
	// UserVerificationPreferred asks for verification and accepts a ceremony
	// without it. It is the protocol's default and this package's.
	UserVerificationPreferred = string(protocol.VerificationPreferred)
	// UserVerificationDiscouraged asks the authenticator not to verify, for a
	// deployment where the passkey is one factor among others.
	UserVerificationDiscouraged = string(protocol.VerificationDiscouraged)
)

User verification requirements, as configured. They are the protocol's own values spelled as strings, because a Config comes out of the environment.

View Source
const DefaultCeremonyTimeout = time.Minute

DefaultCeremonyTimeout is how long a ceremony may take when nothing says otherwise: long enough for a user to find the authenticator, plug it in, and touch it, and short enough that an abandoned ceremony's state is gone before anybody thinks about it again.

It is one minute because that is what the WebAuthn specification suggests for a ceremony where user verification is expected, and because the value has to come from somewhere — a deployment that knows better sets it.

Variables

View Source
var (
	// ErrSessionNotFound indicates no ceremony state is stored under the
	// challenge. It is what every unusable challenge reads as: never issued,
	// already consumed, or past its TTL.
	//
	// A caller should not distinguish those for the client's benefit. All three
	// mean the ceremony has to start again, and telling a caller which one it
	// was tells an attacker whether a challenge they guessed at ever existed.
	ErrSessionNotFound = platformerrors.New("webauthn ceremony session not found")

	// ErrSessionExpired indicates ceremony state was found and had passed its
	// TTL. It wraps ErrSessionNotFound, because a store that can tell the two
	// apart owes its callers the same answer as one that cannot — the
	// database store reads an expires_at column and knows; the cache store's
	// entry is simply gone.
	ErrSessionExpired = platformerrors.Wrap(ErrSessionNotFound, "webauthn ceremony session expired")

	// ErrChallengeRequired indicates a ceremony session with no challenge. It
	// wraps errors.ErrEmptyInputParameter, so a caller may check either.
	//
	// It is a rejection rather than a stored row under an empty key: the
	// challenge is the identity of the ceremony, and a store keyed on nothing
	// would hand the next empty-challenge lookup somebody else's session.
	ErrChallengeRequired = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty webauthn challenge")

	// ErrNilSession indicates Save was called without ceremony state. It wraps
	// errors.ErrNilInputParameter, so a caller may check either.
	ErrNilSession = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil webauthn ceremony session")

	// ErrNonPositiveTTL indicates Save was given a TTL of zero or less, which
	// would store ceremony state that is unusable the instant it is written.
	// Zero cannot stand in for "no expiry" here: ceremony state that never
	// expires is a challenge that can be answered next year.
	ErrNonPositiveTTL = platformerrors.New("webauthn ceremony session ttl is not positive")

	// ErrNilStore indicates NewRelyingParty was called without a session
	// store. It wraps errors.ErrNilInputParameter, so a caller may check
	// either.
	//
	// There is no default. An implicit in-memory store would pass every test
	// and fail intermittently in production the moment a second replica
	// existed, which is the failure this package is for.
	ErrNilStore = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil webauthn session store")

	// ErrNilUser indicates a ceremony was begun or finished without a user. It
	// wraps errors.ErrNilInputParameter, so a caller may check either.
	ErrNilUser = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil webauthn user")

	// ErrNilResponse indicates a ceremony was finished without the client's
	// response — a nil body reader. It wraps errors.ErrNilInputParameter, so a
	// caller may check either.
	//
	// The *http.Request entry points do not report it: the library's parsers
	// answer a nil request with their own bad-request error, and reporting two
	// different sentinels for the same missing thing would be a distinction
	// only this package could explain.
	ErrNilResponse = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil webauthn ceremony response")

	// ErrNilHandler indicates a discoverable login was finished without a
	// handler to resolve the credential's owner. It wraps
	// errors.ErrNilInputParameter, so a caller may check either.
	ErrNilHandler = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil webauthn discoverable user handler")
)

Sentinels. errors/http maps platform sentinels onto status codes and imports the packages it maps, so nothing here may import errors/http or errors/grpc.

Functions

func ValidateSession

func ValidateSession(session *SessionData, ttl time.Duration) error

ValidateSession applies the argument rules every SessionStore.Save owes its callers, so that the two implementations here — and any third one — reject the same inputs with the same sentinels instead of each deciding.

It is exported for that third implementation's benefit. The rules are otherwise a comment on an interface, which is the kind of contract that holds until somebody writes a store that stores a nil session under an empty key for a TTL of zero and finds that nothing complained.

Types

type Config

type Config struct {

	// RPID is the Relying Party identifier: the site's effective domain, with
	// no scheme and no port. "example.com" covers app.example.com; "localhost"
	// is what a development deployment uses.
	//
	// It is the scope of every credential registered under it. Changing it
	// invalidates every passkey a deployment has issued, because an
	// authenticator will not answer for a domain it did not register against.
	RPID string `env:"ID" json:"rpID,omitempty" yaml:"rpID,omitempty"`

	// RPDisplayName is the human-readable name the authenticator shows during
	// registration — "Example", not "example.com". The library requires one.
	RPDisplayName string `env:"DISPLAY_NAME" json:"rpDisplayName,omitempty" yaml:"rpDisplayName,omitempty"`

	// UserVerification is the deployment's user-verification policy:
	// required, preferred, or discouraged. Empty means preferred, which is the
	// protocol's default. A single ceremony may override it through a
	// [LoginOption] or a [RegistrationOption].
	UserVerification string `env:"USER_VERIFICATION" json:"userVerification,omitempty" yaml:"userVerification,omitempty"`

	// RPOrigins is every origin a ceremony may be answered from, fully
	// qualified — "https://example.com", including the port when there is a
	// non-default one. At least one is required, and an origin that is missing
	// here is a login that fails verification rather than one that is merely
	// unstyled.
	RPOrigins []string `env:"ORIGINS" json:"rpOrigins,omitempty" yaml:"rpOrigins,omitempty"`

	// CeremonyTimeout bounds how long a ceremony may take. Zero means
	// DefaultCeremonyTimeout.
	//
	// It is one number in three places, which is the point of it being one
	// field: it is the timeout the browser is asked to honor, the expiry the
	// library enforces when it verifies the response, and the TTL the ceremony
	// state is stored under. Configured separately, those three drift, and the
	// symptom is a ceremony that the client abandons while the server still
	// holds a challenge it will honor.
	CeremonyTimeout time.Duration `env:"CEREMONY_TIMEOUT" json:"ceremonyTimeout,omitempty" yaml:"ceremonyTimeout,omitempty"`
	// contains filtered or unexported fields
}

Config is the Relying Party — the server half of a WebAuthn ceremony — as a deployment configures it.

It is deliberately smaller than go-webauthn's own configuration. What is here is what changes between deployments of the same application; the rest of the protocol's knobs are per-ceremony and are passed to BeginRegistration and BeginLogin as RegistrationOption and LoginOption, which are the library's own. A deployment that needs what neither covers — FIDO metadata-service attestation validation, AAGUID filtering — builds go-webauthn itself and keeps SessionStore, which is the half worth having and is usable on its own.

func (*Config) EnsureDefaults

func (cfg *Config) EnsureDefaults()

EnsureDefaults fills in zero fields.

func (*Config) ValidateWithContext

func (cfg *Config) ValidateWithContext(ctx context.Context) error

ValidateWithContext validates a Config struct.

The three required fields are required by the protocol rather than by taste: go-webauthn refuses to begin a ceremony without an RPID, a display name, or an origin. Checking them here turns "the first passkey registration of the day failed" into a service that did not start.

type Credential

type Credential = gowebauthn.Credential

Credential is a registered passkey — the credential ID, the public key, and the authenticator's sign count. FinishRegistration returns one to persist; FinishLogin returns the one that was used, whose sign count is worth writing back.

type DiscoverableUserHandler

type DiscoverableUserHandler = gowebauthn.DiscoverableUserHandler

DiscoverableUserHandler resolves the user behind a credential during a discoverable (usernameless) login, given the raw credential ID and the user handle the authenticator returned.

type LoginOption

type LoginOption = gowebauthn.LoginOption

LoginOption adjusts one login ceremony — the allowed credentials, the user verification requirement, the extensions.

type Option

type Option func(*options)

Option configures the RelyingParty this package constructs. The zero configuration works: an absent logger logs nowhere, an absent tracer provider traces nowhere, and an absent metrics provider records nothing.

func WithClock

func WithClock(c clock.Clock) Option

WithClock swaps the clock a ceremony's remaining life is measured against when its state is stored.

The deadline itself comes from the library, which reads the wall clock either way, so this does not move a ceremony's expiry — it decides how much of that expiry the store is told about.

func WithLogger

func WithLogger(logger logging.Logger) Option

WithLogger attaches a logger.

Worth setting. A ceremony that fails verification is a security-relevant event — a challenge answered from an origin that is not configured, a credential presented by a user who does not own it — and without a logger the only trace it leaves is whatever the caller does with the returned error.

func WithMetricsProvider

func WithMetricsProvider(metricsProvider metrics.Provider) Option

WithMetricsProvider attaches a metrics provider for the ceremony counters and latency histogram. An absent provider records nothing.

func WithTracerProvider

func WithTracerProvider(tracerProvider tracing.Provider) Option

WithTracerProvider attaches a tracer provider, enabling spans on every ceremony step.

type RegistrationOption

type RegistrationOption = gowebauthn.RegistrationOption

RegistrationOption adjusts one registration ceremony — the authenticator selection, the exclusion list, the attestation conveyance. It is per-ceremony rather than per-Relying-Party, which is why it is passed to BeginRegistration rather than configured.

type RelyingParty

type RelyingParty struct {
	// contains filtered or unexported fields
}

RelyingParty runs the registration and login ceremonies for one Relying Party, keeping each ceremony's state in a SessionStore between the request that begins it and the request that finishes it.

It is exported, and returned by NewRelyingParty, so a caller depends on what it built rather than on an interface. There is no interface: this is the protocol, and a second implementation of it would be a second WebAuthn.

func NewRelyingParty

func NewRelyingParty(ctx context.Context, cfg *Config, store SessionStore, opts ...Option) (*RelyingParty, error)

NewRelyingParty builds a RelyingParty over a session store.

The store is required and has no default, for the reason ErrNilStore gives: the map every WebAuthn example starts from works until there are two replicas, and then fails a fraction of logins in a way that reads as a client bug.

The context is for validating the config, which is done here rather than left to a composition root — an RPID that is not a domain, or an origin list that is empty, is a service that cannot register a passkey, and finding that out at the first registration is finding it out from a user.

func (*RelyingParty) BeginDiscoverableLogin

func (rp *RelyingParty) BeginDiscoverableLogin(
	ctx context.Context,
	opts ...LoginOption,
) (*protocol.CredentialAssertion, error)

BeginDiscoverableLogin issues the assertion options for a login where the user is not known yet — the passkey names them — and stores the ceremony's state.

The ceremony state it stores carries no user handle, and FinishDiscoverable Login refuses a session that does. That is the library's check, and it is what stops a discoverable assertion being answered against a ceremony that was begun for somebody in particular.

func (*RelyingParty) BeginLogin

func (rp *RelyingParty) BeginLogin(
	ctx context.Context,
	user User,
	opts ...LoginOption,
) (*protocol.CredentialAssertion, error)

BeginLogin issues the assertion options for a known user, and stores the ceremony's state. Use BeginDiscoverableLogin when the user is not known yet.

func (*RelyingParty) BeginRegistration

func (rp *RelyingParty) BeginRegistration(
	ctx context.Context,
	user User,
	opts ...RegistrationOption,
) (*protocol.CredentialCreation, error)

BeginRegistration issues the credential creation options a browser needs to register a new passkey for user, and stores the ceremony's state.

The state is not returned. It is stored under the challenge and read back by FinishRegistration from the challenge the client echoes, which is what lets the two requests land on different replicas — and what stops a caller from round-tripping the ceremony's state through the client, where it would be the client deciding what challenge it had been given.

A deployment that wants usernameless (discoverable) login registers resident credentials, which is a per-ceremony option:

rp.BeginRegistration(ctx, user,
	gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired))

func (*RelyingParty) FinishDiscoverableLogin

func (rp *RelyingParty) FinishDiscoverableLogin(
	ctx context.Context,
	handler DiscoverableUserHandler,
	r *http.Request,
) (user User, credential *Credential, err error)

FinishDiscoverableLogin verifies an assertion response whose user is identified by the credential itself, and returns that user alongside the credential that answered.

handler resolves the raw credential ID and user handle the authenticator returned into a User. It is the application's, because only the application knows where its credentials are stored, and it is called once per ceremony.

func (*RelyingParty) FinishDiscoverableLoginBody

func (rp *RelyingParty) FinishDiscoverableLoginBody(
	ctx context.Context,
	handler DiscoverableUserHandler,
	body io.Reader,
) (user User, credential *Credential, err error)

FinishDiscoverableLoginBody is FinishDiscoverableLogin for a caller holding the assertion response as bytes rather than as an HTTP request.

func (*RelyingParty) FinishLogin

func (rp *RelyingParty) FinishLogin(ctx context.Context, user User, r *http.Request) (*Credential, error)

FinishLogin verifies an assertion response carried by an HTTP request against a known user, and returns the credential that answered it.

The returned credential carries the authenticator's sign count, which the application is expected to write back: a count that goes backwards is how a cloned authenticator announces itself, and nothing can notice that unless the last one was stored.

func (*RelyingParty) FinishLoginBody

func (rp *RelyingParty) FinishLoginBody(ctx context.Context, user User, body io.Reader) (*Credential, error)

FinishLoginBody is FinishLogin for a caller holding the assertion response as bytes rather than as an HTTP request.

func (*RelyingParty) FinishRegistration

func (rp *RelyingParty) FinishRegistration(ctx context.Context, user User, r *http.Request) (*Credential, error)

FinishRegistration verifies an attestation response carried by an HTTP request and returns the credential to store against the user.

The credential is returned rather than stored: where a passkey lives, and what else it is stored beside, is the application's. What this owes it is a credential that has been verified against a challenge this server issued, has not been answered before, and is still inside its ceremony window.

func (*RelyingParty) FinishRegistrationBody

func (rp *RelyingParty) FinishRegistrationBody(ctx context.Context, user User, body io.Reader) (*Credential, error)

FinishRegistrationBody is FinishRegistration for a caller that is not serving HTTP — a gRPC handler, a message consumer — and holds the attestation response as bytes:

rp.FinishRegistrationBody(ctx, user, bytes.NewReader(req.GetAttestationResponse()))

It exists so that such a caller does not have to forge an *http.Request around its own payload to reach the verification.

type SessionData

type SessionData = gowebauthn.SessionData

SessionData is the ceremony state that has to outlive the request that issued it: the challenge, the user handle, the allowed credentials, and the deadline. It is what a SessionStore stores.

type SessionStore

type SessionStore interface {
	Save(ctx context.Context, session *SessionData, ttl time.Duration) error
	Consume(ctx context.Context, challenge string) (*SessionData, error)
}

SessionStore holds a ceremony's state between the request that issued the challenge and the request that answers it.

Two methods, and the second is the interesting one. Consume fetches and removes in a single operation, so a challenge is answerable exactly once: an assertion replayed inside its TTL finds nothing the second time. A Get and a Delete would leave that guarantee to whoever remembered to call the Delete, on the success path, after the validation that might have returned early.

There is deliberately no non-consuming read. Nothing in a ceremony needs one — the Begin issues, the Finish answers — and offering one would offer the replay this interface exists to prevent.

What an implementation owes

Save stores session under its own Challenge for ttl. It reports ErrNilSession for a nil session, ErrChallengeRequired for one whose Challenge is empty, and ErrNonPositiveTTL for a ttl of zero or less. The challenge is taken from the session rather than passed beside it, so a session cannot be stored under a key that is not the one a Finish will look it up by.

Consume returns the state stored under challenge and removes it. It reports ErrSessionNotFound when the challenge was never stored, has already been consumed, or has passed its TTL — an implementation that can tell the last case apart may report ErrSessionExpired, which wraps ErrSessionNotFound. Where the backing store can do it, exactly one of several concurrent consumers of one challenge gets the state and the rest are told ErrSessionNotFound; where it cannot, the implementation's doc says so and it declares the deviation to the conformance suite in authentication/webauthn/webauthntest.

A round trip preserves what the ceremony needs: the challenge, the user handle, the allowed credential IDs, the user verification requirement, and the deadline. Timestamps come back as UTC and may be truncated to microseconds, which is what the supported column types store.

What it does not own

Registered credentials. A Credential is the application's to store, for as long as the passkey exists; this interface holds the seconds-long state of one ceremony and nothing else.

type User

type User = gowebauthn.User

User is the Relying Party's user account, as the ceremonies need it: a handle, the two display strings, and the credentials the account owns.

Implementing it is the application's job and is usually an adapter of twenty lines over whatever the application already stores. WebAuthnID is the one with a rule worth repeating — it is an opaque handle of at most 64 bytes, and every authentication decision is made against it rather than against the name.

Directories

Path Synopsis
Package cache stores WebAuthn ceremony state in a cache.Cache.
Package cache stores WebAuthn ceremony state in a cache.Cache.
Package webauthncfg assembles a WebAuthn relying party, and a cache-backed ceremony store, from environment configuration.
Package webauthncfg assembles a WebAuthn relying party, and a cache-backed ceremony store, from environment configuration.
Package webauthnmock provides moq-generated mock implementations of interfaces in the webauthn package.
Package webauthnmock provides moq-generated mock implementations of interfaces in the webauthn package.
Package webauthntest holds the behavior every webauthn.SessionStore owes its callers, written once and run against each implementation.
Package webauthntest holds the behavior every webauthn.SessionStore owes its callers, written once and run against each implementation.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL