Skip to content

Commit ab1c585

Browse files
os-trumpclaude
andauthored
fix(auth): the rotating 2FA verify routes echo the live user row, not the pre-rotation snapshot (#17000)
* wip(auth): #16535 user echo alignment + pins * wip(auth): #16535 lib-target fix, changeset, client doc * fix(auth): echo the live user row from the rotating 2FA verify routes On the enrolment lane better-auth writes `twoFactorEnabled: true`, rotates the session and only then calls the `valid(ctx)` closure it built at entry, so a successful `/two-factor/verify-totp` answered `user.twoFactorEnabled: false` to the caller who had just switched 2FA on. `two-factor-rotated-token-echo` already repaired the body's other stale member, `token`, on exactly these routes and on exactly this predicate; `user` is stale for the same reason and is now repaired under the same one. Narrowed twice: only the members the vendor already echoed are written, so the published payload shape cannot widen; and the row is re-read through `internalAdapter` by the id the response itself published, so the repair travels the same output transform that produced the echo and can never substitute a different principal. `/two-factor/verify-backup-code` does not rotate, is in neither path list, and is pinned as a negative control on both drive legs. A row read that throws degrades to the vendor's own echo, never to a failed verification and never to a lost `token` repair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d4690b commit ab1c585

4 files changed

Lines changed: 659 additions & 23 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
"@objectstack/client": patch
4+
---
5+
6+
`POST /two-factor/verify-totp` and `/two-factor/verify-otp` now echo the user row as it stands when the response is written, instead of the pre-rotation snapshot the vendor closes over.
7+
8+
On the enrolment lane — a signed-in caller confirming a new factor — better-auth writes `twoFactorEnabled: true`, rotates the session, and only then calls the `valid(ctx)` closure it built at entry. That closure still holds the pre-rotation session, so a successful verification answered `user.twoFactorEnabled: false` to the very caller who had just switched 2FA on. An account portal reading that body renders the factor as still OFF right after enrolment, and a bearer client that caches the echoed user carries the wrong flag until its next `get-session`.
9+
10+
`two-factor-rotated-token-echo` already repaired the body's other stale member, `token`, on exactly these routes and on exactly this predicate — the response staged a session cookie whose token differs from the one echoed. The `user` member is stale for the same reason, so it is repaired under the same predicate rather than a new one.
11+
12+
- **Two narrowings, both load-bearing.** Only the members the vendor already echoed are written, so the published payload shape (`AuthWireUser`) cannot widen — better-auth's own output filter is a deny-list, and forwarding a raw row would put every column it happens to carry on the wire. And the row is re-read through `internalAdapter` by the id the response itself published, so the repair travels the same output transform that produced the echo (a driver that stores booleans as `1`/`0` cannot change a member's wire type) and can never substitute a different principal into a response.
13+
- **`/two-factor/verify-backup-code` is untouched.** It does not rotate and already echoed the live row; it is in neither path list, its row is not read, and it is pinned as a negative control on both the in-memory engine and a real `SqlDriver` — an unconditional re-read would have "fixed" the broken lane and quietly rewritten one that was already right.
14+
- **The failure posture is inherited.** A row read that throws or answers nothing degrades to the vendor's own echo, never to a failed verification and never to a lost `token` repair, which is written first for that reason.
15+
16+
`@objectstack/client` drops the `AuthTwoFactorVerificationResult.user` warning that told callers to re-read the session for the live flag; the wire shape it declares is unchanged.

packages/client/src/index.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,11 +1068,7 @@ export interface AuthWireUser {
10681068
createdAt: string;
10691069
/** ISO-8601. */
10701070
updatedAt: string;
1071-
/**
1072-
* `twoFactor` plugin only. ⚠️ On the enrolment lane of `verifyTotp` this
1073-
* is echoed from the pre-flip snapshot — see
1074-
* {@link AuthTwoFactorVerificationResult}.
1075-
*/
1071+
/** `twoFactor` plugin only. */
10761072
twoFactorEnabled?: boolean;
10771073
/**
10781074
* `admin` plugin only. An open string: the vocabulary is the deployment's
@@ -1160,10 +1156,13 @@ export interface AuthTwoFactorVerificationResult {
11601156
*/
11611157
token: string;
11621158
/**
1163-
* ⚠️ On the ENROLMENT lane of `verifyTotp` the vendor echoes the user from
1164-
* its pre-rotation snapshot, so `twoFactorEnabled` reads `false` here
1165-
* although the flag has just flipped server-side (measured on a real SQL
1166-
* driver). Re-read the session for the live value.
1159+
* The caller, as the row stands when the response is written. The vendor
1160+
* echoes the user from its PRE-rotation snapshot on the enrolment lane, so
1161+
* `twoFactorEnabled` used to read `false` here although the flag had just
1162+
* flipped server-side; plugin-auth's `two-factor-rotated-token-echo`
1163+
* repairs that member from the row on the same rotating routes it repairs
1164+
* `token` on, so no second read is needed. The payload's shape is
1165+
* unchanged — the repair corrects values only.
11671166
*/
11681167
user: AuthWireUser;
11691168
}

0 commit comments

Comments
 (0)