CARLOS rastrillo docs

๐Ÿค– secondfactor

amadan.net/rastrillo/rastrillo/secondfactor

The seam between a verified first factor and the session it earns: the pending half-session that names who must still prove a second thing, the attempt budget on proofs that can be guessed, and the recovery codes that are every factor's escape hatch.

The package owns no factor of its own. passkey and totp each verify their own proof and redeem the half-session here, so an app with two factors enrolled has one confirm page offering both, and an app adding a third factor adds it here rather than teaching each identity plugin about it.

Second factors and authenticator apps is the guide.

The trust boundary

A half-session opens nothing by itself. It is minted only by Gate.Hold, at the exact point a verified first factor would have minted the session, and redeemed only by Gate.Complete, which a factor calls once its own proof verified. Nobody is signed in from nothing.

New, Config and Schema

func New(cfg Config) (*Gate, error)

Config requires Sessions, DB and Origin. Origin decides the pending cookie's Secure and __Host- attributes the way sessions' own does. ConfirmPath is the app's "confirm it's you" page, default /signin/confirm; Logger is optional.

Merge Schema into your boot set after passkey.Schema. Its second migration adopts the half-session and recovery-code tables the passkey package owned before this one existed: it copies every recovery code across and drops the old tables. Merged the other way round on a fresh database, passkey's frozen first migration recreates two tables nothing reads. On a database that has already deployed passkey the order does not matter, because that migration has run. The codes people printed survive either way.

Wiring

g, _ := secondfactor.New(secondfactor.Config{Sessions: sess, DB: writer, Origin: origin})
pk, _ := passkey.New(passkey.Config{ /* ..., */ Gate: g})
tp, _ := totp.New(totp.Config{ /* ..., */ Gate: g})
g.Add(pk, tp)

au, _ := auth.New(auth.Config{ /* ..., */ SecondFactor: g.Hold})

Gate.Add registers factors. A Factor is the one-method interface Enrolled(subject string) (bool, error) โ€” verification is the factor's own business. Gate.Enrolled reports whether a subject holds at least one, which is the app's cue for whether a step-up can be a proof rather than a full re-sign-in, and for what the confirm page should offer.

Holding and completing

Call What it does
Gate.Hold The identity plugins' SecondFactor hook. No factor enrolled โ†’ (false, nil) and the plugin signs in exactly as before. Enrolled โ†’ store the half-session, remember a same-site return_to, redirect to the confirm page, (true, nil).
Gate.Pending Resolves the request's cookie to its live half-session โ€” expiry-checked, not consumed. The confirm page and every factor's sign-in endpoint start here.
Gate.Complete Consumes the half-session (DELETE ... RETURNING, so a raced second finish loses with ErrConsumed), clears the cookie, and mints the real session.
Gate.Strike Records a failed guess. The last strike of the budget deletes the row and returns ErrExhausted.
Gate.ConfirmPath Where Hold redirects, exposed so a factor's own failure redirect lands on the same page.

Pending is one live half-session: Subject, the Method its first factor verified with, and the ReturnTo the person was heading for.

The minted session's method is the first factor's plus + plus the factor that completed it: a plugin passing "magiclink" yields "magiclink+passkey" or "magiclink+totp". An app may call Hold itself with a method of its own โ€” "device" for a remembered browser, say โ€” and the same rule applies: nothing is minted unless a factor completes it.

Only a factor whose proof can be guessed needs Strike. A signature over a fresh challenge cannot be, so passkey never calls it; totp calls it on every wrong code.

Recovery codes

func (g *Gate) RegenerateRecoveryCodes(subject string) ([]string, error)

Gate.RegenerateRecoveryCodes mints a fresh set, atomically replacing any previous one, and returns the plaintexts โ€” the only moment they exist outside the reader's screen. Only hashes reach the database. Mount the page that calls it behind sessions.RequireFresh: showing sign-in-grade secrets is exactly the dangerous action step-up exists for. Gate.RecoveryCodesRemaining is the count behind a settings page's "6 of 10 left", and the cue for whether to offer the recovery form at all.

Gate.SignInRecovery is POST /signin/recovery, redeeming the form's code field against the pending half-session. Mount it behind csrf.Protect. It is a plain form POST deliberately: recovery is exactly the moment the authenticator, WebAuthn or JavaScript is not working. A miss leaves the half-session alive and redirects to the confirm page with ?recovery=failed, never saying which check missed. A hit burns the code and mints the session with +recovery, a marker an app can spot to nudge enrolling a replacement factor.

Errors and housekeeping

ErrConsumed is Complete's refusal when the half-session was already redeemed or has expired. ErrExhausted is Strike's report that the budget is spent and the person must start again from the first factor.

Sweep deletes expired half-session rows. Correctness never depends on it โ€” Pending checks expiry itself โ€” so call it from boot, from a sidecar pass, or not at all.

Read this page as markdown โ€” exact, unstyled, and cheap for an agent to fetch.