Repository navigation
Commit b3d2f4b
fix(runtime,plugin-auth)!: a banned user's OAuth access tokens and refresh grants are refused while the ban holds (#30360)
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: updated here
> **Base is `main`.** The pull request this branch was stacked on
(#30287) has landed on `main` as `f4cd4ec879`. This branch merged
`origin/main` at `f6faf75116` in `949fd3f1e4` (a merge, no rebase); the
files both sides added are `main`'s copies, so the diff against `main`
is this change alone.
## 维护者速读
- 改了什么:用户被封禁期间,其 OAuth 访问令牌在 MCP 入口按"无法验证的令牌"拒绝,在 `/oauth2/userinfo`
按无效令牌应答、在 `/oauth2/introspect` 按非活动令牌应答;令牌端点不再为其签发新令牌(refresh
与授权码兑换均被拒)。封禁确认框与管理员、认证文档同步写明。
- 为什么改:封禁已拒绝该用户的登录、会话与 API key;OAuth 令牌及其续期是同一用户的凭据,在读取或签发它们的入口适用同一规则。
- 风险与回滚:MCP 入口每个 OAuth 凭据多一次按主键读 sys_user 封禁列(读失败 503);introspection
每个活动应答多一次读用户行(读失败则请求失败,不臆造应答);令牌端点与 userinfo
不增加读取。不吊销、不改写任何令牌行;授权码在封禁期间被出示即作废,客户端需重新登录。回滚即还原本 PR。
-
先看:packages/plugins/plugin-auth/src/oauth-token-read-ban-refusal.ts(userinfo
与 introspection
两处拒绝);packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.ts(令牌端点,授权码分支的先后顺序见文件头);packages/runtime/src/security/oauth-subject-ban.ts(MCP
入口读取与 503 分类)。
- 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run
src/auth-manager.oauth-consent-organization.test.ts`,预期全部通过(真实 provider
上:封禁用户的 refresh、授权码、userinfo、introspection 应答与无效凭据一致,解封或到期后恢复)。
- 需要你做的:无,席位落地。
## What changes
While a user's ban holds, the rule holds at these doors, and only these:
- **MCP door.** An OAuth access token whose subject is banned resolves
as a token that does not verify: no principal, no fall-back to a cookie
session, the same `401` body and `WWW-Authenticate` header. An agent
forward credential acting for a banned user resolves as one that does
not redeem. The ban is read where the credential becomes its user
(`resolveExecutionContext` → `isOAuthSubjectBanned`,
`packages/runtime/src/security/oauth-subject-ban.ts`), with the shared
predicate `isUserBanInForce` from `@objectstack/core`. The verifier
(`verifyMcpAccessToken`) stays the local, I/O-free signature check.
- **Token endpoint.** The provider mints a banned user no token. A
`refresh_token` grant is answered as an invalid refresh token (`400
invalid_grant`, `invalid refresh token`) and writes nothing. An
`authorization_code` exchange is refused and the code is spent: when the
ban ended the user's sessions, as the ban routes do, the provider's own
session answer refuses it (`400 invalid_request`); otherwise it is
answered as an invalid code (`400 invalid_grant`, `invalid code`). Any
extension grant that issues through the provider is refused as an
invalid grant. The refusal runs in the provider's callback
`customTokenResponseFields`, which receives the user row the provider
already read
(`packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.ts`; its
header records the order on each grant and why this hook).
- **Userinfo.** `/oauth2/userinfo` answers a banned user's access token
as an access token that does not verify (`401 invalid_token`, the
provider's description and `WWW-Authenticate` challenge), the answer a
made-up token gets. It runs in `customUserInfoClaims`, on the user row
the provider already read, before the locale claim
(`packages/plugins/plugin-auth/src/oauth-token-read-ban-refusal.ts`).
- **Introspection.** `/oauth2/introspect` answers a banned user's access
and refresh tokens as inactive (`{ "active": false }`), the answer a
made-up, expired or revoked token gets (RFC 7662 §2.2). It runs as a
section of AuthManager's global after-hook, on the provider's active
answer, with one read of the subject's user row through the provider's
adapter (same file).
- **Expiry.** A ban with an expiry stops binding once it has passed,
exactly as sign-in reads it, at every door above.
- **Not a revocation.** No token, refresh token or consent row is
revoked or rewritten; lifting the ban (or its expiry) admits the same
access token at the MCP door and lets the same refresh token renew.
- **Cost.** One primary-key read of `sys_user` (`id`, `banned`,
`ban_expires`), as the system, per OAuth credential the MCP door
resolves; one user-row read per active introspection answer that names a
user; zero at the token and userinfo endpoints. A store fault on the MCP
door's read is `503 SERVICE_UNAVAILABLE` naming `sys_user`; a store
fault on the introspection read fails the request (`500`) rather than
answering active or inactive; a missing user row or an unprovisioned
table keeps today's answer.
- **The words.** The Ban User confirmation
(`packages/platform-objects/src/identity/sys-user.object.ts`, its AI
description and the three translated bundles),
`content/docs/permissions/administrator-guide.mdx` (lifecycle actions
and the offboarding row) and
`content/docs/permissions/authentication.mdx` (the `ban-user` route)
name OAuth access tokens and token refreshes among what a ban refuses.
`@objectstack/platform-objects` joins the changeset at patch.
- The `plugin-auth` APIError code pin now also reads an OAuth 2.0 error
response (a literal `error` beside an `error_description`, RFC 6749
§5.2) as a coded body, with fixtures for both directions: the
token-endpoint refusal must answer exactly as the provider's own error
does, which carries no ADR-0112 `code`.
## Tests
- `packages/runtime/src/security/resolve-execution-context.test.ts` —
new top-level describe: banned subject resolves exactly as an unverified
bearer; a future expiry binds, a passed one does not; not-banned,
unbanned, no-row and no-table controls admit as before; a ban read that
throws is `503 SERVICE_UNAVAILABLE` naming `sys_user`; cost pinned at
one ban-column read per token and none for an unverified bearer; a
forward credential for a banned user resolves as one that does not
redeem.
- `packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.test.ts` —
the refusal against the provider's own error (class, status, body,
headers) for each grant type; expiry in both spellings; not-banned and
no-user controls.
-
`packages/plugins/plugin-auth/src/oauth-token-read-ban-refusal.test.ts`
(new) — the userinfo refusal (status, body, challenge; expiry in both
spellings; controls); the introspection section (rewritten in place for
a banned subject; expiry; not-banned and no-row controls; inactive,
subject-less, other-path and non-object answers read nothing; a read
that throws fails and leaves the answer as produced; the provider's
adapter is the default reader; AuthManager's global after-hook runs the
section).
-
`packages/plugins/plugin-auth/src/auth-manager.oauth-consent-organization.test.ts`
— on the real provider with AuthManager's own options: a banned user's
refresh grant equals an invalid refresh token's answer and writes no
refresh row, and renews once the ban lapses; a code issued before a ban
that ended the user's sessions gets the provider's session answer,
starts no refresh family and is spent; a code exchanged while the user's
session survived equals a code never issued and starts no refresh
family; userinfo for a banned user's token equals a made-up token's
answer and reads the claims again once the ban lapses; introspection of
a banned user's access and refresh tokens equals a made-up token's
answer and is active again once the ban lapses; not-banned controls for
each.
-
`packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts`
— the Ban User confirmation pinned word for word, now naming OAuth
access tokens and token refreshes.
- `packages/qa/dogfood/test/oauth-banned-subject.dogfood.test.ts` —
booted showcase, real DCR → authorize → consent → token: `initialize`,
`query_records`, `create_record` and the refresh grant against a made-up
token / an invalid refresh token, the refused write landing nothing;
peer, unban and timed-ban controls. Its header declares its measured
test budget (20 s: 13.13 s of tests, the slower of two local runs, ×
1.25, rounded up).
## Acceptance notes
- The `plugin-auth` APIError code pin gains a third accepted shape, the
RFC 6749 §5.2 error response, so a token-endpoint refusal can carry
exactly the provider's own body; a literal `error` with no
`error_description`, or a non-literal `error`, is still reported
(fixtures in both directions).
- An authorization code presented for a banned user is spent whichever
refusal answers it, as the provider consumes a code before it reads the
code's session; after the ban the client signs the user in again.
- The banned-subject dogfood file's 20 s test budget is a local reading
on a shared box; no CI reading exists yet, so it should be re-measured
on CI.
## Verification
Readings at head `942ee6846660` unless stated. Readings marked
`11504e470d` were taken one commit earlier; `942ee68466` adds only the
budget header line of `oauth-banned-subject.dogfood.test.ts`.
`packages/runtime` is unchanged since `949fd3f1e4`, so its reading from
that head stands.
<details><summary>Unit and integration suites</summary>
- `pnpm --filter @objectstack/plugin-auth test` (at `11504e470d`): Test
Files 226 passed (226), Tests 3999 passed, 10 skipped (4009).
- `pnpm --filter @objectstack/platform-objects test` (at `11504e470d`):
Test Files 111 passed (111), Tests 1526 passed (1526).
- `oauth-token-read-ban-refusal.test.ts`,
`oauth-grant-ban-refusal.test.ts` and
`auth-manager.oauth-consent-organization.test.ts` together: Test Files 3
passed (3), Tests 29 passed (29).
- `pnpm --filter @objectstack/runtime test` (at `949fd3f1e4`): Test
Files 542 passed (542), Tests 8651 passed, 19 skipped (8670).
- `pnpm --filter @objectstack/plugin-auth --filter
@objectstack/platform-objects run typecheck`: exit 0 (both
`check:test-typecheck` OK).
- `eslint --no-inline-config` over the 19 changed `.ts` files: exit 0,
no findings.
- `node scripts/check-i18n-bundles.mjs`: OK, 10 packages, all bundles in
sync.
</details>
<details><summary>Ablations (at `11504e470d`; one per refusal and one
per wiring; each red, then restored, blob equal to HEAD and `git diff
HEAD` empty; the restored run 29 passed of 29)</summary>
- Userinfo refusal short-circuited: 3 failed of 29 (two unit, one
real-provider).
- Introspection refusal short-circuited: 5 failed of 29 (four unit, one
real-provider).
- Introspection section removed from AuthManager's global after-hook: 1
failed of 29 (the wiring test).
- Userinfo refusal removed from `customUserInfoClaims`: 1 failed of 29
(the real-provider userinfo leg).
- Grant refusal short-circuited: 6 failed of 29 (four unit, two
real-provider). The code leg where the ban ended the user's sessions
stays green, as it should: the provider's own session answer refuses
that exchange first, as the refusal's header records.
</details>
<details><summary>Dogfood</summary>
- `test/oauth-banned-subject.dogfood.test.ts` alone, run twice (at
`11504e470d`): Test Files 1 passed (1), Tests 4 passed (4), both runs;
the slower took 13.13 s of tests, which gives the declared 20 s budget.
- Full battery at `942ee6846660` (`pnpm --filter @objectstack/dogfood
test`, after a build of its closure at that head): Test Files 421
passed, 3 skipped (424); Tests 3500 passed, 36 skipped (3536); 2208 s on
a shared box. Nothing red: the timeout in the reading at `949fd3f1e4`
does not recur.
</details>
<details><summary>Gates</summary>
- `node scripts/pm/dispatch-gates.mjs --commands` at `942ee6846660`: 96
commands, each run with its exit code captured before any pipe: 96 exit
0. `check:type-check-debt` answered `PREREQUISITE NOT MET` (exit 3) on a
first pass, because the verify lock was held by this head's dogfood
battery, and exit 0 when re-run. `--ran` reconciliation: 96 derived, 96
run, 0 NOT-MEASURED (derived from recorded exit codes).
- CI at `942ee6846660`: 50 check runs, 38 success, 12 skipped, none
failed. "Type Check · consumer gates", red at `d3daa06af0` because the
generated `platform-objects` translation bundle had drifted, is green:
`11504e470d` regenerates the `en` bundle.
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
<!-- signed: security#1 · os-sales · session_01L6bhgVfjk2i8T2D4RmxsKg
-->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent b8929f4 commit b3d2f4b
22 files changed
Lines changed: 1355 additions & 25 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- platform-objects/src
- apps/translations
- identity
- plugins/plugin-auth/src
- qa/dogfood/test
- runtime/src/security
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
85 | | - | |
| 85 | + | |
86 | 86 | | |
87 | 87 | | |
88 | 88 | | |
| |||
201 | 201 | | |
202 | 202 | | |
203 | 203 | | |
204 | | - | |
| 204 | + | |
205 | 205 | | |
206 | 206 | | |
207 | 207 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1261 | 1261 | | |
1262 | 1262 | | |
1263 | 1263 | | |
1264 | | - | |
| 1264 | + | |
1265 | 1265 | | |
1266 | 1266 | | |
1267 | 1267 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
159 | 159 | | |
160 | 160 | | |
161 | 161 | | |
162 | | - | |
| 162 | + | |
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
159 | 159 | | |
160 | 160 | | |
161 | 161 | | |
162 | | - | |
| 162 | + | |
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
159 | 159 | | |
160 | 160 | | |
161 | 161 | | |
162 | | - | |
| 162 | + | |
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
159 | 159 | | |
160 | 160 | | |
161 | 161 | | |
162 | | - | |
| 162 | + | |
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
147 | 147 | | |
148 | 148 | | |
149 | 149 | | |
150 | | - | |
| 150 | + | |
151 | 151 | | |
152 | 152 | | |
153 | 153 | | |
154 | 154 | | |
| 155 | + | |
155 | 156 | | |
156 | 157 | | |
157 | 158 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
207 | 207 | | |
208 | 208 | | |
209 | 209 | | |
210 | | - | |
| 210 | + | |
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
| |||
216 | 216 | | |
217 | 217 | | |
218 | 218 | | |
219 | | - | |
| 219 | + | |
220 | 220 | | |
221 | 221 | | |
222 | 222 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
21 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
22 | 28 | | |
23 | 29 | | |
24 | 30 | | |
| |||
128 | 134 | | |
129 | 135 | | |
130 | 136 | | |
131 | | - | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
132 | 146 | | |
133 | 147 | | |
134 | 148 | | |
| |||
244 | 258 | | |
245 | 259 | | |
246 | 260 | | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
247 | 271 | | |
248 | 272 | | |
249 | 273 | | |
| |||
0 commit comments