Repository navigation
Commit 3a856f6
fix(plugin-auth)!: a completed password reset ends the account's other sessions; a reset request answers the same way for any address without waiting on its mail (#29827)
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj
Clause-②: no (narrowing) — the auth manager refuses an
`emailAndPassword.revokeSessionsOnPasswordReset: false` it accepted
before, at construction and on every configuration patch. Nothing is
widened: no export, route, key or answer is added.
Metadata-protocol: none
Skills-docs: none — no `skills/**` file teaches the reset doors or this
key; the hand-written guides that do
(`content/docs/permissions/authentication.mdx`, `docs/HARDENING.md`) are
updated in this diff.
## 维护者速读
- 改了什么:自助重置密码完成后,该账号此前的所有会话一律结束(与管理员强制重置一致,不是开关);显式配置
`revokeSessionsOnPasswordReset: false`
改为启动即拒绝。重置请求对任何地址都同样作答,重置邮件改在响应之后发送,发送失败只记服务端日志。
- 为什么改:重置密码应当让旧登录失效;重置请求的回答不应随地址或邮件发送而变化。
- 风险与回滚:用户在其他设备上的登录会在重置后被登出(预期行为)。若某部署显式写了 `false`,升级后无法启动,删掉该键即可。回滚 =
还原本 PR,无数据迁移。
-
先看:`packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts`(`revokeSessionsOnPasswordReset:
true` 与 `sendResetPassword`
外包的一行)、`packages/plugins/plugin-auth/src/run-after-response.ts`、`packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts`。
- 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run
src/password-reset-ends-sessions.test.ts
src/reset-request-mail-after-response.test.ts`,预期两个文件全部通过。
- 需要你做的:无,席位落地。
## What changes
- **A completed password reset ends the account's other sessions.** The
auth instance now always hands better-auth
`emailAndPassword.revokeSessionsOnPasswordReset: true`, so every reset
door that sets a password from a reset token or code (the emailed link,
and the phone OTP reset where it is mounted) ends every existing session
of the account in the same unit as the password write — the same as an
administrator's forced reset. It is not a setting.
- **`revokeSessionsOnPasswordReset: false` is refused, never silently
overridden.** The key stays declared by `@objectstack/spec`.
`assertPasswordResetEndsSessions` (new,
`password-reset-ends-sessions.ts`) refuses an explicit `false` where the
configuration enters the manager — the constructor (stops the boot) and
`applyConfigPatch` (the standing configuration keeps ruling) — the way
the plugin already treats a declaration it will not honour
(`assertAudienceConfig`, `assertScimAdminCoherence`). `true` and an
absent key are accepted.
- **A reset request answers the same way for any address, without
waiting on its mail.** `sendResetPassword` is wrapped by
`sendAfterResponseOnPublicDoor` (new, `run-after-response.ts`): a call
that carries its `Request` (the public door) resolves at once and the
mail is built and sent after the response (`setImmediate`, the shape
`createRefusedAttachTombstoner` in service-storage already uses); a
failure is logged on the server at error. A call with no `Request` — the
identity import's server-side reset — is handed straight through and
awaited as before, because it reads the delivery receipt the send
settles.
- **Docs.** `docs/HARDENING.md` no longer asks operators to set the key;
`content/docs/permissions/authentication.mdx` states both rules.
## Tests
- `plugin-auth/src/password-reset-ends-sessions.test.ts` (new): the
session signed in before a completed reset answers `401 UNAUTHENTICATED`
afterwards (through the emailed link and, the same, through the phone
OTP reset — `/phone-number/request-password-reset` and
`/phone-number/reset-password` with an SMS service) and a fresh sign-in
with the new password is served (control); a reset that does not
complete ends nothing; better-auth is handed the flag with no
configuration; `false` is refused at construction and on a patch
(standing config kept); `true`/absent accepted.
- `plugin-auth/src/reset-request-mail-after-response.test.ts` (new): a
send that never settles still lets `POST /request-password-reset`
answer, with the body an address with no account gets; the mail is still
sent, after the response (control); a failed send is logged at error and
the answer is unchanged; the server-side reset call still awaits its
send; `runAfterResponse` failure handling.
- `qa/dogfood/test/password-reset-ends-sessions.dogfood.test.ts` (new):
on the booted showcase, through the HTTP doors — the earlier session is
served before the reset, refused (401) on the session door and a data
read after it, and a fresh sign-in is served on both.
- Updated, because the public door's reset mail now lands after the
response: `auth-email-locale.test.ts`, `identity-mail-sibling.test.ts`,
`admin-import-users-invite-delivery-failure.test.ts` ⑥, and
`identity-mail-read-gate.dogfood.test.ts` (waits for the new identity
row instead of reading the newest one at once).
## Acceptance notes
- The phone OTP reset is pinned too
(`password-reset-ends-sessions.test.ts`, `plugins.phoneNumber` with an
SMS service): the session signed in before a reset completed with the
texted code answers 401 afterwards, and a fresh sign-in is served.
- A session a reset ends is deleted, not stamped: the reset paths are in
neither the interactive-revoke ledger nor the erasure paths
(`session-tombstone.ts`), so no `revoked_at` / `revoke_reason` trail is
written and no `logout` audit row either (`logoutEventFor` records a
`/sign-out` alone). The account's API keys are untouched. Parity with
the forced reset's trail and key revocation is left to the maintainer.
- The after-response send lives as long as the process: a host that
freezes its process right after the response would lose it. The
platform's own Node server does not.
- `upgrade-harness/populate-fixture.ts` (the 18.0 fixture generator,
never collected by vitest) requests a reset and does not read the mail
row, so it is unchanged.
## Verification
Round 2 (the contract review's three must-fixes), head `9c006b978b`:
touched plugin tests 5 files, 94/94; typecheck exit 0. Ablation of
`revokeSessionsOnPasswordReset: true,` now turns 3 cases red (the
emailed-link door, the phone OTP door, the flag); restored. Derived
gates for the round's paths: 51 run, 51 × exit 0, `check:doc-authoring`
included; `check:optional-error-sink` exit 0. CI on `9c006b978b`: 38
success, 12 skipped, 0 failed. Round 1 follows.
All readings below are on head `3ef2258988` (this branch merged with
`origin/main` at `6f094645b4`).
<details><summary>plugin-auth: full suite and typecheck</summary>
- `pnpm --filter @objectstack/plugin-auth exec vitest run
--maxWorkers=2` → exit 0 · Test Files 218 passed (218) · Tests 3715
passed, 10 skipped (3725).
- `pnpm --filter @objectstack/plugin-auth run typecheck` → exit 0 (`tsc
--noEmit`, the examples project, and `check:test-typecheck`: OK, test
layer held at its ledger).
</details>
<details><summary>One-shot ablation of each fix (via
scripts/ablation-replace.mjs; restored to the HEAD blob, tree
clean)</summary>
- Session rule — delete `revokeSessionsOnPasswordReset: true,` in
`auth-manager-auth-instance.ts` (anchor 1 → 0, blob `e85d8f531be9` →
`12592d665800`): `password-reset-ends-sessions.test.ts` 2 failed / 4
passed — the two cases that state the rule ("the session signed in
before the reset is refused afterwards…" and "asks better-auth to end
sessions on every reset door…") went red; the config-refusal cases
stayed green. Restored blob `e85d8f531be9`.
- Reset request answer — force the public-door branch of
`sendAfterResponseOnPublicDoor` to await the send (`if (true) return
send(...)`, blob `4d5bc946ea91` → `fd8c1027b80f`):
`reset-request-mail-after-response.test.ts` 2 failed / 4 passed — the
two cases that state the rule ("answers while the send has not settled…"
and "logs a send that fails on the server…") went red; the control (the
mail is still sent) and the server-side call case stayed green.
Restored.
</details>
<details><summary>dogfood</summary>
- New pin `password-reset-ends-sessions.dogfood.test.ts` and the updated
`identity-mail-read-gate.dogfood.test.ts`: pass (1/1 and 25/25
targeted).
- Full battery `pnpm --filter @objectstack/dogfood test` (no shard, no
path; title carries `!`): Test Files 403 passed, 1 failed, 3 skipped
(407) · Tests 3320 passed, 1 failed, 28 skipped (3349). The one failure
is `managed-content-sealed.dogfood.test.ts` › "control … a NEW flow is
created through /meta and through /automation" — `Test timed out in
5000ms` inside a 35-minute run on a shared box; the file passes alone on
the same head (10/10). It touches no auth path.
</details>
<details><summary>Gates (node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack)</summary>
- 93 derived commands, all run with the exit code captured before any
pipe: 93 × exit 0. Three first answered `PREREQUISITE NOT MET` (exit 3:
`check:skill-examples` and `check:dual-build-cjs-loads` read packages
outside the dogfood build closure; `check:type-check-debt` did not get
the shared verify lock) and were re-run to exit 0 after building those
packages / acquiring the lock.
- `dispatch-gates --ran` reconciliation: 93 derived famil(ies) accounted
for — 93 run, 0 NOT-MEASURED (a derived zero: every entry recorded its
exit code).
- After the merge: `pnpm install --frozen-lockfile`, the dogfood closure
build and `pnpm --filter @objectstack/spec check:generated` → exit 0.
- CI-owned families (Lint & Repo Gates, Type Check, Test Core, Build
Core, the UI lanes) are read from this PR's check-runs.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e9ae8ef commit 3a856f6
14 files changed
Lines changed: 805 additions & 16 deletions
File tree
- .changeset
- content/docs/permissions
- docs
- packages
- plugins/plugin-auth/src
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
289 | 289 | | |
290 | 290 | | |
291 | 291 | | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
292 | 296 | | |
293 | 297 | | |
294 | 298 | | |
| |||
303 | 307 | | |
304 | 308 | | |
305 | 309 | | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
306 | 315 | | |
307 | 316 | | |
308 | 317 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
260 | 260 | | |
261 | 261 | | |
262 | 262 | | |
263 | | - | |
| 263 | + | |
264 | 264 | | |
265 | 265 | | |
266 | 266 | | |
| |||
276 | 276 | | |
277 | 277 | | |
278 | 278 | | |
279 | | - | |
280 | | - | |
| 279 | + | |
| 280 | + | |
281 | 281 | | |
282 | 282 | | |
283 | 283 | | |
| |||
344 | 344 | | |
345 | 345 | | |
346 | 346 | | |
347 | | - | |
348 | 347 | | |
349 | 348 | | |
350 | 349 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
309 | 309 | | |
310 | 310 | | |
311 | 311 | | |
312 | | - | |
313 | | - | |
314 | | - | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
315 | 318 | | |
316 | 319 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
119 | 119 | | |
120 | 120 | | |
121 | 121 | | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
122 | 130 | | |
123 | 131 | | |
124 | 132 | | |
| |||
317 | 325 | | |
318 | 326 | | |
319 | 327 | | |
| 328 | + | |
320 | 329 | | |
321 | 330 | | |
322 | 331 | | |
| |||
522 | 531 | | |
523 | 532 | | |
524 | 533 | | |
| 534 | + | |
525 | 535 | | |
526 | 536 | | |
527 | 537 | | |
| |||
619 | 629 | | |
620 | 630 | | |
621 | 631 | | |
| 632 | + | |
622 | 633 | | |
623 | 634 | | |
624 | 635 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
| 71 | + | |
71 | 72 | | |
72 | 73 | | |
73 | 74 | | |
| |||
382 | 383 | | |
383 | 384 | | |
384 | 385 | | |
385 | | - | |
386 | | - | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
387 | 391 | | |
388 | 392 | | |
389 | 393 | | |
390 | 394 | | |
391 | 395 | | |
392 | | - | |
393 | | - | |
| 396 | + | |
394 | 397 | | |
395 | | - | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
396 | 408 | | |
397 | 409 | | |
398 | 410 | | |
| |||
488 | 500 | | |
489 | 501 | | |
490 | 502 | | |
491 | | - | |
| 503 | + | |
492 | 504 | | |
493 | 505 | | |
494 | 506 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
80 | 80 | | |
81 | 81 | | |
82 | 82 | | |
| 83 | + | |
83 | 84 | | |
84 | 85 | | |
85 | 86 | | |
| |||
1604 | 1605 | | |
1605 | 1606 | | |
1606 | 1607 | | |
| 1608 | + | |
| 1609 | + | |
| 1610 | + | |
| 1611 | + | |
| 1612 | + | |
| 1613 | + | |
1607 | 1614 | | |
1608 | 1615 | | |
1609 | 1616 | | |
| |||
2075 | 2082 | | |
2076 | 2083 | | |
2077 | 2084 | | |
| 2085 | + | |
| 2086 | + | |
| 2087 | + | |
| 2088 | + | |
| 2089 | + | |
| 2090 | + | |
2078 | 2091 | | |
2079 | 2092 | | |
2080 | 2093 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
| 82 | + | |
| 83 | + | |
82 | 84 | | |
83 | 85 | | |
84 | 86 | | |
| |||
0 commit comments