Strong-Factor Lifecycle and Reauth Window
A "strong factor" is any auth method stronger than a password: TOTP, a passkey, or a linked OAuth account. The service tracks, per user, whether at least one such factor is active, when it was last proven, and requires a fresh proof before it lets sensitive account mutations through. This node describes how that state is derived, kept current, and enforced.
The value object
StrongFactor is a domain value object holding three fields: complete (is any strong factor active), method (totp | passkey | oauth | null), and completedAt (when the factor was last proven). It is constructed either as incomplete() — the empty state — or from stored fields via fromProps(). complete(method, completedAt) returns a new completed instance, and isWithinReauthWindow(windowMs) answers whether the last proof is recent enough: it returns false when there is no timestamp, otherwise it compares elapsed time against the caller-supplied window. The window is not baked into the domain — the caller decides how long a proof stays fresh.
Derivation: state is computed, never asserted blindly
Completeness is not a flag someone flips by hand. It is recomputed from the underlying records. The snapshot layer reads three sources for a user: whether a verified TOTP secret exists, whether the passkey count is above zero, and whether any linked OAuth account exists. If any one is present, the user is complete. When more than one source is present, the reported method follows a fixed priority — TOTP first, then passkey, then OAuth.
Two write paths: assertion vs. refresh
There are exactly two ways the stored state changes:
- Assertion (
recordStrongFactorAssertion) — used when a factor is freshly proven or enrolled. It forcescomplete = truewith the given method and stamps the moment as the newcompletedAt, overwriting any prior timestamp. This is what resets the reauth clock. - Refresh (
refreshStrongFactorState) — used when a factor may have been removed. It recomputes the snapshot from the current sources and persists the result, so unlinking the last strong factor flips the user back to incomplete.
The timestamp rule is deliberate: on a plain refresh the existing completedAt is preserved when the user was already complete, so merely recomputing state does not silently extend the freshness window. Only an assertion (or an explicit force) moves the clock forward.
Both paths write the same three fields (complete, method, completedAt) to two rows — the User record and the Credential record — keeping the denormalized copies in step.
The observer: lifecycle changes are picked up automatically
Enrollment and unlinking do not have to remember to update strong-factor state. A Prisma middleware (registerStrongFactorObserver) watches writes to the Passkey and Account models. A create/upsert of a passkey or account triggers an assertion (method passkey or oauth respectively); a delete triggers a refresh. It extracts the affected user id(s) from the operation and updates each. TOTP is not observed this way — its verified state is read at derivation time and asserted by the flow that verifies it.
The reauth window at the mutation boundary
Before a sensitive account mutation, the application layer builds a StrongFactor from the stored user fields and checks two things. First, the factor must be complete, or the request is rejected outright. Second, if the last proof falls within a short reauth window (five minutes), the request passes without re-proving. Outside that window, the user must supply a fresh proof — for example a current TOTP code — which, once verified, is recorded as a new assertion, resetting the window. The effect: proving a strong factor buys a brief grace period for a burst of sensitive actions, after which possession must be shown again.
Why it is shaped this way
Storing completeness as derived state rather than a hand-set flag means the truth always traces back to the actual TOTP/passkey/account records — there is no way for the flag and reality to drift apart, because a refresh recomputes it. The reauth window separates having a strong factor from recently proving it: a long-lived session can still be forced to demonstrate live possession before touching anything dangerous.