CARLOS rastrillo docs

๐Ÿค– Second factors and authenticator apps

rastrillo/secondfactor is the seam between a verified first factor and the session it earns. rastrillo/passkey and rastrillo/totp are the two factors that complete it. An account with both enrolled gets one confirm page offering either; an app adding a third factor adds it here rather than teaching each identity plugin about it.

The half-session

A half-session names who must still prove a second thing. It is minted only by Gate.Hold, which an identity plugin calls at the exact point a verified first factor would mint the session, and it is redeemed only by Gate.Complete, which a factor calls after its proof verified. Nobody is signed in from nothing, and a half-session opens nothing by itself: a short-lived cookie plus a hashed row, five minutes at most.

g, err := secondfactor.New(secondfactor.Config{
	Sessions:    sess,
	DB:          writer,
	Origin:      origin,
	ConfirmPath: "/signin/confirm",   // the default
})
pk, err := passkey.New(passkey.Config{Sessions: sess, DB: writer, Origin: origin, Gate: g})
tp, err := totp.New(totp.Config{
	DB: writer, Sessions: sess, Gate: g,
	Key:    crypto.Derive([]byte(instanceKey), "rastrillo/totp"),
	Issuer: site,
})
g.Add(pk, tp)

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

Merge secondfactor.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 package existed, copying every recovery code across. Merge totp.Schema anywhere after sessions.Schema.

Mount the gate's one endpoint and each factor's own, behind csrf.Protect like every other mutating route:

POST /signin/recovery         <- form field "code"     (g.SignInRecovery)
POST /passkey/signin/begin    -> {"challenge": ...}    (pk.SignInBegin)
POST /passkey/signin/finish   <- authenticate()'s result
POST /totp/signin             <- form field "code"     (tp.SignIn)
POST /totp/stepup             <- "code", "return_to"   (tp.StepUp)

The confirm page

It is yours. Ask g.Pending(r) who is waiting; a miss means nobody is, and the page sends them to sign in. Then offer whichever factors that subject holds โ€” pk.Enrolled, tp.Enrolled โ€” and the recovery form. A passkey completes through webauthn.mjs; a code and a recovery code are plain form POSTs, so the page works with JavaScript off, which is also the moment a passkey ceremony cannot run.

The minted session carries the first factor's method plus the factor that completed it: "magiclink+passkey", "google+totp", "password+recovery". AuthTime is now, which is what sessions.RequireFresh checks.

A miss on a guessable proof is a Strike: five per half-session, then it is gone and the person starts again from the first factor. totp strikes; passkey never needs to, because an assertion over a fresh challenge cannot be guessed.

Hold from your own code

Hold takes the session that would be minted, and an app may call it directly with a method of its own. A remembered browser that should need only a passkey to get back in is one line: hold a session whose Method is "device", and nothing is minted unless a factor completes it, exactly as for a magic link.

Recovery codes

For the account whose only factor is lost.

codes, err := g.RegenerateRecoveryCodes(subject)

Ten single-use codes, shown once, from a page you mount behind sessions.RequireFresh. g.RecoveryCodesRemaining(subject) tells you how many are left, for a settings page that should nag.

SignInRecovery redeems one against the half-session where a proof would have gone. A wrong code redirects back to ConfirmPath?recovery=failed and leaves the half-session alive; a correct one burns the code and mints "<first factor>+recovery", a marker you can use to nudge enrolling a replacement. Sign-in only: there is no recovery step-up, and no attempt counter, because ten codes at 2โปโตโฐ apiece inside a five-minute window need none.

TOTP

rastrillo/totp is RFC 6238 exactly as every authenticator app defaults to it โ€” SHA-1, six digits, thirty seconds, one step of skew either side โ€” and nothing else, because an app that deviates loses half its users' apps.

Enrolment is three calls behind your settings page:

en, err := tp.Begin(subject, address)   // en.Key, en.URI, en.QR (inline SVG)
en, ok, err := tp.Pending(subject, address) // the same again, for a GET
ok, err := tp.Confirm(subject, code)    // the factor is live only after this
err := tp.Disable(subject)              // behind RequireFresh

Show en.QR (mark it template.HTML) and en.Key beside it for typing in by hand; ask for one code to prove the app scanned it. Begin on a live authenticator returns ErrEnrolled โ€” disable first, from a page behind RequireFresh, so a stale session cannot swap the factor out from under its owner.

Secrets are sealed under Config.Key (AES-256-GCM) before they touch the database, so a copied database file is not a copied factor. Derive the key from the instance key; never store it beside the data.

A verified code's step is remembered and never accepted twice, so a code read over a shoulder is spent the moment its owner uses it. At sign-in the guess budget is the half-session's; at step-up and enrolment it is ten misses in fifteen minutes per subject, in memory.

StepUp is the form-POST counterpart of a passkey assertion: a valid but stale session plus a code rotates the session fresh with method "totp". A miss lands on Config.StepUpFailedPath (default /signin?reauth=1&totp=failed) with return_to carried along.

totp.Code(key, t) is the code an authenticator holding key shows at t โ€” for a test that wants to play the phone.

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