Skip to content

Commit b3d2f4b

Browse files
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
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/plugin-auth': patch
4+
'@objectstack/platform-objects': patch
5+
---
6+
7+
fix(runtime,plugin-auth)!: a banned user's OAuth access tokens and refresh grants are refused while the ban holds
8+
9+
Clause-②: yes (narrowing) — while a user's ban holds, an OAuth access token that user consented to is answered at the MCP door as a token that does not verify (the generic `401`, with the same body and `WWW-Authenticate` header), and so is an agent forward credential acting for that user; `/oauth2/userinfo` answers that token as an access token that does not verify (`401 invalid_token`), and `/oauth2/introspect` answers that user's access and refresh tokens as inactive (`{ "active": false }`); the token endpoint mints that user no token: a `refresh_token` grant answers as an invalid refresh token (`400 invalid_grant`), and an `authorization_code` exchange is refused, with the code spent: by the provider's own session answer (`400 invalid_request`) when the ban ended the user's sessions, as the ban routes do, and otherwise as an invalid code (`400 invalid_grant`). Those are the doors this change narrows.
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and nothing at rest changes: the ban columns are read where an OAuth credential becomes its user or is read back, and no token, grant or consent row is revoked or rewritten. -->
13+
14+
**BREAKING** accept-set narrowing at the MCP door and at the OAuth authorization server's token, userinfo and introspection endpoints: a banned user's OAuth access tokens and refresh grants are refused while the ban holds.
15+
16+
- **The rule.** Banning a user already ends their sessions and refuses their sign-in and their API keys. While the ban holds, their OAuth access tokens are now also refused at the MCP door and read as not valid at userinfo and introspection, and the token endpoint mints them no new token (no refresh, no code exchange). A ban with an expiry stops binding once the expiry has passed, exactly as sign-in does.
17+
- **Not a revocation.** No token, refresh token or consent row is revoked or rewritten. Lifting the ban (or letting it expire) admits the same access token at the MCP door and lets the same refresh token renew, with nothing reissued. An authorization code presented while the ban held is spent, so the client signs the user in again.
18+
- **Cost.** One primary-key read of the user's ban columns per OAuth credential the MCP door resolves, and one read of the user row per active introspection answer that names a user; the token and userinfo endpoints read nothing extra. A store fault on the MCP door's read answers `503 SERVICE_UNAVAILABLE`, as every other authorization-input read on that door does; a store fault on the introspection read fails the request rather than answering active or inactive.
19+
- **Who moves.** An MCP client or OIDC relying party connected over OAuth on behalf of a banned user gets `401` at the MCP door and at userinfo, `{ "active": false }` from introspection, and `400` from the token endpoint, until the ban is lifted or expires. The Ban User confirmation (`@objectstack/platform-objects`) and the administrator and authentication guides now say so.
20+
21+
**The remedy.** Lift the ban, or connect the client as a user who is not banned.

‎content/docs/permissions/administrator-guide.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ only on reorgs. Why the tree matters:
8282

8383
Day-to-day lifecycle actions live on each user's row menu and record header:
8484
**Ban / Unban** (blocks sign-in, ends the user's sessions, and refuses their API keys
85-
while the ban holds), **Unlock Account** (clears a brute-force
85+
and OAuth access tokens while the ban holds, with no new OAuth token issued to them), **Unlock Account** (clears a brute-force
8686
lockout early, at both the password and two-factor stages), **Set Password**
8787
(also mints one-time temporary passwords),
8888
and **Impersonate User** (see [Step 4](#step-4--verify)). *(Rough edge today:
@@ -201,7 +201,7 @@ Full model: [Permission Sets](/docs/permissions/permission-sets).
201201
| I need to… | Do this |
202202
|:--|:--|
203203
| Onboard a new employee | Invite/Create the user → Business Unit Member row → assign their position ([2](#step-2--add-people-users)→[3](#step-3--assign-positions)) |
204-
| Offboard someone | **Ban User** (blocks sign-in and the user's API keys immediately) — then hand over their records ([below](#handing-over-a-banned-users-work)) and remove assignments at leisure |
204+
| Offboard someone | **Ban User** (blocks sign-in, the user's API keys and their OAuth access tokens and token refreshes immediately) — then hand over their records ([below](#handing-over-a-banned-users-work)) and remove assignments at leisure |
205205
| "I can't sign in" | User record → is it banned? locked? **Unlock Account** or **Set Password** (temporary, must-change) |
206206
| "Why can't I see X?" | Studio → Access → **Explain access** for that user + object |
207207
| Someone changed departments | Update their Business Unit Member row; re-anchor their User Position rows |

‎content/docs/permissions/authentication.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1261,7 +1261,7 @@ All endpoints are available under `/api/v1/auth/*`:
12611261
- `POST /api/v1/auth/admin/set-user-password` - Set/reset a user's password (also provisions a credential for SSO-onboarded users)
12621262
- `POST /api/v1/auth/admin/import-users` - Bulk import users (CSV/JSON/XLSX; `auto` (default, per-row invite-or-temporary) / `invite` / `temporary` / `none` password policy; ≤500 rows, dry-run supported)
12631263
- `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early
1264-
- `POST /api/v1/auth/admin/ban-user` - Ban a user (blocks sign-in and revokes live sessions; the user's API keys are refused at every door while the ban holds, and a ban with an expiry stops binding once it expires. The keys are not revoked)
1264+
- `POST /api/v1/auth/admin/ban-user` - Ban a user (blocks sign-in and revokes live sessions; the user's API keys are refused at every door while the ban holds; their OAuth access tokens are refused at the MCP endpoint and read as not valid at `/oauth2/userinfo` and `/oauth2/introspect`, and the token endpoint issues them no new token; a ban with an expiry stops binding once it expires. Neither the keys nor the OAuth tokens are revoked)
12651265
- `POST /api/v1/auth/admin/unban-user` - Lift a ban, restoring the account's ability to sign in and its API keys (each key is still judged by its own lifecycle: a key left unused past the inactivity window during the ban answers as inactive, and needs replacing)
12661266

12671267
#### Organization Membership (requires `plugins.organization`; platform-admin gated)

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
159159
},
160160
ban_user: {
161161
label: "Ban User",
162-
description: "Ban this user? They will be signed out, unable to sign in, and their API keys refused until unbanned. The keys work again after the unban.",
162+
description: "Ban this user? They will be signed out, unable to sign in, and their API keys refused until unbanned, as are their OAuth access tokens and token refreshes. The keys work again after the unban.",
163163
successMessage: "User banned",
164164
params: {
165165
banReason: {

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
159159
},
160160
ban_user: {
161161
label: "Bloquear usuario",
162-
description: "¿Bloquear a este usuario? Cerrará sesión, no podrá volver a iniciarla y sus claves de API se rechazarán hasta que se desbloquee. Las claves vuelven a funcionar tras el desbloqueo.",
162+
description: "¿Bloquear a este usuario? Cerrará sesión, no podrá volver a iniciarla y sus claves de API se rechazarán hasta que se desbloquee, al igual que sus tokens de acceso OAuth y sus renovaciones de token. Las claves vuelven a funcionar tras el desbloqueo.",
163163
successMessage: "Usuario bloqueado",
164164
params: {
165165
banReason: {

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
159159
},
160160
ban_user: {
161161
label: "利用停止",
162-
description: "このユーザーを利用停止にしますか?利用停止になるとサインアウトされ、解除されるまでサインインできず、API キーも拒否されます。解除後はキーが再び使えるようになります。",
162+
description: "このユーザーを利用停止にしますか?利用停止になるとサインアウトされ、解除されるまでサインインできず、API キーも拒否されます。OAuth アクセストークンとトークンの更新も同様に拒否されます。解除後はキーが再び使えるようになります。",
163163
successMessage: "ユーザーを利用停止にしました",
164164
params: {
165165
banReason: {

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
159159
},
160160
ban_user: {
161161
label: "封禁用户",
162-
description: "要封禁该用户吗?封禁后会立即登出,在解除封禁前无法再次登录,其 API 密钥也会被拒绝。解除封禁后这些密钥恢复可用。",
162+
description: "要封禁该用户吗?封禁后会立即登出,在解除封禁前无法再次登录,其 API 密钥也会被拒绝,OAuth 访问令牌与令牌续期同样被拒绝。解除封禁后这些密钥恢复可用。",
163163
successMessage: "用户已封禁",
164164
params: {
165165
banReason: {

‎packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,12 @@ describe('#7309 — the destructive warnings survived the move, word for word',
147147
it('sys_user.ban_user still says the user is signed out and locked out', () => {
148148
const a = byName(SysUser, 'ban_user');
149149
expect(a.description).toBe(
150-
'Ban this user? They will be signed out, unable to sign in, and their API keys refused until unbanned. The keys work again after the unban.',
150+
'Ban this user? They will be signed out, unable to sign in, and their API keys refused until unbanned, as are their OAuth access tokens and token refreshes. The keys work again after the unban.',
151151
);
152152
expect(a.description).toContain('signed out');
153153
expect(a.description).toContain('unable to sign in');
154154
expect(a.description).toContain('API keys refused');
155+
expect(a.description).toContain('OAuth access tokens and token refreshes');
155156
});
156157

157158
it('sys_user.delete_my_account still says it cannot be undone', () => {

‎packages/platform-objects/src/identity/sys-user.object.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export const SysUser = ObjectSchema.create({
207207
// and nothing is POSTed until that one dialog's own Confirm.
208208
// NB: the top-level `description` (#7367), never `ai.description` — that
209209
// one is the LLM-facing tool contract and is shown to no user.
210-
description: 'Ban this user? They will be signed out, unable to sign in, and their API keys refused until unbanned. The keys work again after the unban.',
210+
description: 'Ban this user? They will be signed out, unable to sign in, and their API keys refused until unbanned, as are their OAuth access tokens and token refreshes. The keys work again after the unban.',
211211
params: [
212212
{ name: 'banReason', label: 'Ban Reason', type: 'text', required: false },
213213
],
@@ -216,7 +216,7 @@ export const SysUser = ObjectSchema.create({
216216
ai: {
217217
exposed: true,
218218
description:
219-
'Ban a user (the record; optionally banReason): they are signed out, cannot sign in, and their API keys are refused until unbanned; the keys work again after the unban. Platform administrators only. Ask the human to confirm before calling.',
219+
'Ban a user (the record; optionally banReason): they are signed out, cannot sign in, and their API keys, OAuth access tokens and token refreshes are refused until unbanned; the keys work again after the unban. Platform administrators only. Ask the human to confirm before calling.',
220220
requiresConfirmation: true,
221221
},
222222
},

‎packages/plugins/plugin-auth/src/api-error-code.pin.test.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@
1818
* 2. a body destructured, in the same function, from the result of
1919
* `judgeHeldSession(...)`: the held-session judge's ADR-0112 envelope,
2020
* whose code sits under `error` (`platform-admin-gate.held-session.test.ts`
21-
* pins that body's shape).
21+
* pins that body's shape); or
22+
* 3. an OAuth 2.0 error response (RFC 6749 §5.2): an object literal with a
23+
* top-level `error` whose value is a string literal, beside an
24+
* `error_description`. That is the dialect the authorization server's own
25+
* endpoints answer in, and `error` is its machine code; a refusal that must
26+
* answer exactly as the provider's own error does (`oauth-grant-ban-refusal.ts`)
27+
* carries no second, ADR-0112 `code` the provider's answer lacks.
2228
*
2329
* No body, a literal without `code`, a spread, or an identifier from anywhere
2430
* else is a site this pin cannot read a code from, so it fails.
@@ -128,7 +134,15 @@ function whyCodeless(site: ts.NewExpression, sf: ts.SourceFile): string | null {
128134
const carriesCode = body.properties.some(
129135
(p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && propertyName(p.name) === 'code',
130136
);
131-
return carriesCode ? null : 'its body has no top-level `code` property';
137+
if (carriesCode) return null;
138+
// Shape 3: the OAuth 2.0 error response — a literal `error` code beside an `error_description`.
139+
const oauthError = body.properties.some(
140+
(p) => ts.isPropertyAssignment(p) && propertyName(p.name) === 'error' && ts.isStringLiteralLike(p.initializer),
141+
);
142+
const oauthDescription = body.properties.some(
143+
(p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && propertyName(p.name) === 'error_description',
144+
);
145+
return oauthError && oauthDescription ? null : 'its body has no top-level `code` property';
132146
}
133147
if (ts.isIdentifier(body) && isHeldSessionBody(body, site)) return null;
134148
return `its body is \`${body.getText(sf)}\`, which this pin cannot read a code from`;
@@ -244,6 +258,16 @@ describe('the classifier, against the shapes it must tell apart', () => {
244258
expect(verdicts(fixture)).toEqual([true, true, true, true, true]);
245259
});
246260

261+
it('accepts an OAuth 2.0 error response (a literal `error` beside an `error_description`), and nothing short of it', () => {
262+
const fixture = `
263+
function a(d: string) { throw new APIError('BAD_REQUEST', { error_description: d, error: 'invalid_grant' }); }
264+
function b() { throw new APIError('BAD_REQUEST', { error_description: 'invalid code', error: 'invalid_grant' }); }
265+
function c() { throw new APIError('BAD_REQUEST', { error: 'invalid_grant' }); }
266+
function d(e: string) { throw new APIError('BAD_REQUEST', { error_description: 'x', error: e }); }
267+
function f() { throw new APIError('BAD_REQUEST', { error_description: 'x' }); }`;
268+
expect(verdicts(fixture)).toEqual([true, true, false, false, false]);
269+
});
270+
247271
it('the textual control sees past comments, and counts a member-callee site', () => {
248272
const fixture = `
249273
// new APIError('FORBIDDEN', { message: 'in a comment' })

0 commit comments

Comments
 (0)