Skip to content

Commit 133f75a

Browse files
fix(plugin-auth): an auth request is judged and executed as one account (#31469)
Clause-②: yes — this PR publishes one new error code, `CREDENTIALS_NAME_DIFFERENT_ACCOUNTS`, in the `@objectstack/spec` error-code ledger. Metadata-protocol: none ## 维护者速读 - 改了什么:一个认证请求若同时带着会话 cookie 和 bearer token,且两者指向两个不同的已登录账号,就在所有门禁和端点运行之前以 `401 CREDENTIALS_NAME_DIFFERENT_ACCOUNTS` 拒绝,两个账号都不写任何东西;改密码成功后的后续写入(改密时间戳、强制改密标记、密码历史)只落在端点实际改动的那个账号上;管理员撤销会话在仅用 bearer 登录时也认得出调用者。 - 为什么改:一个认证请求必须作为同一个账号被判断、被执行。 - 风险与回滚:只拒绝同时带着两个不同账号凭据的请求;单凭据、同账号的两个凭据、指向已失效会话的凭据都不受影响。Console 现有的会话读取自愈(只带 cookie 重试并丢弃 token)已实测能处理这个新拒绝,前端无改动。spec 错误码账本新增一个码,因此 Clause-② 为 yes。回滚即 revert 本 PR。 - 先看:`packages/plugins/plugin-auth/src/one-account-per-request.ts`(规则与判定);`auth-manager-before-hook.ts` 中新步骤的位置(所有门禁之前);`auth-manager-after-hook.ts` 的 `/change-password` 一段。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/one-account-per-request.test.ts`,预期 17 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes **An auth request is judged and executed as one account.** - A request whose session cookie and bearer token name two different signed-in accounts is refused before anything runs, with `401 CREDENTIALS_NAME_DIFFERENT_ACCOUNTS`, and nothing is read or written for either account. The step runs first in the global better-auth before-hook (ahead of every ObjectStack gate and of the endpoint, over HTTP and for the in-process session read), and as a middleware on the identity mount ahead of its raw ObjectStack routes, which answer it with the same code in the ADR-0112 envelope. - Unaffected: one credential alone; two credentials naming the same account (two sessions of one user included); a credential that names no signed-in account. Two identical tokens cost no lookup. - Fail-closed: a session read that cannot answer refuses as an outage, `503 SERVICE_UNAVAILABLE`. - A successful `/change-password` stamps `password_changed_at`, clears a forced change and appends the history entry on the account the endpoint acted on (`ctx.context.session.user`, the reading the change-email notice already takes), and only when the password checks before it ran for that same account; otherwise none of them lands and the operator is told. - `/admin/revoke-user-session` resolves a caller signed in by bearer alone, so a token that identifies no session is refused (`404 RESOURCE_NOT_FOUND`) on that lane as on the cookie lane. - `CREDENTIALS_NAME_DIFFERENT_ACCOUNTS` is registered in the `@objectstack/spec` error-code ledger under `@objectstack/plugin-auth` (no existing 401 code means "credentials name different accounts"); the three generated reference pages that list the ledger are regenerated. ## Tests - `one-account-per-request.test.ts` (new, 17 cases, real ObjectQL over better-sqlite3 and the installed better-auth, two real accounts): the refusal on the session read (HTTP and in process), a data door's session leg, `/change-password`, `/revoke-session`, `/update-user` and the admin paths (`/admin/set-role`, `/admin/revoke-user-session`, the administrator on either credential), each asserting code and status and that nothing was written; same account on both credentials, cookie alone, bearer alone, a bearer or a cookie naming no signed-in account; the identity mount's raw admin route and its catch-all; the judgment's token reading and the 503 on a read that throws; an API key beside a session cookie is not refused by this rule; a request carrying both credentials whose judgment cannot be reached at the identity mount is answered `503` and goes no further. - `forced-password-change-new-password.test.ts`: one new top-level describe (5 cases) — after-effects land on the account the change acted on, on the bearer lane, and on no other account. - `admin-revoke-user-session-match-guard.test.ts`: one new top-level describe (3 cases) — the bearer lane is judged as the cookie lane. - `change-email-delete-user-wiring.test.ts`: the two-account case now asserts the refusal, no mail and no address moved. ## Acceptance notes - A data door that reads such a request in process sees a refused session read and resolves no account, so it answers with its own anonymous refusal; the coded `401` reaches the wire on the auth routes and the identity mount, not on the data routes. A door that re-reads an active authentication gate treats any failed session read as a store outage and answers `503` there. Carrying the code through those doors is a change in `packages/core` / `packages/runtime`, outside this PR. - A credential that names no signed-in account leaves the request as it was (measured); this rule does not change it. - An API key is not a session credential for this rule: a key beside a session cookie is not refused by it, and each door keeps its existing one-principal rule for keys. - The Console needs no change: measured against the shipped `createAuthClient.getSession`, a coded `401` on the bearer-carrying read is followed by the cookie-only read, whose session and token are adopted. ## Verification On head `7f088a82dc` (origin/main merged in) unless stated. Round 2 (`52faff29f`) adds the API-key pin and the identity mount's `503` pin: `pnpm --filter @objectstack/plugin-auth test` Test Files 241 passed (241), Tests 4292 passed, 10 skipped; typecheck exit 0; each new pin ablated red, then restored. <details><summary>plugin-auth suite and typecheck</summary> - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0 (src, examples, `check:test-typecheck`). - `pnpm --filter @objectstack/plugin-auth test`: Test Files 241 passed (241), Tests 4290 passed, 10 skipped (4300). </details> <details><summary>Ablation (each leg reverted on disk, red, restored to HEAD with an empty diff)</summary> - Before-hook step removed: 7 of the two-account pins red (session read, data door, `/change-password`, `/revoke-session`, `/update-user`, admin paths, change-email), 28 green. - Identity mount refusal removed: 1 red (the raw admin route), 14 green. - After-effects account rule removed: 1 red (checks run for another account), 12 green. - Bearer-lane caller resolution removed: 1 red (zero-match token on the bearer lane), 17 green. </details> <details><summary>Derived gate families</summary> - `dispatch-gates --commands` at `7f088a82dc`: 120 families; 120 run, reconciled with `--ran` (120 derived, 120 run, 0 unrun). - 118 exit 0 on the first pass; `check:skill-examples` refused for an unbuilt `@objectstack/client-react` and exited 0 once that closure was built; `check:dual-build-cjs-loads` NOT MEASURED (it needs the whole workspace built), left to CI. - `pnpm --filter @objectstack/spec check:generated`: one stale artifact (the reference docs) regenerated with `gen:docs`. </details> <!-- signed: security#1 · sunhaolin · session_017xRBcT1w3zpekijraHJE4f --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 95df345 commit 133f75a

16 files changed

Lines changed: 1112 additions & 24 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(plugin-auth): an auth request is judged and executed as one account
7+
8+
Clause-②: yes
9+
Metadata-protocol: none
10+
11+
A request whose session cookie and bearer token name two different signed-in accounts is refused before anything runs, with `401 CREDENTIALS_NAME_DIFFERENT_ACCOUNTS`, and nothing is read or written for either account. It applies to every better-auth endpoint (over HTTP and to the in-process session read every data door makes) and to the raw ObjectStack routes on the identity mount. One credential alone, two credentials that name the same account (two sessions of one user included), and a credential that names no signed-in account are unaffected. An API key is not a session credential for this rule; each door keeps its existing one-principal rule for keys. A session read that cannot answer refuses too, as an outage (`503 SERVICE_UNAVAILABLE`), and so does the identity mount when the judgment cannot be reached at all.
12+
13+
`CREDENTIALS_NAME_DIFFERENT_ACCOUNTS` is registered in the `@objectstack/spec` error-code ledger under `@objectstack/plugin-auth`.
14+
15+
A successful `/change-password` stamps `password_changed_at`, clears an administrator's forced change and appends the history entry on the account the change acted on, and only when the password checks before it ran for that account; otherwise none of them lands, and the operator is told.
16+
17+
`/admin/revoke-user-session` refuses a token that identifies no session (`404 RESOURCE_NOT_FOUND`) for a caller signed in by bearer alone, as it already did on the cookie lane.
18+
19+
The Console needs no change: its session read already treats any refused read as a stale token, retrying with the cookie alone and dropping the token.

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ const result = AutomationApiErrorCode.parse(data);
417417

418418
| Property | Type | Required | Description |
419419
| :--- | :--- | :--- | :--- |
420-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +363 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
420+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +364 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
421421
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
422422

423423
### Allowed Values: `FlowInnerRefusal.code`
@@ -520,6 +520,7 @@ const result = AutomationApiErrorCode.parse(data);
520520
* `CONNECTOR_UPSTREAM_CHANGED`
521521
* `CONNECTOR_UPSTREAM_UNAVAILABLE`
522522
* `CREATE_FAILED`
523+
* `CREDENTIALS_NAME_DIFFERENT_ACCOUNTS`
523524
* `CUBE_NOT_FOUND`
524525
* `DATASET_INVALID`
525526
* `DATASOURCE_ADMIN_ERROR`

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +363 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +364 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -138,6 +138,7 @@ const result = ApiErrorSchema.parse(data);
138138
* `CONNECTOR_UPSTREAM_CHANGED`
139139
* `CONNECTOR_UPSTREAM_UNAVAILABLE`
140140
* `CREATE_FAILED`
141+
* `CREDENTIALS_NAME_DIFFERENT_ACCOUNTS`
141142
* `CUBE_NOT_FOUND`
142143
* `DATASET_INVALID`
143144
* `DATASOURCE_ADMIN_ERROR`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ const result = ErrorCode.parse(data);
305305
* `CONNECTOR_UPSTREAM_CHANGED`
306306
* `CONNECTOR_UPSTREAM_UNAVAILABLE`
307307
* `CREATE_FAILED`
308+
* `CREDENTIALS_NAME_DIFFERENT_ACCOUNTS`
308309
* `CUBE_NOT_FOUND`
309310
* `DATASET_INVALID`
310311
* `DATASOURCE_ADMIN_ERROR`

‎packages/plugins/plugin-auth/src/admin-revoke-user-session-match-guard.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,64 @@ describe('#10069 — /admin/revoke-user-session refuses when the token identifie
326326
});
327327
});
328328

329+
// ───────────────────────────────────────────────────────────────────────────
330+
/**
331+
* The bearer lane. The global before-hook runs before better-auth folds an
332+
* `Authorization: Bearer` token into the session cookie, so the guard resolves
333+
* a caller signed in by bearer alone through the shared actor resolver, and
334+
* grades the role stored on that user. Same answers as the cookie lane.
335+
*/
336+
describe('/admin/revoke-user-session judges a caller signed in by bearer alone as it judges the cookie lane', () => {
337+
const bearerFrom = (response: Response): string => response.headers.get('set-auth-token') ?? '';
338+
const revokeByBearer = (manager: AuthManager, bearer: string, sessionToken: unknown) =>
339+
manager.handleRequest(
340+
new Request(`${BASE}/admin/revoke-user-session`, {
341+
method: 'POST',
342+
headers: { 'Content-Type': 'application/json', authorization: `Bearer ${bearer}` },
343+
body: JSON.stringify({ sessionToken }),
344+
}),
345+
);
346+
const seedBearer = async () => {
347+
const seeded = await seed();
348+
const adminBearer = bearerFrom(await signIn(seeded.manager, 'admin@example.com'));
349+
expect(adminBearer, 'sign-in must hand out a bearer').not.toBe('');
350+
return { ...seeded, adminBearer };
351+
};
352+
353+
it('a token matching ZERO rows answers 404 with code AND status', async () => {
354+
const { engine, manager, targetRow, adminBearer } = await seedBearer();
355+
356+
const res = await revokeByBearer(manager, adminBearer, 'no-such-token-anywhere');
357+
const body: any = await res.json();
358+
expect(res.status).toBe(404);
359+
expect(body?.code).toBe(ADMIN_REVOKE_USER_SESSION_NOT_FOUND_CODE);
360+
expect(body?.success).not.toBe(true);
361+
expect(sessionRows(engine).find((r) => r.id === targetRow.id)?.revoked_at).toBeUndefined();
362+
});
363+
364+
it("an admitted revoke still succeeds — an admin revoking an arbitrary user's session", async () => {
365+
const { engine, manager, targetCookie, targetRow, adminBearer } = await seedBearer();
366+
367+
const res = await revokeByBearer(manager, adminBearer, targetRow.token);
368+
expect(res.status, await res.clone().text()).toBe(200);
369+
expect(await res.json()).toHaveProperty('success', true);
370+
expect(sessionRows(engine).find((r) => r.id === targetRow.id)?.revoke_reason).toBe('admin');
371+
expect(await isAuthenticated(manager, targetCookie)).toBe(false);
372+
});
373+
374+
it('a NON-ADMIN caller keeps the vendor 403 for missing and live tokens alike', async () => {
375+
const { manager, targetRow } = await seedBearer();
376+
const plainBearer = bearerFrom(await signUp(manager, 'plain-bearer@example.com'));
377+
expect(plainBearer).not.toBe('');
378+
379+
const missing = await revokeByBearer(manager, plainBearer, 'no-such-token-anywhere');
380+
const live = await revokeByBearer(manager, plainBearer, targetRow.token);
381+
expect(missing.status).toBe(403);
382+
expect(live.status).toBe(403);
383+
expect(await missing.text()).toBe(await live.text());
384+
});
385+
});
386+
329387
// ───────────────────────────────────────────────────────────────────────────
330388
describe('adminMayRevokeUserSessions — the vendor permission question, vendor inputs', () => {
331389
const admin = { id: 'u_1', role: 'admin' };

‎packages/plugins/plugin-auth/src/auth-manager-actor-guards.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
* caller awaits the same promise the body settles, with no extra wrapper. A
2727
* method is handed over as a closure that calls it on the manager at call
2828
* time, so a test double or spy set on the manager is still the one the body
29-
* reaches — `resolveActor` included, which three of the guards ask through
29+
* reaches — `resolveActor` included, which four of the guards ask through
3030
* the manager rather than calling the function here directly.
3131
*
3232
* Each field was checked on its own: `orgRolesMap` and
@@ -463,7 +463,7 @@ export async function assertRevokeSessionIdentifiesRecord(this: Pick<ActorGuardH
463463
}
464464

465465
/** The body of `AuthManager#assertAdminRevokeUserSessionIdentifiesRecord`; the method's docblock there is its contract. */
466-
export async function assertAdminRevokeUserSessionIdentifiesRecord(ctx: any): Promise<void> {
466+
export async function assertAdminRevokeUserSessionIdentifiesRecord(this: Pick<ActorGuardHost, 'resolveActor'>, ctx: any): Promise<void> {
467467
const token = ctx?.body?.sessionToken;
468468
if (typeof token !== 'string') return; // vendor's body schema answers 400
469469

@@ -474,8 +474,19 @@ export async function assertAdminRevokeUserSessionIdentifiesRecord(ctx: any): Pr
474474
// a demoted caller the vendor is about to refuse).
475475
const { getAuthoritativeSessionFromCtx } = await import('better-auth/api');
476476
const s: any = await getAuthoritativeSessionFromCtx(ctx).catch(() => null);
477+
let caller: any = s?.user;
478+
// The bearer lane: this hook runs before better-auth folds an
479+
// `Authorization: Bearer` token into the session cookie, so the read
480+
// above sees no session for a caller signed in by bearer alone. The
481+
// caller is then the user the shared actor resolver names, read back
482+
// from the store (`findUserById`), so the role graded below is the
483+
// stored one, as on the cookie lane.
484+
if (!caller?.id) {
485+
const actor = await this.resolveActor(ctx);
486+
if (actor?.userId) caller = await ctx.context.internalAdapter.findUserById(actor.userId);
487+
}
477488
// No resolvable caller → the vendor's adminMiddleware issues the 401.
478-
if (!s?.user?.id) return;
489+
if (!caller?.id) return;
479490

480491
// The vendor's own permission question, with the vendor's own inputs:
481492
// the LIVE admin-plugin options (the object the mounted plugin retains)
@@ -485,7 +496,7 @@ export async function assertAdminRevokeUserSessionIdentifiesRecord(ctx: any): Pr
485496
(p: any) => p?.id === 'admin',
486497
)?.options;
487498
const { defaultRoles } = await import('better-auth/plugins/admin/access');
488-
if (!adminMayRevokeUserSessions(s.user, adminOptions, defaultRoles as any)) return;
499+
if (!adminMayRevokeUserSessions(caller, adminOptions, defaultRoles as any)) return;
489500

490501
const found = await ctx.context.internalAdapter.findSession(token);
491502
admitted = anySessionCarriesToken(found);

‎packages/plugins/plugin-auth/src/auth-manager-after-hook.ts‎

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface AfterHookHost {
6767
enforceConcurrentCap: AuthManager['enforceConcurrentCap'];
6868
externalDirectoryDoorDeps: AuthManager['externalDirectoryDoorDeps'];
6969
isEmailDeliveryLive: AuthManager['isEmailDeliveryLive'];
70+
logDurabilityDegradation: AuthManager['logDurabilityDegradation'];
7071
recordPasswordHistory: AuthManager['recordPasswordHistory'];
7172
roleChangeGrantDeps: AuthManager['roleChangeGrantDeps'];
7273
sendChangeEmailNotice: AuthManager['sendChangeEmailNotice'];
@@ -90,6 +91,38 @@ function changeEmailFrom(user: unknown): { email: string; name?: string; id?: st
9091
};
9192
}
9293

94+
/**
95+
* The user of the session the endpoint acted on, as better-auth's session
96+
* middleware recorded it on `ctx.context.session` before the handler ran.
97+
*/
98+
function actingUserIdOf(ctx: any): string | undefined {
99+
const id: unknown = ctx?.context?.session?.user?.id;
100+
return typeof id === 'string' && id !== '' ? id : undefined;
101+
}
102+
103+
/**
104+
* ADR-0069 D1 — the account a successful password change's after-effects
105+
* (the `password_changed_at` stamp, the forced-change flag it clears, the
106+
* history entry) land on, or `undefined` when they land on none.
107+
*
108+
* `/change-password`: the account the endpoint acted on
109+
* ({@link actingUserIdOf}), and only when the before-hook's password checks
110+
* ran for that same account (`__osPwChangeUserId`, stashed beside them). A
111+
* clear of the forced-change flag is the verdict those checks gate, so an
112+
* account they did not judge is given none of it, and no account but the one
113+
* changed is ever stamped.
114+
*
115+
* `/reset-password`: no session is involved; the account is the reset
116+
* token's, which the before-hook resolved, as before.
117+
*/
118+
function passwordChangeAfterEffectsAccount(ctx: any): string | undefined {
119+
const checkedFor: unknown = ctx?.context?.__osPwChangeUserId;
120+
const checked = typeof checkedFor === 'string' && checkedFor !== '' ? checkedFor : undefined;
121+
if (ctx?.path !== '/change-password') return checked;
122+
const acting = actingUserIdOf(ctx);
123+
return acting !== undefined && acting === checked ? acting : undefined;
124+
}
125+
93126
/**
94127
* better-auth's admin plugin refuses a banned user's new session with this
95128
* code, from its `session.create.before` hook (`ADMIN_ERROR_CODES.BANNED_USER`
@@ -251,6 +284,15 @@ export async function globalAfterHook(this: AfterHookHost, ctx: any) {
251284

252285
// ── ADR-0069 D1: on a successful change/reset — stamp
253286
// password_changed_at (expiry) and commit password history.
287+
//
288+
// Every after-effect lands on ONE account, read by
289+
// `passwordChangeAfterEffectsAccount`: on `/change-password` the account
290+
// the endpoint itself acted on — the session better-auth's
291+
// `sensitiveSessionMiddleware` recorded on `ctx.context.session` before
292+
// the handler ran, the reading the change-email notice below takes — and
293+
// only when the before-hook's reuse and forced-change checks ran for that
294+
// same account. The history entry is the old hash those checks read, so it
295+
// is appended only to the account it was read from.
254296
if (ctx?.path === '/change-password' || ctx?.path === '/reset-password') {
255297
let succeeded: boolean;
256298
try {
@@ -260,10 +302,27 @@ export async function globalAfterHook(this: AfterHookHost, ctx: any) {
260302
succeeded = !(ctx?.context?.returned instanceof Error);
261303
}
262304
if (succeeded) {
263-
const stampId = ctx?.context?.__osPwChangeUserId;
264-
if (stampId) await this.stampPasswordChangedAt(stampId);
265-
const stash = ctx?.context?.__osPwHistory;
266-
if (stash?.userId) await this.recordPasswordHistory(stash.userId, stash.oldHash);
305+
const stampId = passwordChangeAfterEffectsAccount(ctx);
306+
if (stampId) {
307+
await this.stampPasswordChangedAt(stampId);
308+
const stash = ctx?.context?.__osPwHistory;
309+
if (stash?.userId === stampId) await this.recordPasswordHistory(stash.userId, stash.oldHash);
310+
} else if (ctx?.path === '/change-password' && actingUserIdOf(ctx)) {
311+
// The password changed, but the checks that gate these writes never
312+
// judged the account it changed on: nothing is stamped, so a forced
313+
// change stays outstanding and the user is asked again (the safe
314+
// direction), and the operator is told, here.
315+
this.logDurabilityDegradation(
316+
'AuthManager: a password change succeeded, but its password_changed_at stamp, the '
317+
+ 'must_change_password clear and the history entry were NOT written: the password '
318+
+ 'checks before it (reuse, forced change) did not run for the account it changed. '
319+
+ 'The new password works; any forced change stays outstanding, so the user is asked '
320+
+ 'to change it again at the next sign-in, and the password-age clock keeps its old '
321+
+ 'start. Remedy: none needed per request; the next change whose checks run re-stamps.',
322+
new Error('password checks did not run for the acting account'),
323+
{ object: 'sys_user', fields: ['password_changed_at', 'must_change_password'] },
324+
);
325+
}
267326
}
268327
delete ctx.context.__osPwChangeUserId;
269328
delete ctx.context.__osPwHistory;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ export interface AuthInstanceHost {
158158
isPlatformAdminUserId: AuthManager['isPlatformAdminUserId'];
159159
isSsoWired: AuthManager['isSsoWired'];
160160
linkingContext: AuthManager['linkingContext'];
161+
logDurabilityDegradation: AuthManager['logDurabilityDegradation'];
161162
recordPasswordHistory: AuthManager['recordPasswordHistory'];
162163
resolveActor: AuthManager['resolveActor'];
163164
resolveAuthSecret: AuthManager['resolveAuthSecret'];
@@ -823,6 +824,7 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
823824
enforceConcurrentCap: (...a) => this.enforceConcurrentCap(...a),
824825
externalDirectoryDoorDeps: (...a) => this.externalDirectoryDoorDeps(...a),
825826
isEmailDeliveryLive: (...a) => this.isEmailDeliveryLive(...a),
827+
logDurabilityDegradation: (...a) => this.logDurabilityDegradation(...a),
826828
recordPasswordHistory: (...a) => this.recordPasswordHistory(...a),
827829
roleChangeGrantDeps: (...a) => this.roleChangeGrantDeps(...a),
828830
sendChangeEmailNotice: (...a) => this.sendChangeEmailNotice(...a),

‎packages/plugins/plugin-auth/src/auth-manager-before-hook.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969
LAST_LOCAL_CREDENTIAL_CODE,
7070
LAST_LOCAL_CREDENTIAL_MESSAGE,
7171
} from './last-local-credential.js';
72+
import { refuseRequestNamingDifferentAccounts } from './one-account-per-request.js';
7273
import { ORGANIZATION_DELETE_PATH } from './organization-delete.js';
7374
import { carriesPrincipalSession } from './principal-session-mint.js';
7475
import { SSO_PROVIDER_DELETE_PATH } from './sign-in-method-floor.js';
@@ -150,6 +151,17 @@ export async function globalBeforeHook(
150151
// `principal-session-mint.ts` (3b).
151152
if (carriesPrincipalSession(ctx)) await setShouldSkipSessionRefresh(true);
152153

154+
// ── An auth request is judged and executed as one account ────────
155+
// AHEAD OF EVERY GATE BELOW, and of the endpoint: a request whose
156+
// session cookie and bearer token name two different signed-in
157+
// accounts is refused here (401 CREDENTIALS_NAME_DIFFERENT_ACCOUNTS),
158+
// so every gate that follows, every after-effect and the endpoint
159+
// itself see one account or none. One credential, one account, or a
160+
// credential that names nobody passes untouched. The two steps above
161+
// read nothing, so they keep their place. `one-account-per-request.ts`
162+
// states the rule and how each credential is read.
163+
await refuseRequestNamingDifferentAccounts(ctx);
164+
153165
// ── #2780: per-number OTP send guard (admission control) ─────
154166
// MUST run BEFORE the phone-number endpoints: better-auth's
155167
// send-otp handler stores a fresh code and only THEN invokes

0 commit comments

Comments
 (0)