Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): /auth/change-password clears the force-change flag and enforces password-reuse on the bearer lane (#8049) - #8101
Conversation
… /change-password (#8049) A successful POST /auth/change-password over a bearer token answered 200 and rotated the password, but cleared nothing: must_change_password stayed true and password_changed_at stayed null, so an admin-provisioned API client stayed locked out of every protected route by a success response. The same missing principal silently skipped a security control. One stash (ctx.context.__osPwChangeUserId) gates three behaviours -- the stamp, ADR-0069 D1's password-reuse rejection, and the history append -- so on the bearer lane password history was neither checked nor recorded. Cause: better-auth orders options.hooks.before ahead of every plugin before-hook, including bearer()'s, which is what rewrites Authorization: Bearer into a session cookie. The resolver used a bare getSessionFromCtx, which reads that cookie, so it resolved null on the bearer lane while better-auth's own password write (running after the conversion) succeeded. Resolve once for both lanes through the shared hook-order-independent resolveActor, rather than adding a second stamp site. That resolver now also strips the signature from a bearer credential the way it always did for cookies: bearer() issues the signed <token>.<sig> form in set-auth-token while sys_session.token stores the unsigned value, so the credential the documented API lane actually hands out resolved nothing. Pinned by a new dogfood gate that drives /auth/change-password over cookie and over both accepted bearer spellings, asserting the same post-conditions on each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8049
An admin-provisioned user (
mustChangePassworddefaults to true) is gated out of every protected route with403 PASSWORD_EXPIREDuntil they rotate their password. On the bearer lane — the documented API/agent/CLI lane — that escape hatch did not work:POST /auth/change-passwordanswered 200, the password really rotated, and the caller stayed locked out forever.Measured on
origin/mainbefore editing (both lanes, real HTTP)must_change_passwordpassword_changed_atfalse400 PASSWORD_REUSEtruenullThe filer's premise is confirmed in full, including the security half.
Why this carries
security, not just "a lockout"One stash —
ctx.context.__osPwChangeUserId, set by the before-hook when it resolves the acting user — gates three behaviours: thepassword_changed_at/must_change_passwordstamp, ADR-0069 D1's password-reuse rejection, and the history append. No principal resolved ⇒ none of them run. So the bearer lane did not merely stay flagged: password history was neither checked nor recorded there, and a user could immediately set their password back to the one just rotated away and be told 200. A control enforced on one transport and silently absent on the other is worse than one absent on both, because the console and every pre-existing pin exercise the working lane.Cause
better-auth's
getHooks(api/dispatch.mjs) pushesoptions.hooks.before— the auth manager's global before-hook — ahead of every plugin before-hook, andbearer()'s before-hook is what rewritesAuthorization: Bearerinto a session cookie.resolvePasswordChangeUserIdused a baregetSessionFromCtx(ctx), which reads that cookie. On the bearer lane the cookie does not exist yet, so it resolved null — while better-auth's own password write, running after the conversion, succeeded. That is the 200 with nothing stamped.Fix — one resolution site, not a second stamp site
/change-passwordnow resolves throughresolveActor, the pipeline's existing hook-order-independent resolver (cookie session first, explicit token lookup as fallback). All three behaviours hang off that one id, so both halves are fixed together.resolveActornow strips the signature from a bearer credential the way it always did for cookies.bearer()hands clients the signedtoken.signatureform inset-auth-token(the credential the issue's reproduction used) and accepts it back, whilesys_session.tokenstores the unsigned value — so the credential the documented lane actually issues resolved nothing.Point 2 is not cosmetic. See ablation B below: routing through
resolveActoralone leaves the signed-bearer lane completely unfixed.This also repairs the same lookup for the
/sso/registeradmin gate, which shares the resolver.Regression gate — both transports, same post-conditions
packages/qa/dogfood/test/bearer-lane-password-change.dogfood.test.tsdrives/auth/change-passwordover cookie and over both accepted bearer spellings, asserting the same post-conditions on each: flag cleared,password_changed_atstamped, old password rejected, protected read 200, history appended, and a reused password refused. Rejection cases assertcodeandstatus, and the two refusals are asserted distinctly — the gate refusal is the ADR-0112 REST envelope (403,error.code), the reuse refusal is better-auth's ownAPIError(400,code).passwordHistoryCountdefaults to 0 (off), which would make every reuse assertion vacuously green, so the suite arms it throughapplyConfigPatch— the same seam the settings service writes.Predict-then-mutate ablation
Predictions were written down before running. Both matched exactly.
Ablation A failure text:
must_change_password not cleared: expected true to be false,no password history recorded: expected [] to have a length of 1,expected 403 to be 200. Ablation B proves edit 2 is load-bearing rather than tidying.Verification
@objectstack/dogfoodfull suite: 96 passed | 1 skipped (97 files), 636 passed | 3 skipped@objectstack/plugin-authfull suite: 45 files, 1063 passedtypecheckgreen for both packagescheck:nul-bytes,check:docs-audit-scope,check:test-source-alias,check:cross-package-test-inputsgreencheck:type-check-debtgreen —plugin-authTEST_DEBT measures 108 against the recorded ceiling of 131 (the −23 gap is pre-existing and repo-wide, tracked by [finding][devx]check:type-check-debt的 ledger 余量会让新写的 pin 变哑:mongodb 曾有 33 条余量吞掉一次真实回退,另有 5 条目前带 4–19 余量 #6376; not moved here)No behaviour change on the cookie lane.
Generated by Claude Code
Generated by Claude Code