Skip to content

Commit e11a575

Browse files
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
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/client': patch
4+
---
5+
6+
feat(auth): withdraw an authorized AI assistant, for the person themself and for a platform administrator
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
Two new routes beside the authorized-clients reads, each keyed by the consent `id` those reads answer on every row:
12+
13+
- `POST /api/v1/auth/authorized-clients/withdraw` with `{ "id": "…" }` withdraws one of the signed-in person's own authorized assistants. Only that person's grant is withdrawn: an id that names someone else's grant answers `404 RESOURCE_NOT_FOUND`, byte for byte the same as an id that names nothing, and leaves it in place.
14+
- `POST /api/v1/auth/admin/authorized-clients/withdraw` with `{ "userId": "…", "id": "…" }` does the same for a person a platform administrator manages, behind the same gate as `GET /api/v1/auth/admin/authorized-clients`: anyone else is refused once (`401` anonymous, `403 PERMISSION_DENIED` otherwise), whoever the body names and whether or not the person or the grant exists.
15+
16+
A withdraw revokes the grant the way `/oauth2/revoke` does: the consent row, then the client's refresh tokens. The assistant's token is refused on its next MCP call, and it must be authorized again to reconnect. Both routes take a closed body (an unknown key, or a missing or empty value, is `400 VALIDATION_ERROR`) and no query parameter, and answer `{ success: true, data: { id } }` with `Cache-Control: no-store`.
17+
18+
`@objectstack/client` adds `oauth.authorizedClients.withdraw(id)` and `oauth.authorizedClients.withdrawForUser(userId, id)`, both answering `AuthorizedClientWithdrawal` (`{ id }`).
19+
20+
better-auth's own `POST /api/v1/auth/oauth2/delete-consent` is unchanged and stays outside the SDK: it answers an unknown id and another person's id differently, and leaves the refresh tokens behind. Use the new routes instead.

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,21 @@ drive this MCP server. Same word, different layer.
376376
person publishes in the UI); it is served only to a token that carries it,
377377
never to an API key or a session, and every authoring call is still checked
378378
against the caller's own metadata permission.
379+
- **A person can withdraw an assistant they authorized, at any time.**
380+
`GET /api/v1/auth/authorized-clients` lists the assistants the signed-in
381+
person authorized (each row's `id` is the grant), and
382+
`POST /api/v1/auth/authorized-clients/withdraw` with `{ "id": "…" }` revokes
383+
one: the grant and the client's refresh tokens are deleted, so the
384+
assistant's token is refused on its very next call and the assistant must
385+
be authorized again to reconnect. Only the person's own grant is withdrawn;
386+
an id that is someone else's answers `404`, exactly as one that names
387+
nothing. A platform administrator does the same for a person with
388+
`GET /api/v1/auth/admin/authorized-clients?userId=…` and
389+
`POST /api/v1/auth/admin/authorized-clients/withdraw` with
390+
`{ "userId": "…", "id": "…" }`; a request with no session is refused
391+
`401`, and anyone else who is signed in is refused `403`. The SDK
392+
spells them `oauth.authorizedClients.list()` / `withdraw(id)` and
393+
`listForUser(userId)` / `withdrawForUser(userId, id)`.
379394
- **Action bodies run as trusted app code** once invoked (the `ai.exposed`
380395
gate and `requiredPermissions` are checked at *invoke* time). Treat writing
381396
an action as a code-review-worthy act — that's the real security boundary.

‎packages/client/src/index.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,7 +1240,7 @@ export interface OAuthConsentResult {
12401240
* secret, no session.
12411241
*/
12421242
export interface AuthorizedClient {
1243-
/** The consent's own id — the key `POST /oauth2/delete-consent` takes to withdraw it. */
1243+
/** The consent's own id — the key `oauth.authorizedClients.withdraw()` takes to withdraw it. */
12441244
id: string;
12451245
/** The OAuth client the grant is for. */
12461246
clientId: string;
@@ -1269,6 +1269,12 @@ export interface AuthorizedClientsList {
12691269
truncated: boolean;
12701270
}
12711271

1272+
/** What both `oauth.authorizedClients` withdraws answer: the consent that was withdrawn. */
1273+
export interface AuthorizedClientWithdrawal {
1274+
/** The consent id the request named. */
1275+
id: string;
1276+
}
1277+
12721278
/**
12731279
* The user object better-auth puts on the wire from the `auth.*` routes that
12741280
* echo one — `changePassword`, `verifyEmail` (on a change-email verification),
@@ -5003,8 +5009,7 @@ export class ObjectStackClient {
50035009
*
50045010
* Keyed by the session and nothing else: the route takes no parameter,
50055011
* and refuses one (`400`) rather than ignoring it, so this method sends
5006-
* none. Withdraw a grant with `POST /oauth2/delete-consent` and the row's
5007-
* `id`.
5012+
* none. Withdraw a grant with `withdraw()` and the row's `id`.
50085013
*/
50095014
list: async (): Promise<AuthorizedClientsList> => {
50105015
const route = this.getRoute('auth');
@@ -5028,6 +5033,44 @@ export class ObjectStackClient {
50285033
);
50295034
return this.unwrapResponse<AuthorizedClientsList>(res);
50305035
},
5036+
5037+
/**
5038+
* Withdraw one of the signed-in person's own authorized assistants, by
5039+
* the `id` `list()` answers on its row.
5040+
* POST /api/v1/auth/authorized-clients/withdraw
5041+
*
5042+
* The grant is revoked: the assistant's token is refused on its next
5043+
* call, and it must be authorized again to reconnect. Only the person's
5044+
* own grant is withdrawn: an id that names another person's grant
5045+
* answers `404` exactly as one that names none, and changes nothing.
5046+
*/
5047+
withdraw: async (id: string): Promise<AuthorizedClientWithdrawal> => {
5048+
const route = this.getRoute('auth');
5049+
const res = await this.fetch(`${this.baseUrl}${route}/authorized-clients/withdraw`, {
5050+
method: 'POST',
5051+
body: JSON.stringify({ id }),
5052+
});
5053+
return this.unwrapResponse<AuthorizedClientWithdrawal>(res);
5054+
},
5055+
5056+
/**
5057+
* Withdraw one of another person's authorized assistants, by the `id`
5058+
* `listForUser()` answers on its row.
5059+
* POST /api/v1/auth/admin/authorized-clients/withdraw
5060+
*
5061+
* Platform administrators only, behind the same gate as
5062+
* `listForUser()`: anyone else is refused `403` whoever the request
5063+
* names. A `userId` that names no user, or an `id` that is not that
5064+
* person's grant, answers `404`.
5065+
*/
5066+
withdrawForUser: async (userId: string, id: string): Promise<AuthorizedClientWithdrawal> => {
5067+
const route = this.getRoute('auth');
5068+
const res = await this.fetch(`${this.baseUrl}${route}/admin/authorized-clients/withdraw`, {
5069+
method: 'POST',
5070+
body: JSON.stringify({ userId, id }),
5071+
});
5072+
return this.unwrapResponse<AuthorizedClientWithdrawal>(res);
5073+
},
50315074
},
50325075
};
50335076

‎packages/client/src/oauth-authorized-clients.test.ts‎

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* [#30918] `oauth.authorizedClients` — the URL each method builds and the
5-
* answer it hands back. The server half (who may read whose grants) is pinned
6-
* in plugin-auth's `authorized-clients-read.real-plugin.test.ts`; this file
7-
* pins only what the SDK puts on the wire and what it returns.
4+
* [#30918, #31048] `oauth.authorizedClients` — the URL each method builds and
5+
* the answer it hands back. The server half (who may read and withdraw whose
6+
* grants) is pinned in plugin-auth's `authorized-clients-read.real-plugin.test.ts`;
7+
* this file pins only what the SDK puts on the wire and what it returns.
88
*/
99

1010
import { describe, it, expect, expectTypeOf, vi } from 'vitest';
11-
import { ObjectStackClient, type AuthorizedClientsList } from './index';
11+
import { ObjectStackClient, type AuthorizedClientsList, type AuthorizedClientWithdrawal } from './index';
1212

1313
const BASE = 'http://localhost:3000';
1414

@@ -70,3 +70,48 @@ describe('#30918 oauth.authorizedClients', () => {
7070
expectTypeOf(client.oauth.authorizedClients.listForUser).returns.toEqualTypeOf<Promise<AuthorizedClientsList>>();
7171
});
7272
});
73+
74+
describe('#31048 oauth.authorizedClients withdraws', () => {
75+
const WITHDRAWN: AuthorizedClientWithdrawal = { id: 'consent_1' };
76+
77+
it('withdraw(id) posts exactly { id } to the own door and unwraps the envelope', async () => {
78+
const { client, fetchMock } = clientAnswering({ success: true, data: WITHDRAWN });
79+
const answer = await client.oauth.authorizedClients.withdraw('consent_1');
80+
expect(answer).toEqual(WITHDRAWN);
81+
expect(fetchMock).toHaveBeenCalledTimes(1);
82+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
83+
expect(url).toBe(`${BASE}/api/v1/auth/authorized-clients/withdraw`);
84+
expect(init.method).toBe('POST');
85+
// The route refuses any other body key, so the method sends only the id.
86+
expect(JSON.parse(String(init.body))).toEqual({ id: 'consent_1' });
87+
});
88+
89+
it('withdrawForUser(userId, id) posts exactly { userId, id } to the admin door', async () => {
90+
const { client, fetchMock } = clientAnswering({ success: true, data: WITHDRAWN });
91+
const answer = await client.oauth.authorizedClients.withdrawForUser('user_2', 'consent_1');
92+
expect(answer).toEqual(WITHDRAWN);
93+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
94+
expect(url).toBe(`${BASE}/api/v1/auth/admin/authorized-clients/withdraw`);
95+
expect(init.method).toBe('POST');
96+
expect(JSON.parse(String(init.body))).toEqual({ userId: 'user_2', id: 'consent_1' });
97+
});
98+
99+
it('a not-found or refused withdraw reaches the caller as a throw, never as a withdrawn answer', async () => {
100+
const notFound = clientAnswering(
101+
{ success: false, error: { code: 'RESOURCE_NOT_FOUND', message: 'No authorized assistant you may withdraw matches the supplied id.' } },
102+
404,
103+
);
104+
await expect(notFound.client.oauth.authorizedClients.withdraw('consent_x')).rejects.toThrow();
105+
const refused = clientAnswering(
106+
{ success: false, error: { code: 'PERMISSION_DENIED', message: 'Platform administrator access required.' } },
107+
403,
108+
);
109+
await expect(refused.client.oauth.authorizedClients.withdrawForUser('user_2', 'consent_1')).rejects.toThrow();
110+
});
111+
112+
it('both withdraws are typed with the answer they unwrap', () => {
113+
const client = new ObjectStackClient({ baseUrl: BASE, fetch: vi.fn() as never });
114+
expectTypeOf(client.oauth.authorizedClients.withdraw).returns.toEqualTypeOf<Promise<AuthorizedClientWithdrawal>>();
115+
expectTypeOf(client.oauth.authorizedClients.withdrawForUser).returns.toEqualTypeOf<Promise<AuthorizedClientWithdrawal>>();
116+
});
117+
});

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4509,7 +4509,8 @@ export class AuthManager {
45094509
* read, and when the read fails (fail closed). So revocation takes effect
45104510
* on the next request: `/oauth2/revoke` deletes the consent and the
45114511
* client's refresh family for that user (`answerOAuthTokenRevocation`), and
4512-
* the Console's `/oauth2/delete-consent` deletes the same row. A server-only
4512+
* the Console's withdraw (`authorized-clients-withdraw.ts`) revokes the same
4513+
* grant the same way. A server-only
45134514
* `skip_consent` client has no consent row and is admitted without one.
45144515
*
45154516
* The caller (runtime dispatcher) maps the returned principal through

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3896,6 +3896,29 @@ export class AuthPlugin implements Plugin {
38963896
}
38973897
});
38983898

3899+
// ────────────────────────────────────────────────────────────────────
3900+
// [#31048] admin: withdraw one of the AI assistants another person
3901+
// authorized, by the consent id the read above answers. The read's own
3902+
// gate first, ahead of any body read, so a caller outside a platform
3903+
// administrator's reach gets one refusal whoever the body names and
3904+
// whether or not the person or the consent exists (authorized-clients-withdraw.ts).
3905+
rawApp.post(`${basePath}/admin/authorized-clients/withdraw`, async (c: any) => {
3906+
try {
3907+
const actor = await gateAdmin(c);
3908+
if (actor instanceof Response) return actor;
3909+
const { authorizedClientWithdrawDepsFor, runAdminAuthorizedClientWithdraw } = await import('./authorized-clients-withdraw.js');
3910+
const answer = await runAdminAuthorizedClientWithdraw(authorizedClientWithdrawDepsFor(this.authManager!), actor, {
3911+
raw: c.req.raw,
3912+
queries: (c.req.queries() ?? {}) as Record<string, string[]>,
3913+
});
3914+
return c.json(answer.body, answer.status as any, answer.headers);
3915+
} catch (error) {
3916+
const err = error instanceof Error ? error : new Error(String(error));
3917+
ctx.logger.error('[AuthPlugin] admin/authorized-clients/withdraw failed', err);
3918+
return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'The authorized client could not be withdrawn.' } }, 500);
3919+
}
3920+
});
3921+
38993922
// ────────────────────────────────────────────────────────────────────
39003923
// [#30958] ADR-0134 — admin: mint and revoke the bearer credentials SCIM
39013924
// connections authenticate with. The SCIM plugin verifies against
@@ -4781,6 +4804,28 @@ export class AuthPlugin implements Plugin {
47814804
}
47824805
});
47834806

4807+
// ────────────────────────────────────────────────────────────────────
4808+
// [#31048] Withdraw one of the caller's own authorized AI assistants, by
4809+
// the consent id the read above answers: the consent and the client's
4810+
// refresh family go, so the assistant's token is refused on its next MCP
4811+
// call. Only the session subject's own consent is withdrawn; an id naming
4812+
// no consent and one naming another person's read the same 404. Another
4813+
// person's grant is the admin door above. See authorized-clients-withdraw.ts.
4814+
rawApp.post(`${basePath}/authorized-clients/withdraw`, async (c: any) => {
4815+
try {
4816+
const { authorizedClientWithdrawDepsFor, runOwnAuthorizedClientWithdraw } = await import('./authorized-clients-withdraw.js');
4817+
const answer = await runOwnAuthorizedClientWithdraw(authorizedClientWithdrawDepsFor(this.authManager!), {
4818+
raw: c.req.raw,
4819+
queries: (c.req.queries() ?? {}) as Record<string, string[]>,
4820+
});
4821+
return c.json(answer.body, answer.status as any, answer.headers);
4822+
} catch (error) {
4823+
const err = error instanceof Error ? error : new Error(String(error));
4824+
ctx.logger.error('[AuthPlugin] authorized-clients/withdraw failed', err);
4825+
return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'The authorized client could not be withdrawn.' } }, 500);
4826+
}
4827+
});
4828+
47844829
// ────────────────────────────────────────────────────────────────────
47854830
// Self-service resend of the email-verification link. SHADOWS better-auth's
47864831
// native `/send-verification-email` (registered before the catch-all below).

‎packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,14 @@ describe('auth route ledger hygiene', () => {
248248
// holds it to the same proof — better-auth's own read is
249249
// `/organization/get-invitation`, a different path.
250250
'GET /api/v1/auth/organization/invitation-preview',
251+
// [#31048] Both withdraws, on this pin's own terms: `auth-plugin.ts`
252+
// mounts each itself (a `rawApp.post` on the
253+
// `${basePath}/admin/authorized-clients/withdraw` and
254+
// `${basePath}/authorized-clients/withdraw` templates, ahead of the
255+
// catch-all), and the `live.has(route)` loop below holds them to the same
256+
// proof — better-auth's own withdraw is `/oauth2/delete-consent`, a
257+
// different path.
258+
'POST /api/v1/auth/admin/authorized-clients/withdraw',
251259
'POST /api/v1/auth/admin/import-users',
252260
// On this pin's own terms: `auth-plugin.ts` mounts it itself (a
253261
// `rawApp.post` on the `${basePath}/admin/list-platform-admins`
@@ -274,6 +282,7 @@ describe('auth route ledger hygiene', () => {
274282
'POST /api/v1/auth/admin/sso/request-domain-verification',
275283
'POST /api/v1/auth/admin/sso/verify-domain',
276284
'POST /api/v1/auth/admin/unlock-user',
285+
'POST /api/v1/auth/authorized-clients/withdraw',
277286
'POST /api/v1/auth/organization/add-member',
278287
'POST /api/v1/auth/set-initial-password',
279288
'POST /api/v1/auth/sys-oauth-application/register',

0 commit comments

Comments
 (0)