Skip to content
20 changes: 20 additions & 0 deletions .changeset/banned-user-oauth-credentials-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/runtime': patch
'@objectstack/plugin-auth': patch
---

fix(runtime,plugin-auth)!: a banned user's OAuth access tokens and refresh grants are refused while the ban holds

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; the token endpoint answers that user's `refresh_token` grant and `authorization_code` exchange as an invalid grant of the same type (`400 invalid_grant`, the same description). Nothing else narrows.
Metadata-protocol: none

<!-- 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, and no token, grant or consent row is revoked or rewritten. -->

**BREAKING** accept-set narrowing at the MCP door and the OAuth token endpoint: a banned user's OAuth credentials are refused while the ban holds.

- **The rule.** A ban holds every credential of the user. Banning a user already ends their sessions and refuses their sign-in and their API keys; their OAuth access tokens are now refused at the MCP door, and the token endpoint mints them no new token (no refresh, no code exchange), for as long as the ban holds. A ban with an expiry stops binding once the expiry has passed, exactly as sign-in does.
- **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 and lets the same refresh token renew, with nothing reissued.
- **Cost.** One primary-key read of the user's ban columns per OAuth credential the MCP door resolves; the token endpoint reads nothing extra. A store fault on that read answers `503 SERVICE_UNAVAILABLE`, as every other authorization-input read on that door does.
- **Who moves.** An MCP client connected over OAuth on behalf of a banned user gets `401` at the MCP door, and its refresh gets `400 invalid_grant`, until the ban is lifted or expires.

**The remedy.** Lift the ban, or connect the client as a user who is not banned.
28 changes: 26 additions & 2 deletions packages/plugins/plugin-auth/src/api-error-code.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@
* 2. a body destructured, in the same function, from the result of
* `judgeHeldSession(...)`: the held-session judge's ADR-0112 envelope,
* whose code sits under `error` (`platform-admin-gate.held-session.test.ts`
* pins that body's shape).
* pins that body's shape); or
* 3. an OAuth 2.0 error response (RFC 6749 §5.2): an object literal with a
* top-level `error` whose value is a string literal, beside an
* `error_description`. That is the dialect the authorization server's own
* endpoints answer in, and `error` is its machine code; a refusal that must
* answer exactly as the provider's own error does (`oauth-grant-ban-refusal.ts`)
* carries no second, ADR-0112 `code` the provider's answer lacks.
*
* No body, a literal without `code`, a spread, or an identifier from anywhere
* else is a site this pin cannot read a code from, so it fails.
Expand Down Expand Up @@ -128,7 +134,15 @@ function whyCodeless(site: ts.NewExpression, sf: ts.SourceFile): string | null {
const carriesCode = body.properties.some(
(p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && propertyName(p.name) === 'code',
);
return carriesCode ? null : 'its body has no top-level `code` property';
if (carriesCode) return null;
// Shape 3: the OAuth 2.0 error response — a literal `error` code beside an `error_description`.
const oauthError = body.properties.some(
(p) => ts.isPropertyAssignment(p) && propertyName(p.name) === 'error' && ts.isStringLiteralLike(p.initializer),
);
const oauthDescription = body.properties.some(
(p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && propertyName(p.name) === 'error_description',
);
return oauthError && oauthDescription ? null : 'its body has no top-level `code` property';
}
if (ts.isIdentifier(body) && isHeldSessionBody(body, site)) return null;
return `its body is \`${body.getText(sf)}\`, which this pin cannot read a code from`;
Expand Down Expand Up @@ -244,6 +258,16 @@ describe('the classifier, against the shapes it must tell apart', () => {
expect(verdicts(fixture)).toEqual([true, true, true, true, true]);
});

it('accepts an OAuth 2.0 error response (a literal `error` beside an `error_description`), and nothing short of it', () => {
const fixture = `
function a(d: string) { throw new APIError('BAD_REQUEST', { error_description: d, error: 'invalid_grant' }); }
function b() { throw new APIError('BAD_REQUEST', { error_description: 'invalid code', error: 'invalid_grant' }); }
function c() { throw new APIError('BAD_REQUEST', { error: 'invalid_grant' }); }
function d(e: string) { throw new APIError('BAD_REQUEST', { error_description: 'x', error: e }); }
function f() { throw new APIError('BAD_REQUEST', { error_description: 'x' }); }`;
expect(verdicts(fixture)).toEqual([true, true, false, false, false]);
});

it('the textual control sees past comments, and counts a member-callee site', () => {
const fixture = `
// new APIError('FORBIDDEN', { message: 'in a comment' })
Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
registeredAppSchemesOf,
} from './one-time-token-handoff.js';
import { userInfoLocaleClaim } from './oidc-userinfo-locale-claim.js';
import { refuseGrantForBannedUser } from './oauth-grant-ban-refusal.js';
import { buildTurnstileSignUpPlugin, reportHalfConfiguredTurnstile, resolveTurnstileKeys } from './turnstile-sign-up.js';

/**
Expand Down Expand Up @@ -684,6 +685,15 @@ export async function buildPluginList(this: PluginListHost): Promise<any[]> {
// `IAuthService.verifyMcpAccessToken` contract names.
customAccessTokenClaims: ({ referenceId }) =>
referenceId ? { [OAUTH_ORGANIZATION_CLAIM]: referenceId } : {},
// ── A banned user is minted no token (`oauth-grant-ban-refusal.ts`) ──
// The provider's one pre-write callback for every grant that acts for a
// user, with that user's row in hand: while the ban holds, the grant is
// answered as an invalid grant of its type and nothing is written. It
// contributes no response field.
customTokenResponseFields: async ({ grantType, user }) => {
await refuseGrantForBannedUser({ grantType, user });
return {};
},
// ── The standard `locale` claim at userinfo (OIDC Core §5.1) ──
// `sys_user.locale` under the `profile` scope, so an environment that
// signs in through this OP follows the user's language; the rule and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,15 @@ async function captureProviderOptions(): Promise<any> {
* derived from each plugin's own resolved schema, so they are the platform's
* `sys_*` names whenever a plugin resolves to them.
*/
async function bootServer(opts: any) {
const [{ betterAuth }, { memoryAdapter }, { jwt }, { organization }, { oauthProvider: realOauthProvider }] =
async function bootServer(opts: any, { admin = false }: { admin?: boolean } = {}) {
const [{ betterAuth }, { memoryAdapter }, { jwt }, { organization }, { oauthProvider: realOauthProvider }, adminPlugins] =
await Promise.all([
vi.importActual<typeof import('better-auth')>('better-auth'),
import('better-auth/adapters/memory'),
import('better-auth/plugins'),
import('better-auth/plugins/organization'),
vi.importActual<typeof import('@better-auth/oauth-provider')>('@better-auth/oauth-provider'),
import('better-auth/plugins/admin'),
]);

const provider = realOauthProvider(opts);
Expand All @@ -110,6 +111,9 @@ async function bootServer(opts: any) {
// mapped to `sys_jwks` anyway once AuthManager has built one in this process
// (see auth-manager.mcp-oauth-resource.test.ts).
const jwtPlugin = jwt({ schema: buildJwtPluginSchema() as any });
// The admin plugin, when a leg asks for it, gives the user model its ban
// columns (`banned` / `banExpires`), as the platform's admin plugin does.
const extra: any[] = admin ? [adminPlugins.admin()] : [];

const db: Record<string, any[]> = {};
for (const m of ['user', 'session', 'account', 'verification']) db[m] = [];
Expand All @@ -125,7 +129,7 @@ async function bootServer(opts: any) {
secret: SECRET,
database: memoryAdapter(db),
emailAndPassword: { enabled: true },
plugins: [jwtPlugin, orgPlugin as any, provider as any],
plugins: [jwtPlugin, orgPlugin as any, provider as any, ...extra],
});
await auth.$context;

Expand All @@ -139,6 +143,8 @@ async function bootServer(opts: any) {
/** The stored rows, by the platform's column name. */
consentRows: () => db[consentModel] ?? [],
refreshRows: () => db[refreshModel] ?? [],
/** The stored user rows — a leg that bans writes the ban columns here, as the admin route does. */
userRows: () => db.user ?? [],
};
}

Expand Down Expand Up @@ -413,3 +419,63 @@ describe('existing consents re-confirm once (ruling 6232069920, item 4)', () =>
expect((await verifier(server).verifyMcpAccessToken(oldFamily.access_token))?.organizationId).toBeUndefined();
});
});

describe('a banned user is minted no token at the token endpoint while the ban holds (`oauth-grant-ban-refusal.ts`)', () => {
/** The token endpoint's answer, as a caller can tell it apart. */
async function tokenAnswer(server: Server, params: Record<string, string>) {
const res = await server.auth.handler(
new Request(`${ISSUER}/oauth2/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params).toString(),
}),
);
return {
status: res.status,
body: (await res.json().catch(() => null)) as any,
cacheControl: res.headers.get('cache-control'),
wwwAuthenticate: res.headers.get('www-authenticate'),
};
}
const refreshAnswer = (server: Server, clientId: string, refreshToken: string) =>
tokenAnswer(server, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, resource: MCP_RESOURCE });

it('the refresh grant of a banned user answers as an invalid refresh token, writes nothing, and renews again once the ban lapses', async () => {
const server = await bootServer(await captureProviderOptions(), { admin: true });
const clientId = await registerClient(server);
const otherClientId = await registerClient(server);
const cookie = await signUp(server, 'banned.subject@acme.example.com');
const tokens = await consentedGrant(server, clientId, cookie);
const [row] = server.userRows();
expect(row, 'the user row the leg bans').toBeDefined();

// The answer an invalid refresh token gets: a real one presented by a client it was not issued to.
const control = await refreshAnswer(server, otherClientId, tokens.refresh_token!);
expect(control.status).toBe(400);
expect(control.body).toEqual({ error_description: 'invalid refresh token', error: 'invalid_grant' });

row.banned = true;
row.banExpires = new Date(Date.now() + 3_600_000);
const before = JSON.stringify(server.refreshRows());

const refused = await refreshAnswer(server, clientId, tokens.refresh_token!);

expect(refused).toEqual(control);
expect(JSON.stringify(server.refreshRows()), 'the refused grant rotated or revoked nothing').toBe(before);

// The ban's expiry passes: the SAME refresh token renews, nothing reissued in between.
row.banExpires = new Date(Date.now() - 1_000);
const renewed = await refresh(server, clientId, tokens.refresh_token!);
expect(renewed.access_token).toBeTruthy();
});

it('control: a user who is not banned renews as before', async () => {
const server = await bootServer(await captureProviderOptions(), { admin: true });
const clientId = await registerClient(server);
const cookie = await signUp(server, 'not.banned@acme.example.com');
const tokens = await consentedGrant(server, clientId, cookie);
server.userRows()[0].banned = false;
const renewed = await refresh(server, clientId, tokens.refresh_token!);
expect(renewed.access_token).toBeTruthy();
});
});
6 changes: 5 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4305,7 +4305,11 @@ export class AuthManager {
* deliberately LOCAL, I/O-free verifier depend on the data engine and turn
* a transient store error into a 401 for a legitimate human, and a row's
* existence is not humanity anyway (`isHumanUserRow` exists because
* `usr_system` is a row and not a person).
* `usr_system` is a row and not a person). A banned user's token is
* refused by the door that resolves the principal, which reads the ban
* where it already reads the principal's grants and answers a store fault
* as the 503 it is (`@objectstack/runtime` `oauth-subject-ban.ts`); the
* token endpoint mints a banned user no token (`oauth-grant-ban-refusal.ts`).
*
* Revocation note: JWT access tokens are not server-tracked, so
* revocation takes effect at expiry (≤1h default); refresh tokens ARE
Expand Down
89 changes: 89 additions & 0 deletions packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The token endpoint mints no token for a user whose ban holds: the grant is
* answered as an invalid grant of its type (`oauth-grant-ban-refusal.ts`).
*
* The answer is pinned against the provider's own error for that grant — the
* same class, status, `error` and `error_description` the installed
* `@better-auth/oauth-provider` throws for an invalid refresh token or code —
* so a banned user's grant cannot be told from an invalid one. The wiring into
* the provider and the door's answer are pinned end to end in the dogfood
* battery (`oauth-banned-subject.dogfood.test.ts`).
*/

import { describe, it, expect } from 'vitest';
import { APIError } from 'better-auth/api';
import { refuseGrantForBannedUser } from './oauth-grant-ban-refusal.js';

const NOW = Date.parse('2026-06-01T12:00:00Z');
const user = (over: Record<string, unknown> = {}) => ({ id: 'u1', email: 'u1@example.com', ...over });

async function refusalOf(promise: Promise<void>): Promise<any> {
try {
await promise;
} catch (err) {
return err;
}
return undefined;
}

/** What the provider answers an invalid grant of `grantType` with, as it builds it. */
const providerInvalidGrant = (description: string) =>
new APIError('BAD_REQUEST', { error_description: description, error: 'invalid_grant' });

describe('a banned user is minted no token: the grant answers as an invalid grant of its type', () => {
it('a refresh grant for a banned user answers as an invalid refresh token', async () => {
const err = await refusalOf(refuseGrantForBannedUser({ grantType: 'refresh_token', user: user({ banned: true }) }, NOW));
const control = providerInvalidGrant('invalid refresh token');

expect(err).toBeInstanceOf(APIError);
expect(err.status).toBe(control.status);
expect(err.statusCode).toBe(400);
expect(err.body).toEqual(control.body);
expect(err.body).toEqual({ error_description: 'invalid refresh token', error: 'invalid_grant' });
expect(err.headers).toEqual(control.headers);
});

it('an authorization-code exchange for a banned user answers as an invalid code', async () => {
const err = await refusalOf(refuseGrantForBannedUser({ grantType: 'authorization_code', user: user({ banned: true }) }, NOW));
expect(err).toBeInstanceOf(APIError);
expect(err.body).toEqual(providerInvalidGrant('invalid code').body);
});

it('any other grant that acts for a banned user is refused as an invalid grant too', async () => {
const err = await refusalOf(
refuseGrantForBannedUser({ grantType: 'urn:ietf:params:oauth:grant-type:device_code', user: user({ banned: true }) }, NOW),
);
expect(err).toBeInstanceOf(APIError);
expect(err.statusCode).toBe(400);
expect(err.body).toEqual({ error_description: 'invalid grant', error: 'invalid_grant' });
});

it('a ban with an expiry binds until it passes, in either spelling the row carries', async () => {
const soon = new Date(NOW + 60_000);
const lapsed = new Date(NOW - 60_000);
for (const over of [{ banExpires: soon }, { ban_expires: soon.toISOString() }]) {
const err = await refusalOf(refuseGrantForBannedUser({ grantType: 'refresh_token', user: user({ banned: true, ...over }) }, NOW));
expect(err?.body?.error, JSON.stringify(over)).toBe('invalid_grant');
}
for (const over of [{ banExpires: lapsed }, { ban_expires: lapsed.toISOString() }]) {
await expect(
refuseGrantForBannedUser({ grantType: 'refresh_token', user: user({ banned: true, ...over }) }, NOW),
JSON.stringify(over),
).resolves.toBeUndefined();
}
});

it('control: a user who is not banned, or was unbanned, is granted as before', async () => {
for (const row of [user(), user({ banned: false, banExpires: null }), user({ banned: null })]) {
await expect(refuseGrantForBannedUser({ grantType: 'refresh_token', user: row }, NOW), JSON.stringify(row)).resolves.toBeUndefined();
await expect(refuseGrantForBannedUser({ grantType: 'authorization_code', user: row }, NOW)).resolves.toBeUndefined();
}
});

it('control: a grant with no user (client_credentials) is not touched', async () => {
await expect(refuseGrantForBannedUser({ grantType: 'client_credentials', user: undefined }, NOW)).resolves.toBeUndefined();
await expect(refuseGrantForBannedUser({ grantType: 'client_credentials', user: null }, NOW)).resolves.toBeUndefined();
});
});
Loading
Loading