Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/two-factor-reenrollment-verified-flag.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/plugin-auth": patch
---

`POST /api/v1/auth/two-factor/enable` no longer leaves `sys_two_factor.verified`
describing the enrollment *before* the secret it stores.

better-auth's enable handler computes the row it writes as
`verified: existingTwoFactor != null && existingTwoFactor.verified === true`
(measured on the installed 1.7.1, `dist/plugins/two-factor/index.mjs`), and
`sys_two_factor` declares `user_id` unique — so a second `enable` on an account
that already has a confirmed factor rewrites that one row with a brand-new
secret while inheriting the old enrollment's flag. The flag then said
"user-confirmed" about a secret nobody had ever confirmed, and the sign-in
challenge honoured it.

The vendor already gates the challenge on that flag, in both places it matters:
`totp/index.mjs` refuses an unconfirmed factor with `TOTP_NOT_ENABLED` before
any lockout bookkeeping, and the post-sign-in hook offers `totp` among
`twoFactorMethods` only when the flag is not `false`. That gate is exactly what
a *first* enrollment relies on. Re-enrollment was the one path that slipped past
it — not because the gate was missing, but because the value handed to it was
inherited. So the fix restores the flag rather than adding a second gate:
after a successful `method: 'totp'` enable, `verified` is set to `false`, and
the freshly issued secret becomes live only once the caller proves possession of
it through `/two-factor/verify-totp`.

This is a tightening. The request body, the response shape and the status are
unchanged, a first-time enrollment is unaffected (better-auth already wrote
`false` there), and a rotation is still reachable and still completes — it now
takes the same confirmation step a first enrollment takes. What changes is that
a secret the endpoint hands out is no longer accepted at the next sign-in until
it has been confirmed. Clients that re-enroll and then rely on the new
authenticator working immediately at sign-in must call `/two-factor/verify-totp`
with the live session first, which is the flow first-time enrollment already
uses.
12 changes: 12 additions & 0 deletions content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -547,6 +547,18 @@ A complete opt-in 2FA UX still needs to handle:
- the `twoFactorRedirect` response returned by password sign-in, and
- backup-code recovery.

<Callout type="warn">
**`/two-factor/enable` always issues a secret that must be confirmed — including
on re-enrollment.** Calling it on an account that already has 2FA active replaces
the stored secret and marks the enrollment unconfirmed, so the new secret is
**not** accepted at the sign-in challenge (`400 TOTP_NOT_ENABLED`) and `totp` is
not offered in `twoFactorMethods` until `/two-factor/verify-totp` succeeds with
the live session. The replaced secret stops working as soon as `enable` returns,
so a re-enrollment UI must run the confirmation step in the same session, and
must show the backup codes from that response — they are the recovery path if
the authenticator was never captured.
</Callout>

For custom account UIs, enable the backend plugin in configuration:

```typescript
Expand Down
13 changes: 13 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ import {
withBearerAdminSessionRecovery,
} from './impersonation-bearer-rotation.js';
import { echoInstalledSessionToken } from './two-factor-rotated-token-echo.js';
import { resetVerifiedOnTwoFactorReenrollment } from './two-factor-reenrollment-verified-reset.js';
import {
applyPlatformAdminImpersonation,
} from './admin-impersonate-endpoint.js';
Expand DownExpand Up@@ -1716,6 +1717,18 @@ export class AuthManager {
// corrects the echoed VALUE only; resolver precedence is untouched.
await echoInstalledSessionToken(ctx);

// ── #10700: `verified` must describe the secret stored beside it ──
// A second `/two-factor/enable` on an already-confirmed account
// rewrites the TOTP secret on the one `sys_two_factor` row the
// account has and INHERITS `verified` from the enrollment before it,
// so a secret nobody confirmed is honoured at the sign-in challenge.
// better-auth already gates that challenge on the flag — a first
// enrollment is inert until the session-lane verify flips it — so
// restoring the flag restores the gate rather than adding a second
// one. See `two-factor-reenrollment-verified-reset.ts`, whose header
// also states what this deliberately does NOT do.
await resetVerifiedOnTwoFactorReenrollment(ctx);

// ── ADR-0069 D2: account lockout (counter) ──────────────────
// better-auth catches an INVALID_EMAIL_OR_PASSWORD APIError and runs
// the after-hook with it on `ctx.context.returned`; a success leaves
Expand Down
Loading
Loading