You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 133f75a
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
Copy file name to clipboardExpand all lines: content/docs/references/api/automation-api.mdx
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -417,7 +417,7 @@ const result = AutomationApiErrorCode.parse(data);
417
417
418
418
| Property | Type | Required | Description |
419
419
| :--- | :--- | :--- | :--- |
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 |
421
421
|**message**|`string`| ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
422
422
423
423
### Allowed Values: `FlowInnerRefusal.code`
@@ -520,6 +520,7 @@ const result = AutomationApiErrorCode.parse(data);
Copy file name to clipboardExpand all lines: content/docs/references/api/contract.mdx
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
28
28
29
29
| Property | Type | Required | Description |
30
30
| :--- | :--- | :--- | :--- |
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) |
32
32
|**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) |
|**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);
0 commit comments