Repository navigation
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
Conversation
… sessions Every self-service reset door (the emailed link and the phone OTP reset) now ends every existing session of the account when it sets the new password, the same as an administrator's forced reset. It is not a deployment option: an explicit emailAndPassword.revokeSessionsOnPasswordReset false is refused where the configuration enters the manager (constructor and applyConfigPatch) instead of being silently overridden; true is accepted and changes nothing. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
…ime, for any address On the public door the reset mail is built and sent after the response, never inside it; a send failure is logged on the server and never reaches the caller. The identity import's own server-side reset call still awaits its send, because it reads the delivery receipt the send settles. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
…est does not wait on its mail New pins, stated as the rules: the session signed in before a completed reset is refused afterwards while a fresh sign-in is served (plugin and showcase HTTP doors); revokeSessionsOnPasswordReset false is refused at construction and on a patch; a reset request answers while its mail has not been sent, the mail is still sent after the response, and a failed send is logged on the server. Tests that read the public door's reset mail right after the response now wait for it to land. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
…equest answer The hardening guide no longer asks operators to set revokeSessionsOnPasswordReset (it is always on, and false is refused); the authentication guide states both rules. The changeset declares the narrowing (false refused) with its remedy. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a09b73f6889d41bb096e1b1d8e57f929eaa61704 && git checkout a09b73f6889d41bb096e1b1d8e57f929eaa61704
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4b71a82b76531059ad45ba224899aaadaa219ebc 64669bc8e48ecfb33428fb4e1cd4e3c13ffa1f8f && git checkout -B drift-repro 4b71a82b76531059ad45ba224899aaadaa219ebc && git merge --no-ff 64669bc8e48ecfb33428fb4e1cd4e3c13ffa1f8f
node scripts/docs-audit/affected-docs.mjs --json 4b71a82b76531059ad45ba224899aaadaa219ebc
|
…ts warn channel The failure sink type declared both `error` and `warn` optional, a contract that permits silence (check:optional-error-sink). `warn` is the channel every host logger guarantees (AuthManagerOptions.logger), so the type now says so; `error` stays optional and the console remains the floor when no host logger is wired. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 35af63a · 2026-11-03T07:26Z |
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentMust-fix (prose claims wider than the code, in the tree):
Tried to break it, held:
Implemented-by: VERDICT: FAIL |
…ming wording, pin the phone OTP reset - password-reset-ends-sessions.ts: a session a reset ends is deleted, not stamped (the reset paths are in neither the interactive-revoke ledger nor the erasure paths), writes no logout audit row (logoutEventFor records a /sign-out alone), and leaves the account's API keys alone. The earlier sentence claimed a logout row and back-channel logout. - run-after-response.ts and the sendResetPassword comment: the reset request answers at once and does not wait on the send; the wording no longer claims more. - password-reset-ends-sessions.test.ts: the phone OTP reset door (plugins.phoneNumber with an SMS service) is now pinned: the session signed in before a reset completed with the texted code is refused afterwards, and a fresh sign-in is served. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract reviewServed-tier: ① Derived judgmentsOne commit on top of the reviewed head, four files, no runtime change: two module headers, one comment, one new pin. Each of the three must-fixes from the earlier record, judged in the tree:
The PR body's Tests line and Acceptance notes were rewritten for this head and were checked sentence by sentence against the tree: the Tests line names the phone drive and its two routes as the pin has them; the Acceptance notes state the deleted-not-stamped outcome, the absent ② Semver levelUnchanged from the earlier record: ③ Boundary flags
④ Design judgmentThe delta is prose and a pin. Nothing in it reaches the runtime, so the earlier record's ④ findings hold unchanged (send failure, the receipt path, the runtime patch, the refusal's guidance, the deferred send's context). On the one new piece of evidence: the phone pin exercises the real vendor door end to end rather than a stub of it, uses the same session-refused-then-fresh-sign-in shape as the emailed-link pin, and puts no code or link value in a title or message. No must-fix found. Implemented-by: VERDICT: PASS |
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj 席位核对ACCEPT · head
|
One conflict, packages/plugins/plugin-auth/src/auth-manager.ts, two hunks: main added assertTrustedAppSchemes at the same two entry points (the constructor, after assertScimAdminCoherence, and applyConfigPatch, after the merged-plugins check) where this branch adds assertPasswordResetEndsSessions. The two assertions are independent; both are kept, this branch's first, main's lines unchanged. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract reviewServed-tier: ① Derived judgments
② Semver levelUnchanged: ③ Boundary flags
④ Design judgmentThe merge brings in a sibling entry validation at the same two seams. Tried: an ordering in which one assert ran after Implemented-by: VERDICT: PASS |
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj 席位核对ACCEPT · head
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj
Clause-②: no (narrowing) — the auth manager refuses an
emailAndPassword.revokeSessionsOnPasswordReset: falseit 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
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: falseis refused, never silently overridden. The key stays declared by@objectstack/spec.assertPasswordResetEndsSessions(new,password-reset-ends-sessions.ts) refuses an explicitfalsewhere the configuration enters the manager — the constructor (stops the boot) andapplyConfigPatch(the standing configuration keeps ruling) — the way the plugin already treats a declaration it will not honour (assertAudienceConfig,assertScimAdminCoherence).trueand an absent key are accepted.sendResetPasswordis wrapped bysendAfterResponseOnPublicDoor(new,run-after-response.ts): a call that carries itsRequest(the public door) resolves at once and the mail is built and sent after the response (setImmediate, the shapecreateRefusedAttachTombstonerin service-storage already uses); a failure is logged on the server at error. A call with noRequest— 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/HARDENING.mdno longer asks operators to set the key;content/docs/permissions/authentication.mdxstates both rules.Tests
plugin-auth/src/password-reset-ends-sessions.test.ts(new): the session signed in before a completed reset answers401 UNAUTHENTICATEDafterwards (through the emailed link and, the same, through the phone OTP reset —/phone-number/request-password-resetand/phone-number/reset-passwordwith 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;falseis 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 letsPOST /request-password-resetanswer, 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;runAfterResponsefailure 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.auth-email-locale.test.ts,identity-mail-sibling.test.ts,admin-import-users-invite-delivery-failure.test.ts⑥, andidentity-mail-read-gate.dogfood.test.ts(waits for the new identity row instead of reading the newest one at once).Acceptance notes
password-reset-ends-sessions.test.ts,plugins.phoneNumberwith 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.session-tombstone.ts), so norevoked_at/revoke_reasontrail is written and nologoutaudit row either (logoutEventForrecords a/sign-outalone). The account's API keys are untouched. Parity with the forced reset's trail and key revocation is left to the maintainer.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 ofrevokeSessionsOnPasswordReset: 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-authoringincluded;check:optional-error-sinkexit 0. CI on9c006b978b: 38 success, 12 skipped, 0 failed. Round 1 follows.All readings below are on head
3ef2258988(this branch merged withorigin/mainat6f094645b4).plugin-auth: full suite and typecheck
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, andcheck:test-typecheck: OK, test layer held at its ledger).One-shot ablation of each fix (via scripts/ablation-replace.mjs; restored to the HEAD blob, tree clean)
revokeSessionsOnPasswordReset: true,inauth-manager-auth-instance.ts(anchor 1 → 0, blobe85d8f531be9→12592d665800):password-reset-ends-sessions.test.ts2 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 blobe85d8f531be9.sendAfterResponseOnPublicDoorto await the send (if (true) return send(...), blob4d5bc946ea91→fd8c1027b80f):reset-request-mail-after-response.test.ts2 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.dogfood
password-reset-ends-sessions.dogfood.test.tsand the updatedidentity-mail-read-gate.dogfood.test.ts: pass (1/1 and 25/25 targeted).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 ismanaged-content-sealed.dogfood.test.ts› "control … a NEW flow is created through /meta and through /automation" —Test timed out in 5000msinside a 35-minute run on a shared box; the file passes alone on the same head (10/10). It touches no auth path.Gates (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack)
PREREQUISITE NOT MET(exit 3:check:skill-examplesandcheck:dual-build-cjs-loadsread packages outside the dogfood build closure;check:type-check-debtdid not get the shared verify lock) and were re-run to exit 0 after building those packages / acquiring the lock.dispatch-gates --ranreconciliation: 93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED (a derived zero: every entry recorded its exit code).pnpm install --frozen-lockfile, the dogfood closure build andpnpm --filter @objectstack/spec check:generated→ exit 0.Generated by Claude Code