Skip to content

Commit 36c2f00

Browse files
os-zhuangclaude
andauthored
fix(plugin-auth): resolve the acting principal on the bearer lane for /change-password (#8049) (#8101)
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. Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b51981 commit 36c2f00

3 files changed

Lines changed: 348 additions & 5 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): `/auth/change-password` now clears the force-change flag and enforces password-reuse on the BEARER lane, not only on cookies (#8049)
6+
7+
An admin-provisioned user (`POST /auth/admin/create-user`, where
8+
`mustChangePassword` defaults to **true**) is gated out of every protected route
9+
with `403 PASSWORD_EXPIRED` until they rotate their password. On the **bearer
10+
lane** — the documented API/agent/CLI lane — that escape hatch did not work:
11+
`POST /auth/change-password` answered **200**, the password really rotated, and
12+
the caller stayed locked out forever. `must_change_password` stayed `true` and
13+
`password_changed_at` stayed `null`. The console was unaffected, because the
14+
cookie lane cleared both correctly.
15+
16+
**The same root cause silently skipped a security control.** One stash —
17+
`ctx.context.__osPwChangeUserId`, set by the before-hook when it resolves the
18+
acting user — gates three behaviours: the `password_changed_at` /
19+
`must_change_password` stamp, ADR-0069 D1's password-reuse **rejection**, and the
20+
password-history append. With no principal resolved, none of them ran, so on the
21+
bearer lane password history was **neither checked nor recorded** — a user could
22+
immediately "change" their password back to the one they had just rotated away
23+
and be told 200. A control enforced on one transport and absent on another is
24+
worse than one absent on both, because the console and the existing tests both
25+
exercise the working lane.
26+
27+
**Cause.** better-auth orders `options.hooks.before` (the auth manager's global
28+
before-hook) ahead of every plugin before-hook — including `bearer()`'s, which is
29+
what rewrites `Authorization: Bearer` into a session cookie. The resolver used a
30+
bare `getSessionFromCtx(ctx)`, which reads that cookie, so on the bearer lane it
31+
read a cookie that did not exist yet and resolved nothing, while better-auth's
32+
own password write — running after the conversion — succeeded.
33+
34+
**Fix.** The acting principal is now resolved once, for both lanes, through the
35+
shared hook-order-independent `resolveActor` (which falls back to explicit token
36+
lookup) rather than a second stamp site. That resolver also now strips the
37+
signature from a bearer credential the way it always did for cookies: `bearer()`
38+
hands clients the signed `<token>.<sig>` form in `set-auth-token` and accepts it
39+
back, while `sys_session.token` stores the unsigned value — so the credential the
40+
documented lane actually issues resolved nothing. This also repairs the same
41+
lookup for the `/sso/register` admin gate, which shares the resolver.
42+
43+
No behaviour change on the cookie lane.

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3822,11 +3822,19 @@ export class AuthManager {
38223822
const hdr = (k: string): string =>
38233823
((ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) as string) || '';
38243824
let token: string | undefined;
3825+
// `session.token` stores the UNSIGNED value, but a credential reaches us
3826+
// in either spelling: `bearer()` hands clients the SIGNED `<token>.<sig>`
3827+
// in `set-auth-token` (and accepts both back), while the cookie always
3828+
// carries the signed form. Strip the signature on BOTH branches — the
3829+
// cookie branch always did, and a bearer branch that did not silently
3830+
// resolved nothing for the exact credential the documented API lane hands
3831+
// out, which is indistinguishable from "not signed in".
3832+
const unsigned = (v: string): string => v.split('.')[0];
38253833
const bm = /^Bearer\s+(.+)$/i.exec(hdr('authorization'));
3826-
if (bm?.[1]) token = bm[1].trim();
3834+
if (bm?.[1]) token = unsigned(bm[1].trim());
38273835
if (!token) {
38283836
const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr('cookie'));
3829-
if (cm?.[1]) token = decodeURIComponent(cm[1]).split('.')[0];
3837+
if (cm?.[1]) token = unsigned(decodeURIComponent(cm[1]));
38303838
}
38313839
if (token) {
38323840
const sess: any = await (ctx as any).context.adapter.findOne({
@@ -4448,12 +4456,27 @@ export class AuthManager {
44484456
* `/change-password` the caller is authenticated (session); for
44494457
* `/reset-password` the user is carried by the reset token's verification
44504458
* value (the same lookup better-auth's own handler uses).
4459+
*
4460+
* [#8049] `/change-password` goes through {@link resolveActor}, the shared
4461+
* hook-order-independent resolver — NOT a bare `getSessionFromCtx`. That call
4462+
* reads the session COOKIE, and better-auth orders `options.hooks.before`
4463+
* (this hook) ahead of every plugin before-hook, including `bearer()`'s —
4464+
* which is precisely what converts `Authorization: Bearer` into that cookie.
4465+
* So on the bearer lane the cookie does not exist yet and the bare call
4466+
* resolves null, while better-auth's own password write (running after the
4467+
* conversion) succeeds: a 200 with nothing stamped.
4468+
*
4469+
* This is one resolution site on purpose. THREE behaviours hang off the id it
4470+
* returns — the `password_changed_at` / `must_change_password` stamp, ADR-0069
4471+
* D1's password-reuse REJECTION, and the history append — so a lane this
4472+
* cannot see is not merely a lane that stays flagged: it is a lane where a
4473+
* declared security control silently does not run. Resolving the principal
4474+
* correctly here fixes all three at once; a second stamp site would fix the
4475+
* visible one and leave the control transport-dependent.
44514476
*/
44524477
private async resolvePasswordChangeUserId(ctx: any): Promise<string | undefined> {
44534478
if (ctx?.path === '/change-password') {
4454-
const { getSessionFromCtx } = await import('better-auth/api');
4455-
const sess: any = await getSessionFromCtx(ctx).catch(() => null);
4456-
return sess?.user?.id ?? sess?.session?.userId ?? undefined;
4479+
return (await this.resolveActor(ctx))?.userId;
44574480
}
44584481
if (ctx?.path === '/reset-password') {
44594482
const token = typeof ctx?.body?.token === 'string' ? ctx.body.token : '';
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#8049] `/auth/change-password` must behave IDENTICALLY on every transport.
5+
*
6+
* ## The defect this pins
7+
*
8+
* An admin-provisioned user (`mustChangePassword` defaults to true) is gated out
9+
* of every protected route with `403 PASSWORD_EXPIRED` until they rotate their
10+
* password. On the COOKIE lane the escape hatch worked. On the BEARER lane —
11+
* the documented API/agent/CLI lane — `/auth/change-password` answered **200**,
12+
* the password really rotated, and nothing else happened: `must_change_password`
13+
* stayed `true`, `password_changed_at` stayed `null`, and the caller stayed
14+
* locked out of every protected route by a success response.
15+
*
16+
* Measured on `origin/main` before the fix, all three of these were true at once
17+
* on the bearer lane and false on the cookie lane — which is the whole point:
18+
*
19+
* must_change_password true password_changed_at null
20+
* protected read 403 previous_password_hashes null
21+
* reusing the previous password ACCEPTED (200)
22+
*
23+
* ## Why it is `security`, not just a lockout
24+
*
25+
* That last line is the half that is easy to under-fix. ONE stash —
26+
* `ctx.context.__osPwChangeUserId`, set by the before-hook when it resolves the
27+
* acting user — gates all three behaviours: the `password_changed_at` /
28+
* `must_change_password` stamp, ADR-0069 D1's password-reuse REJECTION, and the
29+
* history append. Unresolved principal ⇒ none of them run. So the bearer lane
30+
* did not merely stay flagged; password history was **neither checked nor
31+
* recorded** there. A control enforced on one transport and silently absent on
32+
* the other is worse than one absent on both, because the console and every
33+
* pre-existing pin exercise the working lane.
34+
*
35+
* Hence this file asserts the SAME post-conditions on every lane rather than
36+
* asserting the bug's absence on one. A fix that cleared the flags but left the
37+
* reuse control transport-dependent passes a lockout test and fails this one.
38+
*
39+
* ## Root cause, for whoever changes the resolver next
40+
*
41+
* better-auth's `getHooks` (`api/dispatch.mjs`) pushes `options.hooks.before`
42+
* — the auth manager's global before-hook — ahead of every PLUGIN before-hook,
43+
* and `bearer()`'s before-hook is what rewrites `Authorization: Bearer` into a
44+
* session cookie. A bare `getSessionFromCtx(ctx)` in our hook therefore reads a
45+
* cookie that does not exist yet on the bearer lane and resolves null, while
46+
* better-auth's own password write — which runs after the conversion — succeeds.
47+
* That is the 200-with-nothing-stamped. The resolver now goes through the shared
48+
* hook-order-independent `resolveActor`, which falls back to explicit token
49+
* lookup.
50+
*
51+
* ## Why BOTH bearer spellings are driven
52+
*
53+
* `bearer()` hands clients the SIGNED `<token>.<sig>` in the `set-auth-token`
54+
* response header (this is what the issue's reproduction used) and accepts both
55+
* that and the raw `token` from the sign-in body. `sys_session.token` stores the
56+
* UNSIGNED value. A resolver that looked the credential up verbatim would work
57+
* for one spelling and silently resolve nothing for the other — the same
58+
* per-transport asymmetry one level down. Driving both is what keeps that
59+
* closed; drop the signed lane and half the fix can be reverted invisibly.
60+
*
61+
* Harness notes:
62+
* - `/auth/admin/create-user` 501s unless better-auth's `admin` plugin is on,
63+
* and `bootStack` exposes no auth-plugin override. `OS_SCIM_ENABLED` is the
64+
* one env knob that reaches it (`buildPluginList` resolves
65+
* `admin: pluginConfig.admin ?? scimEffective`), so it must precede
66+
* `bootStack` — same shape as `admin-identity-audit-trail.dogfood.test.ts`.
67+
* - `passwordHistoryCount` is 0 (off) by default, which would make every reuse
68+
* assertion below vacuously green. It is set through `applyConfigPatch`, the
69+
* same seam the settings service writes, so the reuse control is genuinely
70+
* armed for all lanes.
71+
* - Two different error envelopes are asserted, deliberately and distinctly:
72+
* the gate refusal is the ADR-0112 REST envelope (`{error:{code}}`, 403)
73+
* raised at the transport seam, while the reuse refusal is better-auth's own
74+
* `APIError` (`{code}`, 400) surfaced through the proxied `/auth/*` route.
75+
* They are different outcomes and a test that conflated them could not tell
76+
* "reuse rejected" from "still locked out".
77+
*/
78+
79+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
80+
import showcaseStack from '@objectstack/example-showcase';
81+
import { bootStack, type VerifyStack } from '@objectstack/verify';
82+
83+
const SYS = { context: { isSystem: true } };
84+
85+
/** Depth of the ADR-0069 D1 history ring this file arms. */
86+
const HISTORY_COUNT = 3;
87+
88+
const FIRST_PASSWORD = 'BearerLane!First1';
89+
const SECOND_PASSWORD = 'BearerLane!Second2';
90+
91+
/** Collect a response's Set-Cookie values into a single request Cookie header. */
92+
function cookieHeader(res: Response): string {
93+
const jar = res.headers.getSetCookie?.() ?? [];
94+
return jar.map((c) => c.split(';')[0]).join('; ');
95+
}
96+
97+
/**
98+
* One authenticated transport. `credential` is whatever the lane carries after
99+
* a sign-in; `headers` turns it into the request headers that lane would send.
100+
*/
101+
interface Lane {
102+
readonly name: string;
103+
/** Pick this lane's credential out of a sign-in response. */
104+
credential(res: Response, body: { token?: string }): string;
105+
/** The auth headers a request on this lane carries. */
106+
headers(credential: string): Record<string, string>;
107+
}
108+
109+
const LANES: Lane[] = [
110+
{
111+
name: 'cookie',
112+
credential: (res) => cookieHeader(res),
113+
headers: (c) => ({ Cookie: c }),
114+
},
115+
{
116+
// The credential the issue's reproduction used: the `set-auth-token`
117+
// response header, which carries the SIGNED `<token>.<sig>` form.
118+
name: 'bearer (signed set-auth-token)',
119+
credential: (res) => res.headers.get('set-auth-token') ?? '',
120+
headers: (c) => ({ Authorization: `Bearer ${c}` }),
121+
},
122+
{
123+
// The other accepted spelling: the raw session token from the sign-in body.
124+
name: 'bearer (raw sign-in token)',
125+
credential: (_res, body) => body.token ?? '',
126+
headers: (c) => ({ Authorization: `Bearer ${c}` }),
127+
},
128+
];
129+
130+
describe('#8049: /auth/change-password clears the force-change flag and enforces reuse on EVERY transport', () => {
131+
let stack: VerifyStack;
132+
let ql: any;
133+
let adminToken: string;
134+
let priorScim: string | undefined;
135+
136+
beforeAll(async () => {
137+
priorScim = process.env.OS_SCIM_ENABLED;
138+
process.env.OS_SCIM_ENABLED = 'true';
139+
stack = await bootStack(showcaseStack, {});
140+
ql = await stack.kernel.getServiceAsync<any>('objectql');
141+
142+
// Arm ADR-0069 D1's history ring. Default is 0 (off), under which every
143+
// reuse assertion in this file would pass without testing anything.
144+
const auth = await stack.kernel.getServiceAsync<any>('auth');
145+
auth.applyConfigPatch({ passwordHistoryCount: HISTORY_COUNT });
146+
147+
adminToken = await stack.signIn();
148+
}, 180_000);
149+
150+
afterAll(async () => {
151+
await stack?.stop?.();
152+
if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED;
153+
else process.env.OS_SCIM_ENABLED = priorScim;
154+
});
155+
156+
/** Sign in through the real route and hand back every lane's credential. */
157+
async function signIn(email: string, password: string) {
158+
const res = await stack.api('/auth/sign-in/email', {
159+
method: 'POST',
160+
headers: { 'Content-Type': 'application/json' },
161+
body: JSON.stringify({ email, password }),
162+
});
163+
const body = res.status === 200 ? ((await res.clone().json()) as { token?: string }) : {};
164+
return { res, body };
165+
}
166+
167+
/** The `sys_user` row + its credential account, read with system context. */
168+
async function identity(email: string) {
169+
const user = (await ql.find('sys_user', { where: { email }, limit: 1 }, SYS))[0];
170+
const account = (
171+
await ql.find(
172+
'sys_account',
173+
{ where: { user_id: String(user?.id), provider_id: 'credential' }, limit: 1 },
174+
SYS,
175+
)
176+
)[0];
177+
const raw = account?.previous_password_hashes;
178+
let history: string[] = [];
179+
if (typeof raw === 'string' && raw.trim()) {
180+
try {
181+
const parsed = JSON.parse(raw);
182+
if (Array.isArray(parsed)) history = parsed;
183+
} catch {
184+
throw new Error(`previous_password_hashes is not JSON: ${raw}`);
185+
}
186+
}
187+
return { user, history };
188+
}
189+
190+
for (const lane of LANES) {
191+
// eslint-disable-next-line vitest/valid-title
192+
describe(`lane: ${lane.name}`, () => {
193+
// One provisioned user per lane — the flags are per-user, so sharing one
194+
// would let an earlier lane's successful change satisfy a later lane's
195+
// assertions and hide exactly the asymmetry this file exists to catch.
196+
const email = `bearer.lane.8049.${lane.name.replace(/[^a-z]/gi, '').toLowerCase()}@example.com`;
197+
let credential = '';
198+
199+
it('an admin-provisioned user is gated out with 403 PASSWORD_EXPIRED', async () => {
200+
const created = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', {
201+
email,
202+
name: `Bearer Lane ${lane.name}`,
203+
password: FIRST_PASSWORD,
204+
});
205+
expect(created.status, await created.clone().text()).toBe(200);
206+
expect((await created.json()).data.mustChangePassword).toBe(true);
207+
208+
const { res, body } = await signIn(email, FIRST_PASSWORD);
209+
expect(res.status, await res.clone().text()).toBe(200);
210+
credential = lane.credential(res, body);
211+
expect(credential, `${lane.name}: sign-in yielded no credential`).toBeTruthy();
212+
213+
const read = await stack.api('/data/showcase_task?$top=1', { headers: lane.headers(credential) });
214+
// The gate refusal — asserted as code AND status, and distinctly from
215+
// the reuse refusal below (different envelope, different outcome).
216+
expect(read.status).toBe(403);
217+
expect((await read.json())?.error?.code).toBe('PASSWORD_EXPIRED');
218+
}, 120_000);
219+
220+
it('POST /auth/change-password rotates the password AND clears the force-change flag', async () => {
221+
const changed = await stack.api('/auth/change-password', {
222+
method: 'POST',
223+
headers: { 'Content-Type': 'application/json', ...lane.headers(credential) },
224+
body: JSON.stringify({ currentPassword: FIRST_PASSWORD, newPassword: SECOND_PASSWORD }),
225+
});
226+
expect(changed.status, await changed.clone().text()).toBe(200);
227+
228+
// The rotation itself was never the broken half — it landed on every
229+
// lane, which is why the defect answered 200 and looked fine.
230+
const stale = await signIn(email, FIRST_PASSWORD);
231+
expect(stale.res.status).toBe(401);
232+
expect((await stale.res.json())?.code).toBe('INVALID_EMAIL_OR_PASSWORD');
233+
234+
// …and the half that did NOT run on the bearer lane.
235+
const { user } = await identity(email);
236+
expect(user.must_change_password, `${lane.name}: must_change_password not cleared`).toBe(false);
237+
expect(user.password_changed_at, `${lane.name}: password_changed_at not stamped`).toBeTruthy();
238+
expect(Number.isFinite(new Date(user.password_changed_at as string).getTime())).toBe(true);
239+
}, 120_000);
240+
241+
it('the caller can then reach protected routes with a fresh session', async () => {
242+
const { res, body } = await signIn(email, SECOND_PASSWORD);
243+
expect(res.status, await res.clone().text()).toBe(200);
244+
credential = lane.credential(res, body);
245+
246+
const read = await stack.api('/data/showcase_task?$top=1', { headers: lane.headers(credential) });
247+
expect(read.status, await read.clone().text()).toBe(200);
248+
}, 120_000);
249+
250+
it('ADR-0069 D1: the change RECORDED history and a reused password is REJECTED', async () => {
251+
// Recorded — the old hash landed in the bounded ring. On the unfixed
252+
// bearer lane this column stayed null, so a fixture that only checked
253+
// the flags would have called the security half fixed.
254+
const { history } = await identity(email);
255+
expect(history, `${lane.name}: no password history recorded`).toHaveLength(1);
256+
257+
// …and checked. Reusing the password that was just rotated away must be
258+
// refused — code AND status, distinct from the 403 gate refusal above.
259+
const reuse = await stack.api('/auth/change-password', {
260+
method: 'POST',
261+
headers: { 'Content-Type': 'application/json', ...lane.headers(credential) },
262+
body: JSON.stringify({ currentPassword: SECOND_PASSWORD, newPassword: FIRST_PASSWORD }),
263+
});
264+
expect(reuse.status, await reuse.clone().text()).toBe(400);
265+
const refusal = await reuse.json();
266+
expect(refusal?.code).toBe('PASSWORD_REUSE');
267+
expect(String(refusal?.message)).toContain(`last ${HISTORY_COUNT} passwords`);
268+
269+
// The refusal must not have rotated anything: the ring is unchanged and
270+
// the current password still signs in.
271+
expect((await identity(email)).history).toHaveLength(1);
272+
const still = await signIn(email, SECOND_PASSWORD);
273+
expect(still.res.status).toBe(200);
274+
}, 120_000);
275+
});
276+
}
277+
});

0 commit comments

Comments
 (0)