fix(two-factor): preserve enforcement on path-less endpoints

Restore the pre-patch semantics for the after-hook matcher: a missing
`context.path` must still match so virtual endpoints that mint a session
are not silently exempt from 2FA. The `/two-factor/` and
session-transition prefix checks now apply only when `path` is set.

Also trim the `shouldEnforce` JSDoc; the behavioral detail lives in the
Enforcement scope section of the 2FA docs.
This commit is contained in:
Gustavo Valverde
2026-04-15 10:38:07 +01:00
parent 42f784d9c1
commit 01f7e2b0a7
2 changed files with 17 additions and 28 deletions
@@ -380,14 +380,15 @@ export const twoFactor = <O extends TwoFactorOptions>(options?: O) => {
matcher(context) {
if (context.context.newSession == null) return false;
const path = context.path;
if (!path) return false;
if (path.startsWith("/two-factor/")) return false;
if (
SESSION_TRANSITION_PATH_PREFIXES.some((prefix) =>
path.startsWith(prefix),
)
) {
return false;
if (path) {
if (path.startsWith("/two-factor/")) return false;
if (
SESSION_TRANSITION_PATH_PREFIXES.some((prefix) =>
path.startsWith(prefix),
)
) {
return false;
}
}
return true;
},
@@ -66,29 +66,17 @@ export interface TwoFactorOptions {
*/
trustDeviceMaxAge?: number | undefined;
/**
* Decides whether to challenge 2FA on a given sign-in.
* Decides whether to challenge 2FA on a given sign-in. Return `true`
* to challenge, `false` to skip. Setting this option replaces the
* built-in decision, including the passkey UV exemption.
*
* Return `true` to challenge 2FA for this request; return `false` to
* skip. Setting this option replaces the built-in enforcement
* decision (including the passkey user-verification exemption).
* Same-user session rewrites (session refresh, `updateUser`) and
* session-transition endpoints (admin impersonation, multi-session
* switching) are never matched and cannot be overridden.
*
* When omitted, 2FA is challenged on every sign-in that creates a
* new session, with one built-in exception: passkey sign-ins whose
* assertion confirmed user verification (UV) are not challenged,
* since a UV-verified passkey already satisfies MFA.
* The authenticating user is available at `ctx.context.newSession.user`.
*
* Two guards run before this callback and cannot be overridden:
* same-user session rewrites (session refresh, `updateUser`) are
* never challenged, and session-transition endpoints (admin
* impersonation, multi-session switching) are never matched.
*
* The callback receives the endpoint context. The authenticating
* user is available at `ctx.context.newSession.user`.
*
* Use this option to skip 2FA on flows where the upstream provider
* is trusted to enforce it (for example, OAuth callbacks where the
* provider already required MFA), or to force a challenge on flows
* that the built-in logic would otherwise skip.
* @see {@link https://better-auth.com/docs/plugins/2fa#enforcement-scope}
*/
shouldEnforce?:
| ((ctx: GenericEndpointContext) => boolean | Promise<boolean>)