Skip to content
20 changes: 20 additions & 0 deletions .changeset/31048-withdraw-authorized-assistant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/client': patch
---

feat(auth): withdraw an authorized AI assistant, for the person themself and for a platform administrator

Clause-②: yes (widening)
Metadata-protocol: none

Two new routes beside the authorized-clients reads, each keyed by the consent `id` those reads answer on every row:

- `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.
- `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.

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`.

`@objectstack/client` adds `oauth.authorizedClients.withdraw(id)` and `oauth.authorizedClients.withdrawForUser(userId, id)`, both answering `AuthorizedClientWithdrawal` (`{ id }`).

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.
15 changes: 15 additions & 0 deletions content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,21 @@ drive this MCP server. Same word, different layer.
person publishes in the UI); it is served only to a token that carries it,
never to an API key or a session, and every authoring call is still checked
against the caller's own metadata permission.
- **A person can withdraw an assistant they authorized, at any time.**
`GET /api/v1/auth/authorized-clients` lists the assistants the signed-in
person authorized (each row's `id` is the grant), and
`POST /api/v1/auth/authorized-clients/withdraw` with `{ "id": "…" }` revokes
one: the grant and the client's refresh tokens are deleted, so the
assistant's token is refused on its very next call and the assistant must
be authorized again to reconnect. Only the person's own grant is withdrawn;
an id that is someone else's answers `404`, exactly as one that names
nothing. A platform administrator does the same for a person with
`GET /api/v1/auth/admin/authorized-clients?userId=…` and
`POST /api/v1/auth/admin/authorized-clients/withdraw` with
`{ "userId": "…", "id": "…" }`; a request with no session is refused
`401`, and anyone else who is signed in is refused `403`. The SDK
spells them `oauth.authorizedClients.list()` / `withdraw(id)` and
`listForUser(userId)` / `withdrawForUser(userId, id)`.
- **Action bodies run as trusted app code** once invoked (the `ai.exposed`
gate and `requiredPermissions` are checked at *invoke* time). Treat writing
an action as a code-review-worthy act — that's the real security boundary.
Expand Down
49 changes: 46 additions & 3 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1240,7 +1240,7 @@ export interface OAuthConsentResult {
* secret, no session.
*/
export interface AuthorizedClient {
/** The consent's own id — the key `POST /oauth2/delete-consent` takes to withdraw it. */
/** The consent's own id — the key `oauth.authorizedClients.withdraw()` takes to withdraw it. */
id: string;
/** The OAuth client the grant is for. */
clientId: string;
Expand Down Expand Up @@ -1269,6 +1269,12 @@ export interface AuthorizedClientsList {
truncated: boolean;
}

/** What both `oauth.authorizedClients` withdraws answer: the consent that was withdrawn. */
export interface AuthorizedClientWithdrawal {
/** The consent id the request named. */
id: string;
}

/**
* The user object better-auth puts on the wire from the `auth.*` routes that
* echo one — `changePassword`, `verifyEmail` (on a change-email verification),
Expand Down Expand Up @@ -5003,8 +5009,7 @@ export class ObjectStackClient {
*
* Keyed by the session and nothing else: the route takes no parameter,
* and refuses one (`400`) rather than ignoring it, so this method sends
* none. Withdraw a grant with `POST /oauth2/delete-consent` and the row's
* `id`.
* none. Withdraw a grant with `withdraw()` and the row's `id`.
*/
list: async (): Promise<AuthorizedClientsList> => {
const route = this.getRoute('auth');
Expand All @@ -5028,6 +5033,44 @@ export class ObjectStackClient {
);
return this.unwrapResponse<AuthorizedClientsList>(res);
},

/**
* Withdraw one of the signed-in person's own authorized assistants, by
* the `id` `list()` answers on its row.
* POST /api/v1/auth/authorized-clients/withdraw
*
* The grant is revoked: the assistant's token is refused on its next
* call, and it must be authorized again to reconnect. Only the person's
* own grant is withdrawn: an id that names another person's grant
* answers `404` exactly as one that names none, and changes nothing.
*/
withdraw: async (id: string): Promise<AuthorizedClientWithdrawal> => {
const route = this.getRoute('auth');
const res = await this.fetch(`${this.baseUrl}${route}/authorized-clients/withdraw`, {
method: 'POST',
body: JSON.stringify({ id }),
});
return this.unwrapResponse<AuthorizedClientWithdrawal>(res);
},

/**
* Withdraw one of another person's authorized assistants, by the `id`
* `listForUser()` answers on its row.
* POST /api/v1/auth/admin/authorized-clients/withdraw
*
* Platform administrators only, behind the same gate as
* `listForUser()`: anyone else is refused `403` whoever the request
* names. A `userId` that names no user, or an `id` that is not that
* person's grant, answers `404`.
*/
withdrawForUser: async (userId: string, id: string): Promise<AuthorizedClientWithdrawal> => {
const route = this.getRoute('auth');
const res = await this.fetch(`${this.baseUrl}${route}/admin/authorized-clients/withdraw`, {
method: 'POST',
body: JSON.stringify({ userId, id }),
});
return this.unwrapResponse<AuthorizedClientWithdrawal>(res);
},
},
};

Expand Down
55 changes: 50 additions & 5 deletions packages/client/src/oauth-authorized-clients.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

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

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

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

Expand Down Expand Up @@ -70,3 +70,48 @@ describe('#30918 oauth.authorizedClients', () => {
expectTypeOf(client.oauth.authorizedClients.listForUser).returns.toEqualTypeOf<Promise<AuthorizedClientsList>>();
});
});

describe('#31048 oauth.authorizedClients withdraws', () => {
const WITHDRAWN: AuthorizedClientWithdrawal = { id: 'consent_1' };

it('withdraw(id) posts exactly { id } to the own door and unwraps the envelope', async () => {
const { client, fetchMock } = clientAnswering({ success: true, data: WITHDRAWN });
const answer = await client.oauth.authorizedClients.withdraw('consent_1');
expect(answer).toEqual(WITHDRAWN);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`${BASE}/api/v1/auth/authorized-clients/withdraw`);
expect(init.method).toBe('POST');
// The route refuses any other body key, so the method sends only the id.
expect(JSON.parse(String(init.body))).toEqual({ id: 'consent_1' });
});

it('withdrawForUser(userId, id) posts exactly { userId, id } to the admin door', async () => {
const { client, fetchMock } = clientAnswering({ success: true, data: WITHDRAWN });
const answer = await client.oauth.authorizedClients.withdrawForUser('user_2', 'consent_1');
expect(answer).toEqual(WITHDRAWN);
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`${BASE}/api/v1/auth/admin/authorized-clients/withdraw`);
expect(init.method).toBe('POST');
expect(JSON.parse(String(init.body))).toEqual({ userId: 'user_2', id: 'consent_1' });
});

it('a not-found or refused withdraw reaches the caller as a throw, never as a withdrawn answer', async () => {
const notFound = clientAnswering(
{ success: false, error: { code: 'RESOURCE_NOT_FOUND', message: 'No authorized assistant you may withdraw matches the supplied id.' } },
404,
);
await expect(notFound.client.oauth.authorizedClients.withdraw('consent_x')).rejects.toThrow();
const refused = clientAnswering(
{ success: false, error: { code: 'PERMISSION_DENIED', message: 'Platform administrator access required.' } },
403,
);
await expect(refused.client.oauth.authorizedClients.withdrawForUser('user_2', 'consent_1')).rejects.toThrow();
});

it('both withdraws are typed with the answer they unwrap', () => {
const client = new ObjectStackClient({ baseUrl: BASE, fetch: vi.fn() as never });
expectTypeOf(client.oauth.authorizedClients.withdraw).returns.toEqualTypeOf<Promise<AuthorizedClientWithdrawal>>();
expectTypeOf(client.oauth.authorizedClients.withdrawForUser).returns.toEqualTypeOf<Promise<AuthorizedClientWithdrawal>>();
});
});
3 changes: 2 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4509,7 +4509,8 @@ export class AuthManager {
* read, and when the read fails (fail closed). So revocation takes effect
* on the next request: `/oauth2/revoke` deletes the consent and the
* client's refresh family for that user (`answerOAuthTokenRevocation`), and
* the Console's `/oauth2/delete-consent` deletes the same row. A server-only
* the Console's withdraw (`authorized-clients-withdraw.ts`) revokes the same
* grant the same way. A server-only
* `skip_consent` client has no consent row and is admitted without one.
*
* The caller (runtime dispatcher) maps the returned principal through
Expand Down
45 changes: 45 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3896,6 +3896,29 @@ export class AuthPlugin implements Plugin {
}
});

// ────────────────────────────────────────────────────────────────────
// [#31048] admin: withdraw one of the AI assistants another person
// authorized, by the consent id the read above answers. The read's own
// gate first, ahead of any body read, so a caller outside a platform
// administrator's reach gets one refusal whoever the body names and
// whether or not the person or the consent exists (authorized-clients-withdraw.ts).
rawApp.post(`${basePath}/admin/authorized-clients/withdraw`, async (c: any) => {
try {
const actor = await gateAdmin(c);
if (actor instanceof Response) return actor;
const { authorizedClientWithdrawDepsFor, runAdminAuthorizedClientWithdraw } = await import('./authorized-clients-withdraw.js');
const answer = await runAdminAuthorizedClientWithdraw(authorizedClientWithdrawDepsFor(this.authManager!), actor, {
raw: c.req.raw,
queries: (c.req.queries() ?? {}) as Record<string, string[]>,
});
return c.json(answer.body, answer.status as any, answer.headers);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
ctx.logger.error('[AuthPlugin] admin/authorized-clients/withdraw failed', err);
return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'The authorized client could not be withdrawn.' } }, 500);
}
});

// ────────────────────────────────────────────────────────────────────
// [#30958] ADR-0134 — admin: mint and revoke the bearer credentials SCIM
// connections authenticate with. The SCIM plugin verifies against
Expand Down Expand Up @@ -4781,6 +4804,28 @@ export class AuthPlugin implements Plugin {
}
});

// ────────────────────────────────────────────────────────────────────
// [#31048] Withdraw one of the caller's own authorized AI assistants, by
// the consent id the read above answers: the consent and the client's
// refresh family go, so the assistant's token is refused on its next MCP
// call. Only the session subject's own consent is withdrawn; an id naming
// no consent and one naming another person's read the same 404. Another
// person's grant is the admin door above. See authorized-clients-withdraw.ts.
rawApp.post(`${basePath}/authorized-clients/withdraw`, async (c: any) => {
try {
const { authorizedClientWithdrawDepsFor, runOwnAuthorizedClientWithdraw } = await import('./authorized-clients-withdraw.js');
const answer = await runOwnAuthorizedClientWithdraw(authorizedClientWithdrawDepsFor(this.authManager!), {
raw: c.req.raw,
queries: (c.req.queries() ?? {}) as Record<string, string[]>,
});
return c.json(answer.body, answer.status as any, answer.headers);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
ctx.logger.error('[AuthPlugin] authorized-clients/withdraw failed', err);
return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'The authorized client could not be withdrawn.' } }, 500);
}
});

// ────────────────────────────────────────────────────────────────────
// Self-service resend of the email-verification link. SHADOWS better-auth's
// native `/send-verification-email` (registered before the catch-all below).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,14 @@ describe('auth route ledger hygiene', () => {
// holds it to the same proof — better-auth's own read is
// `/organization/get-invitation`, a different path.
'GET /api/v1/auth/organization/invitation-preview',
// [#31048] Both withdraws, on this pin's own terms: `auth-plugin.ts`
// mounts each itself (a `rawApp.post` on the
// `${basePath}/admin/authorized-clients/withdraw` and
// `${basePath}/authorized-clients/withdraw` templates, ahead of the
// catch-all), and the `live.has(route)` loop below holds them to the same
// proof — better-auth's own withdraw is `/oauth2/delete-consent`, a
// different path.
'POST /api/v1/auth/admin/authorized-clients/withdraw',
'POST /api/v1/auth/admin/import-users',
// On this pin's own terms: `auth-plugin.ts` mounts it itself (a
// `rawApp.post` on the `${basePath}/admin/list-platform-admins`
Expand All @@ -274,6 +282,7 @@ describe('auth route ledger hygiene', () => {
'POST /api/v1/auth/admin/sso/request-domain-verification',
'POST /api/v1/auth/admin/sso/verify-domain',
'POST /api/v1/auth/admin/unlock-user',
'POST /api/v1/auth/authorized-clients/withdraw',
'POST /api/v1/auth/organization/add-member',
'POST /api/v1/auth/set-initial-password',
'POST /api/v1/auth/sys-oauth-application/register',
Expand Down
Loading
Loading