Repository navigation
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:
|
…a signed bearer (objectstack-ai#8102) (objectstack-ai#8175) * fix(plugin-auth): run the D5.1 /oauth2/authorize env-access gate for a signed bearer (objectstack-ai#8102) ADR-0069 D5.1's cloud-as-IdP gate resolved its subject with an inline copy of resolveActor -- line for line the same logic, in a second place. objectstack-ai#8101 fixed one bug in the shared resolver and left the copy untouched, so the two diverged. The shared resolver learned that a bearer credential must have its signature stripped before lookup: bearer() hands clients the signed form in set-auth-token (the documented API-lane credential) and accepts it back, while session.token stores the unsigned value. The copy guarding /oauth2/authorize kept looking the signed credential up verbatim and resolved nothing. The unresolved case here is deliberately fail-open, so that miss did not deny the request -- it skipped the check entirely. An authenticated caller on the documented API lane was read as unauthenticated, and against a skip_consent client was issued an authorization code the gate would have refused. Delete the copy and call resolveActor. Two resolution sites are what let them diverge, so a second corrected copy would not have fixed the class. The fail-open default for genuinely unauthenticated callers is preserved unchanged. resolveActor also returns activeOrgId; this gate deliberately does not consume it -- the D5.1 host contract is (userId, clientId) and the control plane derives org membership from the user itself. Pinned by a dogfood gate that arms a DENYING gate and drives /oauth2/authorize over the cookie lane and both accepted bearer spellings, asserting the gate was actually invoked with the caller as its subject and the request refused rather than issued a code. The cookie and raw-token lanes are controls that pass on the broken build too; the signed-bearer lane is the pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 * test(qa): assert the fixture's own discriminator claim in the objectstack-ai#8102 pin The Lane.pinsTheDefect field was declared on every lane and read by nothing -- the same declared-but-unenforced shape this file exists to pin. Assert it: the suite now fails if a later edit makes a second lane the discriminator or drops the signed-bearer lane, either of which would leave the file green while measuring something other than objectstack-ai#8102. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --------- Co-authored-by: Claude <noreply@anthropic.com>
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