Repository navigation
Commit e11a575
feat(auth): withdraw an authorized AI assistant, for the person and for a platform administrator (#31073)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao9RVHLeGV8ByfXSADPXFwJD)_
Clause-②: yes (widening)
Metadata-protocol: none
Fixes #31048
## 维护者速读
- **改了什么**:新增两个撤回入口:用户撤回自己授权过的 AI 助手(`POST
/api/v1/auth/authorized-clients/withdraw`),平台管理员替所管理的人撤回(`POST
/api/v1/auth/admin/authorized-clients/withdraw`);SDK 增加
`oauth.authorizedClients.withdraw()` / `withdrawForUser()`。
- **为什么改**:授权页承诺「您可以随时在账户设置中撤销访问权限」,控制台已能列出授权过的助手,但没有可调用的撤回入口;#30894
的撤回按钮等的就是它。
- **风险与回滚**:只新增路由,现有路由不变。撤回与 `/oauth2/revoke` 共用同一段撤销代码(删授权记录 +
该客户端的刷新令牌),助手的令牌在下一次 MCP 调用即被拒。别人的 id 和不存在的 id 回同一个 404,探不出 id 是否存在。回滚即
revert 本 PR。
-
**先看**:`packages/plugins/plugin-auth/src/authorized-clients-withdraw.ts`(`findConsentOf`
的归属判断);`auth-plugin.ts` 里管理员入口先跑 `gateAdmin`。
- **两分钟验证**:`pnpm --filter @objectstack/plugin-auth exec vitest run
src/authorized-clients-read.real-plugin.test.ts -t 31048`,预期 6 条通过。
- **需要你做的**:无,席位落地。
## What changes
Two ObjectStack mounts beside the authorized-clients reads, both keyed
by the consent `id` the reads answer on every row:
| Route | Body | Who |
|:---|:---|:---|
| `POST /api/v1/auth/authorized-clients/withdraw` | `{ id }` | the
signed-in person, their own grant only |
| `POST /api/v1/auth/admin/authorized-clients/withdraw` | `{ userId, id
}` | a platform administrator, behind the same `gateAdmin` as `GET
/admin/authorized-clients` |
A withdraw revokes the grant through `revokeMcpGrant`
(`mcp-token-revocation.ts`), the function `/oauth2/revoke` already uses:
the consent row, then the client's refresh family, all through
better-auth's own storage adapter. `mcpConsentAdmits` refuses the token
on its next MCP call, and because the refresh family goes too, a refresh
token the client still holds can't come back if the person authorizes
the same assistant again later. The write is credited to whoever
withdrew (`runAttributedToUser`, attribution only).
- `auth-route-ledger.ts`: two `sdk` rows
(`oauth.authorizedClients.withdraw` / `withdrawForUser`), and a
`server-only` row for better-auth's `POST /oauth2/delete-consent` that
records the measurement below and names the new door. The read row's
note now names the withdraw as its key's consumer.
- `@objectstack/client`: `oauth.authorizedClients.withdraw(id)` and
`withdrawForUser(userId, id)`, both answering
`AuthorizedClientWithdrawal` (`{ id }`). The stale `list()` doc line
that pointed at `/oauth2/delete-consent` is corrected.
- `content/docs/ai/connect-mcp.mdx`: one bullet in *The security model*
(no docs page listed the authorized-clients doors before; this is the
page that covers OAuth assistants).
- Doc comments that named `/oauth2/delete-consent` as "the Console's
withdraw" (`authorized-clients-read.ts`, `mcp-token-revocation.ts`,
`auth-manager.ts`) now name the new module.
### Design choice: (b), an ObjectStack mount
Measured on `@better-auth/oauth-provider` 1.7.12
(`deleteConsentEndpoint`): the vendor route does check ownership
(`consent.userId !== session.user.id` → `UNAUTHORIZED`), so option (a)
is not a security defect and nothing went to security#1. But it answers
an unknown id `404 not_found` and another person's id `401
UNAUTHORIZED`, so a caller can tell them apart; it reads the row by id
alone, and it deletes only the consent row, leaving the refresh family.
Ledgering it as `sdk` would have pinned a two-answer, probe-able door
with a 401 that means "not yours" rather than "not signed in". The mount
gives one closed answer set:
| Caller → door | Unknown id | Another person's id | Own id |
|:---|:---|:---|:---|
| member → own | `404 RESOURCE_NOT_FOUND` | `404 RESOURCE_NOT_FOUND`
(same bytes; row stays) | `200 { id }` |
| platform admin → own | `404` | `404` (same bytes; the own door is
keyed by the session) | `200` |
| platform admin → admin `{userId, id}` | `404` (consent message) |
`404` (same bytes when `id` isn't `userId`'s; row stays) | `200` |
| platform admin → admin, `userId` naming no user | `404
RESOURCE_NOT_FOUND` with the admin read's message | | |
| member / org owner / org admin / the person → admin | `403
PERMISSION_DENIED` | `403` (same bytes, also for `{}`) | `403` |
| anonymous → either | `401 UNAUTHENTICATED` | `401` | `401` |
Shape refusals on both doors: a query parameter, a foreign body key, a
missing, blank or non-string `id`/`userId`, a non-object body → `400
VALIDATION_ERROR`; JSON declared and unparseable → `400 INVALID_REQUEST`
(`readJsonRequestBody`). A held session on the own door → `403` with the
posture's own code. All codes come from the existing catalog; no spec
key or error code is added.
### Audit
The grant and the revoke paths write no explicit `sys_audit_log` row.
Both go through better-auth's adapter into the engine, and
`sys_oauth_consent` is not in plugin-audit's `SKIP_OBJECTS`, so the
generic writer records the grant as `create` and the revoke as `delete`.
The withdraw goes through the same adapter, so it gets the same generic
`delete` row with no new action value. The dogfood pin measures both
rows on the real stack with plugin-audit installed. The `delete` row is
credited to whoever withdrew: the person on the own door, the
administrator on the admin door (`user_id`). An own withdraw made from
an impersonation session hands `runAttributedToUser` `{ id,
impersonatedBy }`, read off the session as `gateAdmin` and the auth
before-hook read it. The revoke's writes therefore carry the
attribution-only `impersonated_by` beside the person.
## Tests
- `plugin-auth` `authorized-clients-read.real-plugin.test.ts` (the
shared harness: real `AuthManager`, real better-auth, real `AuthPlugin`
routes on Hono): six new cases in two `#31048` describes. They cover a
member withdrawing their own grant (row and refresh family gone, that
grant's MCP token refused, the person's other grants and tokens
untouched, a second withdraw is the not-found answer); another person's
id from a member and from a platform admin on the own door
(byte-identical to an unknown id, row stays, token still admitted); the
shape refusals; an admin withdrawing a managed person's grant (token
refused, list shrinks); a mismatched `userId`/`id` and an unknown user;
and the non-admin matrix (member, org owner, org admin, the person
themself: one `403` for existing, missing and empty bodies; anonymous
`401`; nothing moves). One more `#31048` describe (2 cases) reads the
credited actor from inside the revoke. An impersonation session yields
`{ userId, impersonatedBy }`. An ordinary session or a blank
`impersonatedBy` yields `{ userId }` alone.
- `auth-route-ledger.conformance.test.ts`: the hand-written
objectstack-sourced roster gains both routes, each proven absent from
better-auth's live table.
- `client` `oauth-authorized-clients.test.ts`: URL, method and exact
body of both withdraws, throw on `404`/`403`, return types.
- dogfood `authorized-client-withdraw.dogfood.test.ts` (new): over HTTP
on the showcase stack with the MCP server and plugin-audit. It drives a
real DCR, consent and code-exchange grant. Another member's withdraw is
the same `404` as an unknown id and the assistant keeps working. The
owner's withdraw makes `initialize` answer `401` and the refresh grant
`400 invalid_grant`, with audit rows as described above. On the admin
door a member gets `403`, and the admin's withdraw refuses the token.
Its generic `delete` row is credited to the administrator, not the
person.
- dogfood `admin-route-nonadmin-refusal` /
`admin-platform-admin-standing`: the new admin route classified in both
derived sweeps (`objectstack-gate`; `ADMITTED`), each with a payload
that reaches the gate.
## Acceptance notes
- **Refresh family scope.** `revokeMcpGrant` deletes the client's
refresh tokens for the person across organizations (keyed by client and
user, as `/oauth2/revoke` already does), while the consent it deletes is
the one organization's. If a person granted the same client in two
organizations, withdrawing one makes the other organization's session
re-authorize when its access token expires (its consent stays, so no new
consent prompt). This keeps one owner for "revoke a grant"; narrowing
the refresh delete to `reference_id` would change `/oauth2/revoke` too
and isn't in this card. Tracked in #31076.
- `POST /oauth2/delete-consent` stays mounted by better-auth's
catch-all, unchanged, now ledgered `server-only` with the measurement.
Shadowing or refusing it is not in this card.
- The route-count prose in
`admin-route-nonadmin-refusal.dogfood.test.ts`'s header (11 + 24 = 31)
was already stale before this change and is left alone, as that header
itself says.
## Verification
<details><summary>Ablations (negative pins, each restored blob ==
HEAD)</summary>
(i) Ownership check removed from `findConsentOf` (consent read by `id`
alone, its own `userId` returned):
```
× another person's id is refused with the SAME bytes as an id naming no grant, and their grant stays in place
AssertionError: alice@authorized-clients.example.com: {"success":true,"data":{"id":"pOyAoR48grgEuTwXiONA0OSBScnR0oYa"}}: expected 200 to be 404
× a consent that is not the named person's is the same 404 as none, and stays; a userId naming no user is the read's 404
AssertionError: {"success":true,"data":{"id":"swvjR3Xx9jMnIdcQOi1n4vBOmS1k1g66"}}: expected 200 to be 404
Tests 2 failed | 4 passed | 10 skipped (16)
ablation-replace: ok restored: blob == HEAD (8835068) and `git diff HEAD` is empty
```
(ii) `gateAdmin` removed from the admin withdraw mount:
```
× a member, an organization OWNER, an organization ADMIN and the person themself are refused with ONE answer, whether or not the person or the grant exists, and nothing moves
AssertionError: member: {"success":true,"data":{"id":"zboAeJt35osPRynfvVdv7dOyFEXU10Qx"}}: expected 200 to be 403
Tests 1 failed | 5 passed | 10 skipped (16)
ablation-replace: ok restored: blob == HEAD (61ed5ae) and `git diff HEAD` is empty
```
(iii) `impersonatedBy` dropped from the own withdraw's actor (`const
actor = { id: subject };`):
```
× an impersonation session: the person, with the impersonating administrator beside them
AssertionError: expected { userId: 'usr_person_31048' } to deeply equal { userId: 'usr_person_31048', …(1) }
- "impersonatedBy": "usr_admin_31048",
Tests 1 failed | 1 passed | 16 skipped (18)
ablation-replace: restored: blob == HEAD (488794e) and `git diff HEAD` is empty
```
</details>
<details><summary>Suites and gates on head 05ade98 (main 3f61e4f
merged)</summary>
- `pnpm --filter @objectstack/plugin-auth test`: Test Files 232 passed
(232), Tests 4189 passed | 10 skipped (4199).
- `pnpm --filter @objectstack/client test`: Test Files 55 passed (55),
Tests 712 passed (712).
- `pnpm --filter @objectstack/plugin-auth typecheck`, `pnpm --filter
@objectstack/client typecheck`: exit 0.
- dogfood, on the final head with a full `pnpm build` (119/119 tasks):
`authorized-client-withdraw`, `admin-route-nonadmin-refusal` and
`admin-platform-admin-standing` 17/17 (3 files).
- `eslint --no-inline-config` on every changed `.ts` file: exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands`: 95 families derived,
95 run, all exit 0. The `--ran` reconciliation shows 95 run, 0
NOT-MEASURED, 0 UNRUN. Record: `31073-gates-ran-05ade983.list` in the
seat's out folder.
- Full dogfood battery: n/a. The title carries no `!` and Clause-② has
no `(narrowing)` arm.
</details>
<details><summary>Earlier: suites and gates on head 1c32be0 (main
5210acf merged)</summary>
- `pnpm --filter @objectstack/plugin-auth test`: Test Files 232 passed
(232), Tests 4187 passed | 10 skipped (4197).
- `pnpm --filter @objectstack/client test`: Test Files 55 passed (55),
Tests 712 passed (712) (run on cdf571a; the merge touched no client
file). Touched client file on the final head: 8/8.
- `pnpm --filter @objectstack/plugin-auth typecheck`, `pnpm --filter
@objectstack/client typecheck`: exit 0.
- Touched plugin-auth files on the final head (real-plugin + ledger
conformance): 26/26.
- dogfood, on the final head with a full `pnpm build` (119/119 tasks):
`authorized-client-withdraw` 2/2, `admin-route-nonadmin-refusal` +
`admin-platform-admin-standing` 15/15 (3 files, 17 tests).
- `eslint --no-inline-config` on every changed `.ts` file: exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands`: 95 families derived,
95 run, all exit 0; `--ran` reconciliation: 95 run, 0 NOT-MEASURED, 0
UNRUN. Record: `31048-gates-ran-1c32be06.list` in the seat's out folder.
On the first sweep (before the full build and the ledger-note fix),
`check:doc-authoring` caught a tracker id in a ledger note, now removed.
`check:skill-examples` and `check:dual-build-cjs-loads` reported exit 3
because there was no dist yet, and `check-plugin-teardown-shape
--self-test` reported exit 3 because the clone is shallow (its pinned
fixture commit was fetched). All are green on the final sweep.
- Full dogfood battery: n/a. The title carries no `!` and Clause-② has
no `(narrowing)` arm.
</details>
<!-- signed: domain:server#2 · os-tesla ·
session_01L9qibyvxbrLP4jm4PargoH -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3f61e4f commit e11a575
17 files changed
Lines changed: 1055 additions & 21 deletions
File tree
- .changeset
- content/docs/ai
- packages
- client/src
- plugins/plugin-auth/src
- qa/dogfood/test
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
376 | 376 | | |
377 | 377 | | |
378 | 378 | | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
379 | 394 | | |
380 | 395 | | |
381 | 396 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1240 | 1240 | | |
1241 | 1241 | | |
1242 | 1242 | | |
1243 | | - | |
| 1243 | + | |
1244 | 1244 | | |
1245 | 1245 | | |
1246 | 1246 | | |
| |||
1269 | 1269 | | |
1270 | 1270 | | |
1271 | 1271 | | |
| 1272 | + | |
| 1273 | + | |
| 1274 | + | |
| 1275 | + | |
| 1276 | + | |
| 1277 | + | |
1272 | 1278 | | |
1273 | 1279 | | |
1274 | 1280 | | |
| |||
5003 | 5009 | | |
5004 | 5010 | | |
5005 | 5011 | | |
5006 | | - | |
5007 | | - | |
| 5012 | + | |
5008 | 5013 | | |
5009 | 5014 | | |
5010 | 5015 | | |
| |||
5028 | 5033 | | |
5029 | 5034 | | |
5030 | 5035 | | |
| 5036 | + | |
| 5037 | + | |
| 5038 | + | |
| 5039 | + | |
| 5040 | + | |
| 5041 | + | |
| 5042 | + | |
| 5043 | + | |
| 5044 | + | |
| 5045 | + | |
| 5046 | + | |
| 5047 | + | |
| 5048 | + | |
| 5049 | + | |
| 5050 | + | |
| 5051 | + | |
| 5052 | + | |
| 5053 | + | |
| 5054 | + | |
| 5055 | + | |
| 5056 | + | |
| 5057 | + | |
| 5058 | + | |
| 5059 | + | |
| 5060 | + | |
| 5061 | + | |
| 5062 | + | |
| 5063 | + | |
| 5064 | + | |
| 5065 | + | |
| 5066 | + | |
| 5067 | + | |
| 5068 | + | |
| 5069 | + | |
| 5070 | + | |
| 5071 | + | |
| 5072 | + | |
| 5073 | + | |
5031 | 5074 | | |
5032 | 5075 | | |
5033 | 5076 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
5 | | - | |
6 | | - | |
7 | | - | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
| 11 | + | |
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4509 | 4509 | | |
4510 | 4510 | | |
4511 | 4511 | | |
4512 | | - | |
| 4512 | + | |
| 4513 | + | |
4513 | 4514 | | |
4514 | 4515 | | |
4515 | 4516 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3896 | 3896 | | |
3897 | 3897 | | |
3898 | 3898 | | |
| 3899 | + | |
| 3900 | + | |
| 3901 | + | |
| 3902 | + | |
| 3903 | + | |
| 3904 | + | |
| 3905 | + | |
| 3906 | + | |
| 3907 | + | |
| 3908 | + | |
| 3909 | + | |
| 3910 | + | |
| 3911 | + | |
| 3912 | + | |
| 3913 | + | |
| 3914 | + | |
| 3915 | + | |
| 3916 | + | |
| 3917 | + | |
| 3918 | + | |
| 3919 | + | |
| 3920 | + | |
| 3921 | + | |
3899 | 3922 | | |
3900 | 3923 | | |
3901 | 3924 | | |
| |||
4781 | 4804 | | |
4782 | 4805 | | |
4783 | 4806 | | |
| 4807 | + | |
| 4808 | + | |
| 4809 | + | |
| 4810 | + | |
| 4811 | + | |
| 4812 | + | |
| 4813 | + | |
| 4814 | + | |
| 4815 | + | |
| 4816 | + | |
| 4817 | + | |
| 4818 | + | |
| 4819 | + | |
| 4820 | + | |
| 4821 | + | |
| 4822 | + | |
| 4823 | + | |
| 4824 | + | |
| 4825 | + | |
| 4826 | + | |
| 4827 | + | |
| 4828 | + | |
4784 | 4829 | | |
4785 | 4830 | | |
4786 | 4831 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
248 | 248 | | |
249 | 249 | | |
250 | 250 | | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
251 | 259 | | |
252 | 260 | | |
253 | 261 | | |
| |||
274 | 282 | | |
275 | 283 | | |
276 | 284 | | |
| 285 | + | |
277 | 286 | | |
278 | 287 | | |
279 | 288 | | |
| |||
0 commit comments