Skip to content

fix(plugin-auth): /auth/change-password clears the force-change flag and enforces password-reuse on the bearer lane (#8049) - #8101

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8049-bearer-lane-password-change
Aug 12, 2026
Merged

fix(plugin-auth): /auth/change-password clears the force-change flag and enforces password-reuse on the bearer lane (#8049)#8101
os-zhuang merged 1 commit into
mainfrom
claude/issue-8049-bearer-lane-password-change

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8049

An admin-provisioned user (mustChangePassword defaults to true) is gated out of every protected route with 403 PASSWORD_EXPIRED until they rotate their password. On the bearer lane — the documented API/agent/CLI lane — that escape hatch did not work: POST /auth/change-password answered 200, the password really rotated, and the caller stayed locked out forever.

Measured on origin/main before editing (both lanes, real HTTP)

change-passwordpassword rotatedmust_change_passwordpassword_changed_atprotected readreuse of previous password
Cookie200yesfalsestamped200refused400 PASSWORD_REUSE
Bearer200yesstays truestays null403 PASSWORD_EXPIREDaccepted, 200

The 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: the password_changed_at / must_change_password stamp, 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) pushes options.hooks.before — the auth manager's global before-hook — ahead of every plugin before-hook, and bearer()'s before-hook is what rewrites Authorization: Bearer into a session cookie. resolvePasswordChangeUserId used a bare getSessionFromCtx(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

  1. /change-password now resolves through resolveActor, 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.
  2. resolveActor now strips the signature from a bearer credential the way it always did for cookies. bearer() hands clients the signed token.signature form in set-auth-token (the credential the issue's reproduction used) and accepts it back, while sys_session.token stores the unsigned value — so the credential the documented lane actually issues resolved nothing.

Point 2 is not cosmetic. See ablation B below: routing through resolveActor alone leaves the signed-bearer lane completely unfixed.

This also repairs the same lookup for the /sso/register admin gate, which shares the resolver.

Regression gate — both transports, same post-conditions

packages/qa/dogfood/test/bearer-lane-password-change.dogfood.test.ts drives /auth/change-password over cookie and over both accepted bearer spellings, asserting the same post-conditions on each: flag cleared, password_changed_at stamped, old password rejected, protected read 200, history appended, and a reused password refused. Rejection cases assert codeandstatus, 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 own APIError (400, code).

passwordHistoryCount defaults to 0 (off), which would make every reuse assertion vacuously green, so the suite arms it through applyConfigPatch — the same seam the settings service writes.

Predict-then-mutate ablation

Predictions were written down before running. Both matched exactly.

ablationpredictedmeasured
A — both edits removed6 green / 6 red; cookie lane whole, each bearer lane green only on the 403 gate test6 passed / 6 failed, exactly those
B — only the signature normalization removed9 green / 3 red; failures confined to the signed bearer lane9 passed / 3 failed, exactly those

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

No behaviour change on the cookie lane.

Generated by Claude Code


Generated by Claude Code

… /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
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 3:24pm

Request Review

@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx(via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx(via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-auth)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx(via @objectstack/plugin-auth)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review August 12, 2026 15:39
@os-zhuang
os-zhuang added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 36c2f00Aug 12, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8049-bearer-lane-password-change branch August 12, 2026 16:12
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude