diff --git a/.changeset/banned-user-oauth-credentials-refused.md b/.changeset/banned-user-oauth-credentials-refused.md new file mode 100644 index 00000000000..e571e4c58fb --- /dev/null +++ b/.changeset/banned-user-oauth-credentials-refused.md @@ -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 + + + +**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. diff --git a/packages/plugins/plugin-auth/src/api-error-code.pin.test.ts b/packages/plugins/plugin-auth/src/api-error-code.pin.test.ts index a51b869c579..6156c774576 100644 --- a/packages/plugins/plugin-auth/src/api-error-code.pin.test.ts +++ b/packages/plugins/plugin-auth/src/api-error-code.pin.test.ts @@ -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. @@ -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`; @@ -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' }) diff --git a/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts b/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts index 811bf7cbc61..1df52dabc3e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts @@ -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'; /** @@ -684,6 +685,15 @@ export async function buildPluginList(this: PluginListHost): Promise { // `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 diff --git a/packages/plugins/plugin-auth/src/auth-manager.oauth-consent-organization.test.ts b/packages/plugins/plugin-auth/src/auth-manager.oauth-consent-organization.test.ts index bedbd80ce50..97f71851b46 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.oauth-consent-organization.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.oauth-consent-organization.test.ts @@ -94,14 +94,15 @@ async function captureProviderOptions(): Promise { * 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('better-auth'), import('better-auth/adapters/memory'), import('better-auth/plugins'), import('better-auth/plugins/organization'), vi.importActual('@better-auth/oauth-provider'), + import('better-auth/plugins/admin'), ]); const provider = realOauthProvider(opts); @@ -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 = {}; for (const m of ['user', 'session', 'account', 'verification']) db[m] = []; @@ -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; @@ -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 ?? [], }; } @@ -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) { + 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(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 7c46b7cb160..654c3fff2f3 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -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 diff --git a/packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.test.ts b/packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.test.ts new file mode 100644 index 00000000000..edc0ff5f8b9 --- /dev/null +++ b/packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.test.ts @@ -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 = {}) => ({ id: 'u1', email: 'u1@example.com', ...over }); + +async function refusalOf(promise: Promise): Promise { + 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(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.ts b/packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.ts new file mode 100644 index 00000000000..ef3213bc7f3 --- /dev/null +++ b/packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * oauth-grant-ban-refusal — the token endpoint mints no token for a user whose + * ban holds. + * + * A ban holds every credential of the user: it ends their sessions, refuses + * their sign-in, their API keys, and their OAuth access tokens at the door that + * resolves them (`@objectstack/runtime` `oauth-subject-ban.ts`). A refresh + * token renews an access token, and an authorization code exchange mints one, + * so the grant is refused here for as long as the ban holds — with the one ban + * predicate every other refusal asks (`isUserBanInForce`, `@objectstack/core`): + * the ban flag, lifted once its expiry has passed, exactly as sign-in reads it. + * + * ## Where it runs, and why there + * + * The installed `@better-auth/oauth-provider` (1.7.12) resolves every grant + * that acts for a user — `authorization_code`, `refresh_token`, and any + * extension grant that issues through the provider — to that user's row + * (`internalAdapter.findUserById`) and hands the row to ONE function, + * `createUserTokens`, which mints the access, refresh and ID tokens. Its first + * operator callback, `customTokenResponseFields({ grantType, user, … })`, runs + * there BEFORE anything is written: before the refresh token is rotated, before + * any token row is created, before anything is signed. So it is the one point + * that sees every user grant with the user's row in hand, and a refusal thrown + * from it leaves every row as it was. The provider throws its own grant errors + * from the same handler, so a refusal thrown here leaves the endpoint exactly + * as one of those does: the same status, body and headers. + * + * Not chosen: a `/oauth2/token` before-hook (it has no user — it would have to + * re-implement the provider's refresh-token decoding and storage hashing); a + * database hook on the token models (a refresh that mints a JWT access token + * writes no access-token row, and the old refresh token is revoked before the + * new one is created); `customAccessTokenClaims` (called for JWT access tokens + * only, and after an opaque grant's refresh token is already written). + * + * The row is the provider's own read, so the refusal costs no read of its own. + * A `client_credentials` grant has no user and is not touched. + * + * ## The answer + * + * The answer an invalid grant of the same type gets today: `400 invalid_grant` + * with the provider's own description for that grant — `invalid refresh token` + * (what an expired, revoked or misdirected refresh token answers) and `invalid + * code` (what a spent or unknown code answers). Nothing tells the holder + * whether the grant, or its user, is the reason. A grant type the provider + * names no description for answers `invalid grant`. + * + * ⛔ Not a revocation: nothing is written, so lifting the ban (or letting it + * expire) lets the same refresh token renew again. + */ + +import { isUserBanInForce, type UserBanFields } from '@objectstack/core'; + +/** The provider's own `invalid_grant` description per grant type, as it answers an invalid grant of that type. */ +const INVALID_GRANT_DESCRIPTION: Readonly> = { + refresh_token: 'invalid refresh token', + authorization_code: 'invalid code', +}; + +/** What the provider hands `customTokenResponseFields` that this refusal reads. */ +export interface TokenGrantInput { + grantType?: string; + user?: (UserBanFields & Record) | null; +} + +/** + * Throws the provider's `invalid_grant` error when the grant acts for a user + * whose ban holds at `nowMs`; returns nothing otherwise. + */ +export async function refuseGrantForBannedUser(input: TokenGrantInput, nowMs: number = Date.now()): Promise { + if (!input.user || !isUserBanInForce(input.user, nowMs)) return; + const { APIError } = await import('better-auth/api'); + throw new APIError('BAD_REQUEST', { + error_description: INVALID_GRANT_DESCRIPTION[input.grantType ?? ''] ?? 'invalid grant', + error: 'invalid_grant', + }); +} diff --git a/packages/qa/dogfood/test/oauth-banned-subject.dogfood.test.ts b/packages/qa/dogfood/test/oauth-banned-subject.dogfood.test.ts new file mode 100644 index 00000000000..a13d53a0076 --- /dev/null +++ b/packages/qa/dogfood/test/oauth-banned-subject.dogfood.test.ts @@ -0,0 +1,335 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A banned user's OAuth access tokens and refresh grants are refused while the + * ban holds. + * + * A ban ends the user's sessions, refuses their sign-in and refuses their API + * keys. An OAuth access token the user consented to, and the refresh token + * that renews it, are credentials of that same user, so while the ban holds: + * + * - at the MCP door, the user's access token answers exactly as a token that + * does not verify: `initialize`, a read tool and a write tool all get the + * same status, body and `WWW-Authenticate` header a made-up token gets, and + * the refused write lands nothing; + * - at the token endpoint, the user's refresh grant answers exactly as an + * invalid refresh token does (`400 invalid_grant`, the same description and + * headers), and mints nothing. + * + * Both are read where the credential becomes its user, with the one ban + * predicate the sign-in, session and API-key refusals share: the ban flag, and + * its expiry. A ban with an expiry stops binding once it expires. + * + * ⛔ Not a revocation: no token row is written. Once the ban is lifted the SAME + * access token is served again and the SAME refresh token renews, with nothing + * reissued. + * + * Controls, which keep today's answer: before the ban the token initializes, + * reads, writes and renews; another user's token keeps working during the ban; + * after the unban, and after a timed ban expires, the same credentials work. + * The read cost and the store-fault answer are pinned on the resolver + * (`packages/runtime/src/security/resolve-execution-context.test.ts`) and the + * grant refusal on its own module (`plugin-auth` `oauth-grant-ban-refusal`). + */ + +import { createHash, randomBytes } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { MCPServerPlugin } from '@objectstack/mcp'; +import { bootShowcase } from './showcase-boot.js'; +import { showcaseAppDefaultSecurity } from './showcase-security.js'; + +const OBJECT = 'showcase_private_note'; +const SYS = { context: { isSystem: true } }; +const PASSWORD = 'Member-Pass-123'; +const REDIRECT_URI = 'http://127.0.0.1:33419/callback'; +const SCOPE = 'openid profile email offline_access data:read data:write'; + +/** One door's answer, reduced to what a caller can tell apart. */ +interface DoorAnswer { + status: number; + body: unknown; + wwwAuthenticate: string | null; + cacheControl: string | null; +} + +interface AgentGrant { + clientId: string; + accessToken: string; + refreshToken: string; +} + +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.value) ? r.value : []); + +describe('a banned user\'s OAuth access tokens and refresh grants are refused while the ban holds', () => { + let stack: VerifyStack; + let adminToken: string; + let ql: any; + let resource: string; + let priorScim: string | undefined; + let priorOidc: string | undefined; + + const BANNED_EMAIL = 'oauth.banned.subject@verify.test'; + const PEER_EMAIL = 'oauth.banned.peer@verify.test'; + let bannedUserId: string; + let banned: AgentGrant; + let peer: AgentGrant; + /** A JWS-shaped bearer nothing signed — the answer every MCP refusal is compared to. */ + const madeUpToken = [randomBytes(24), randomBytes(48), randomBytes(32)].map((b) => b.toString('base64url')).join('.'); + + const answerOf = async (res: Response): Promise => { + const text = await res.text(); + let body: unknown; + try { body = JSON.parse(text); } catch { body = text; } + return { + status: res.status, + body, + wwwAuthenticate: res.headers.get('www-authenticate'), + cacheControl: res.headers.get('cache-control'), + }; + }; + + /** DCR → sign-in → authorize with the MCP resource → consent → code exchange. */ + const consentAgent = async (email: string): Promise => { + const reg = await stack.api('/auth/oauth2/register', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: 'banned-subject pin', + redirect_uris: [REDIRECT_URI], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + application_type: 'native', + scope: SCOPE, + }), + }); + const regBody: any = await reg.json(); + expect(reg.status, JSON.stringify(regBody)).toBe(201); + const signIn = await stack.api('/auth/sign-in/email', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD }), + }); + expect(signIn.status, await signIn.clone().text()).toBe(200); + const cookie = ((signIn.headers as any).getSetCookie() as string[]).map((c) => c.split(';')[0]).join('; '); + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + const qs = new URLSearchParams({ + client_id: regBody.client_id, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: SCOPE, + state: 'st', + code_challenge: challenge, + code_challenge_method: 'S256', + resource, + }); + const az = await stack.api(`/auth/oauth2/authorize?${qs}`, { headers: { cookie }, redirect: 'manual' }); + const loc = az.headers.get('location') ?? ''; + let code = new URL(loc, 'http://localhost:3000').searchParams.get('code'); + if (!code) { + const consent = await stack.api('/auth/oauth2/consent', { + method: 'POST', + headers: { 'content-type': 'application/json', cookie, origin: 'http://localhost:3000' }, + body: JSON.stringify({ accept: true, oauth_query: loc.slice(loc.indexOf('?')) }), + }); + const cb: any = await consent.json().catch(() => null); + code = new URL(cb?.redirect_uri ?? cb?.url).searchParams.get('code'); + } + const tok = await tokenEndpoint({ + grant_type: 'authorization_code', + code: code!, + redirect_uri: REDIRECT_URI, + client_id: regBody.client_id, + code_verifier: verifier, + resource, + }); + const tj: any = await tok.json(); + expect(tok.status, JSON.stringify(tj)).toBe(200); + expect(tj.refresh_token, 'offline_access yields a refresh token').toBeTruthy(); + return { clientId: regBody.client_id, accessToken: tj.access_token, refreshToken: tj.refresh_token }; + }; + + const tokenEndpoint = (form: Record) => + stack.api('/auth/oauth2/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(form).toString(), + }); + const refresh = (grant: AgentGrant, refreshToken = grant.refreshToken, clientId = grant.clientId) => + tokenEndpoint({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, resource }); + + const mcp = (token: string, method: string, params?: unknown) => + stack.api('/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, ...(params !== undefined ? { params } : {}) }), + }); + const initialize = (token: string) => + mcp(token, 'initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'banned-subject-pin', version: '0.0.0' }, + }); + const queryRecords = (token: string) => + mcp(token, 'tools/call', { name: 'query_records', arguments: { objectName: OBJECT } }); + const createRecord = (token: string, title: string) => + mcp(token, 'tools/call', { name: 'create_record', arguments: { objectName: OBJECT, data: { title } } }); + + const notesTitled = async (title: string): Promise => + rowsOf(await ql.find(OBJECT, { where: { title }, limit: 5, ...SYS })); + + const ban = async (body: Record) => { + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/ban-user', { userId: bannedUserId, ...body }); + expect(res.status, await res.clone().text()).toBe(200); + }; + const unban = async () => { + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/unban-user', { userId: bannedUserId }); + expect(res.status, await res.clone().text()).toBe(200); + }; + + /** The token initializes, reads, writes (as its user) at the MCP door. */ + const expectAccessTokenServed = async (grant: AgentGrant, userId: string | undefined, label: string) => { + const init = await initialize(grant.accessToken); + expect(init.status, `${label}: initialize ${await init.clone().text()}`).toBe(200); + const read = await queryRecords(grant.accessToken); + const readBody: any = await read.json(); + expect(read.status, `${label}: query_records`).toBe(200); + expect(readBody?.result?.isError, `${label}: query_records ${JSON.stringify(readBody)}`).not.toBe(true); + const title = `served-${randomBytes(4).toString('hex')}`; + const write = await createRecord(grant.accessToken, title); + const writeBody: any = await write.json(); + expect(write.status, `${label}: create_record`).toBe(200); + expect(writeBody?.result?.isError, `${label}: create_record ${JSON.stringify(writeBody)}`).not.toBe(true); + const [row] = await notesTitled(title); + expect(row, `${label}: the write landed`).toBeDefined(); + if (userId) expect(row.created_by, `${label}: written as the token's user`).toBe(userId); + }; + + /** Every MCP call answers the banned user's token exactly as it answers a made-up token. */ + const expectAccessTokenAnsweredAsMadeUp = async (label: string) => { + const init = await answerOf(await initialize(banned.accessToken)); + const initControl = await answerOf(await initialize(madeUpToken)); + expect(initControl.status, `${label}: a made-up token's initialize is refused`).toBe(401); + expect(init, `${label}: initialize`).toEqual(initControl); + + const read = await answerOf(await queryRecords(banned.accessToken)); + const readControl = await answerOf(await queryRecords(madeUpToken)); + expect(readControl.status, `${label}: a made-up token's read is refused`).toBe(401); + expect(read, `${label}: query_records`).toEqual(readControl); + + const title = `refused-${randomBytes(4).toString('hex')}`; + const write = await answerOf(await createRecord(banned.accessToken, title)); + const writeControl = await answerOf(await createRecord(madeUpToken, `${title}-control`)); + expect(writeControl.status, `${label}: a made-up token's write is refused`).toBe(401); + expect(write, `${label}: create_record`).toEqual(writeControl); + expect(await notesTitled(title), `${label}: the refused write landed nothing`).toEqual([]); + }; + + /** + * The banned user's refresh grant answers exactly as an invalid refresh + * token does. The control is a real refresh token presented by a client it + * was not issued to, which the endpoint answers as an invalid refresh token. + */ + const expectRefreshAnsweredAsInvalid = async (label: string) => { + const refused = await answerOf(await refresh(banned)); + const control = await answerOf(await refresh(peer, peer.refreshToken, banned.clientId)); + expect(control.status, `${label}: the invalid refresh token is refused`).toBe(400); + expect((control.body as any)?.error, `${label}: control`).toBe('invalid_grant'); + expect(refused, `${label}: refresh grant`).toEqual(control); + }; + + /** A refresh that renews: a new access token, and the rotated refresh token kept for the next call. */ + const expectRefreshServed = async (grant: AgentGrant, label: string) => { + const res = await refresh(grant); + const body: any = await res.json(); + expect(res.status, `${label}: refresh ${JSON.stringify(body)}`).toBe(200); + expect(body.access_token, `${label}: a new access token`).toBeTruthy(); + if (body.refresh_token) grant.refreshToken = body.refresh_token; + grant.accessToken = body.access_token; + }; + + beforeAll(async () => { + // The ban routes answer 501 unless better-auth's admin plugin is on (SCIM + // forces it on), and the OAuth authorization server is opt-in. Both are + // read when the auth manager is constructed, so they precede the boot. + priorScim = process.env.OS_SCIM_ENABLED; + priorOidc = process.env.OS_OIDC_PROVIDER_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + process.env.OS_OIDC_PROVIDER_ENABLED = 'true'; + stack = await bootShowcase({ + extraPlugins: [new MCPServerPlugin()], + security: showcaseAppDefaultSecurity(), + }); + adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + resource = ((await stack.kernel.getServiceAsync('auth')) as any).getMcpResourceUrl(); + + const bannedSession = await stack.signUp(BANNED_EMAIL, PASSWORD); + const me = await stack.apiAs(bannedSession, 'GET', '/auth/get-session'); + expect(me.status).toBe(200); + bannedUserId = String(((await me.json()) as any)?.user?.id ?? ''); + expect(bannedUserId).not.toBe(''); + await stack.signUp(PEER_EMAIL, PASSWORD); + + banned = await consentAgent(BANNED_EMAIL); + peer = await consentAgent(PEER_EMAIL); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = priorScim; + if (priorOidc === undefined) delete process.env.OS_OIDC_PROVIDER_ENABLED; + else process.env.OS_OIDC_PROVIDER_ENABLED = priorOidc; + }); + + it('control: before the ban, the access token initializes, reads and writes as its user, and the refresh grant renews it', async () => { + await expectAccessTokenServed(banned, bannedUserId, 'before the ban'); + await expectRefreshServed(banned, 'before the ban'); + await expectAccessTokenServed(banned, bannedUserId, 'renewed, before the ban'); + }); + + it('while the ban holds, the access token answers as a token that does not verify, and the refresh grant as an invalid refresh token', async () => { + const accessBefore = banned.accessToken; + const refreshBefore = banned.refreshToken; + await ban({ banReason: 'oauth-credential-pin' }); + try { + await expectAccessTokenAnsweredAsMadeUp('banned'); + await expectRefreshAnsweredAsInvalid('banned'); + + // Another user's token and refresh are not touched by this user's ban. + await expectAccessTokenServed(peer, undefined, 'peer during the ban'); + await expectRefreshServed(peer, 'peer during the ban'); + } finally { + await unban(); + } + // Nothing was reissued in between: the credentials are the ones held before the ban. + expect(banned.accessToken).toBe(accessBefore); + expect(banned.refreshToken).toBe(refreshBefore); + }); + + it('the ban revokes nothing: once lifted, the same access token is served and the same refresh token renews', async () => { + await expectAccessTokenServed(banned, bannedUserId, 'after the unban'); + await expectRefreshServed(banned, 'after the unban'); + }); + + it('a ban with an expiry binds until it expires, and not after', async () => { + await ban({ banReason: 'timed-oauth-credential-pin', banExpiresIn: 3 }); + try { + await expectAccessTokenAnsweredAsMadeUp('timed ban, before expiry'); + await expectRefreshAnsweredAsInvalid('timed ban, before expiry'); + await new Promise((r) => setTimeout(r, 3_500)); + // No sign-in lapses the ban on the row first: the expiry alone decides. + await expectAccessTokenServed(banned, bannedUserId, 'timed ban, after expiry'); + await expectRefreshServed(banned, 'timed ban, after expiry'); + } finally { + await unban(); + } + }, 60_000); +}); diff --git a/packages/runtime/src/security/oauth-subject-ban.ts b/packages/runtime/src/security/oauth-subject-ban.ts new file mode 100644 index 00000000000..176462fcaa3 --- /dev/null +++ b/packages/runtime/src/security/oauth-subject-ban.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * oauth-subject-ban — a banned user's OAuth access tokens are refused while + * the ban holds. + * + * A ban holds every credential of the user: it ends their sessions, refuses + * their sign-in and refuses their API keys (`resolveApiKeyAdmission`). An OAuth + * access token is a credential of the user who consented to it, and so is the + * agent forward credential the action door mints from one. This door is where + * either becomes that user (`resolveExecutionContext`), so the ban is read + * here, with the one ban predicate every other refusal asks + * (`isUserBanInForce`, `@objectstack/core`): the ban flag, lifted once its + * expiry has passed, exactly as sign-in reads it. + * + * The verifier (`IAuthService.verifyMcpAccessToken`) stays the local, + * I/O-free signature check it is documented to be. The ban is a fact about the + * user, not about the token, and it lives in the store this door already reads + * for the principal's grants. + * + * The answer to a banned subject is the answer a token that does not verify + * gets at this door: no principal, and no fall-back to a cookie session. + * Nothing tells the holder whether the token, or its user, is the reason. + * ⛔ Not a revocation: nothing is written, so lifting the ban (or letting it + * expire) admits the same token again. + * + * One primary-key read of the ban columns, as the system, per credential + * resolved — the read shape the key-admission seam uses. A read that THROWS + * is the branded store outage (`AuthzStoreUnavailableError`, 503), never "not + * banned" and never "no principal"; a `sys_user` table that was never + * provisioned holds no ban, and a user with no row keeps today's answer. + */ + +import { AuthzStoreUnavailableError, isUserBanInForce, USER_BAN_FIELDS } from '@objectstack/core'; +import { isMissingTableError } from '@objectstack/types'; + +/** The one read this module issues — the engine's `find`, as the key-admission seam calls it. */ +interface UserRowReader { + find(object: string, query: Record): Promise; +} + +/** + * True when `userId`'s ban holds at `nowMs`. A door with no data engine has + * no ban to read and answers false, as it answers today. + */ +export async function isOAuthSubjectBanned( + ql: Partial | null | undefined, + userId: string, + nowMs: number = Date.now(), +): Promise { + if (!ql || typeof ql.find !== 'function') return false; + let row: Record | undefined; + try { + let users: any = await ql.find('sys_user', { + where: { id: userId }, + fields: [...USER_BAN_FIELDS], + limit: 1, + context: { isSystem: true }, + }); + if (users && users.value) users = users.value; + row = Array.isArray(users) ? users[0] : undefined; + } catch (err) { + if (!isMissingTableError(err, 'sys_user')) throw new AuthzStoreUnavailableError('sys_user', err); + } + return isUserBanInForce(row, nowMs); +} diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 51f046dca31..c6293b6722a 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -7,6 +7,7 @@ import { ObjectKernel, isAuthzStoreUnavailableError, isServiceNotRegisteredError import { resolveExecutionContext } from './resolve-execution-context.js'; import { hashApiKey } from './api-key.js'; import { bindCatalogFromTables } from './security-catalog.testkit.js'; +import { FORWARD_CREDENTIAL_HEADER, withForwardCredential, type ForwardCredentialBinding } from '@objectstack/rest'; /** * Minimal ObjectQL stub. Only `sys_api_key` is populated; every other object @@ -824,3 +825,128 @@ describe('[#13906 decision 1 A, runtime door] the tenancy posture seam tells "ne expect(err.object).toBe('tenancy'); }); }); + +describe('a banned user\'s OAuth credentials resolve to no principal while the ban holds', () => { + const NOW = Date.now(); + const SOON = new Date(NOW + 3_600_000).toISOString(); + const LAPSED = new Date(NOW - 60_000).toISOString(); + const VERIFIED = { userId: 'u_ban', scopes: ['data:read', 'data:write'], clientId: 'agent-client-1' }; + + /** + * A table double for `sys_user` that records every read, and can make the + * ban read throw (a store fault) or answer as a table that was never + * provisioned. Every other object is empty. + */ + function banQl(user: Record | undefined, fault?: 'throws' | 'missing-table') { + const userReads: any[] = []; + const tables: Record = { sys_user: user ? [user] : [] }; + const ql = { + async find(object: string, opts: any) { + if (object === 'sys_user') { + userReads.push(opts); + // The fault strikes the ban read alone, so the outage asserted below is that read's. + const banRead = Array.isArray(opts?.fields) && opts.fields.includes('banned'); + if (banRead && fault === 'throws') throw new Error('connection reset by peer'); + if (banRead && fault === 'missing-table') throw Object.assign(new Error('no such table: sys_user'), { code: 'SQLITE_ERROR' }); + } + return bounded((tables[object] ?? []).filter((row) => + Object.entries(opts?.where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v !== null && typeof v === 'object') throw new Error(`fake driver: unsupported operator on '${k}'`); + return row[k] === v; + }), + ), opts); + }, + }; + return { ql, userReads }; + } + const oauthOpts = (ql: any, verified: any = VERIFIED) => ({ + acceptOAuthAccessToken: true, + getService: async (name: string) => (name === 'auth' ? { verifyMcpAccessToken: async () => verified } : undefined), + getQl: async () => ql, + request: { headers: { authorization: 'Bearer a.b.c' } }, + }); + /** The envelope a bearer that does not verify gets at this door — what a banned subject must get. */ + const notVerified = () => resolveExecutionContext(oauthOpts(banQl(undefined).ql, null)); + + it('a token whose subject is banned resolves exactly as a token that does not verify', async () => { + const { ql } = banQl({ id: 'u_ban', banned: true, ban_expires: null }); + const ctx = await resolveExecutionContext(oauthOpts(ql)); + const control = await notVerified(); + expect(control.principalKind, 'control: an unverified bearer is the guest').toBe('guest'); + expect(ctx).toEqual(control); + expect(ctx.userId).toBeUndefined(); + }); + + it('a ban with an expiry still ahead binds; one whose expiry has passed does not', async () => { + const holding = await resolveExecutionContext(oauthOpts(banQl({ id: 'u_ban', banned: true, ban_expires: SOON }).ql)); + expect(holding).toEqual(await notVerified()); + const lapsed = await resolveExecutionContext(oauthOpts(banQl({ id: 'u_ban', banned: true, ban_expires: LAPSED }).ql)); + expect(lapsed.principalKind).toBe('agent'); + expect(lapsed.userId).toBe('u_ban'); + }); + + it('control: a user who is not banned, or was unbanned, is the agent principal, as before', async () => { + for (const row of [{ id: 'u_ban' }, { id: 'u_ban', banned: false, ban_expires: null }]) { + const ctx = await resolveExecutionContext(oauthOpts(banQl(row).ql)); + expect(ctx.principalKind, JSON.stringify(row)).toBe('agent'); + expect(ctx.userId).toBe('u_ban'); + } + }); + + it('control: a subject with no user row, or no provisioned user table, keeps today\'s answer', async () => { + const noRow = await resolveExecutionContext(oauthOpts(banQl(undefined).ql)); + expect(noRow.principalKind).toBe('agent'); + expect(noRow.userId).toBe('u_ban'); + const noTable = await resolveExecutionContext(oauthOpts(banQl(undefined, 'missing-table').ql)); + expect(noTable.principalKind).toBe('agent'); + }); + + it('a ban read that throws is the store outage — 503 SERVICE_UNAVAILABLE naming sys_user, never a principal and never the guest', async () => { + const err: any = await resolveExecutionContext(oauthOpts(banQl(undefined, 'throws').ql)).then( + () => undefined, + (e) => e, + ); + expect(err, 'the resolver resolved despite the store fault').toBeDefined(); + expect(isAuthzStoreUnavailableError(err)).toBe(true); + expect(err.code).toBe('SERVICE_UNAVAILABLE'); + expect(err.status).toBe(503); + expect(err.object).toBe('sys_user'); + }); + + it('cost: one read of the ban columns, by primary key, as the system, per token resolved', async () => { + const { ql, userReads } = banQl({ id: 'u_ban' }); + await resolveExecutionContext(oauthOpts(ql)); + const banReads = userReads.filter((q) => Array.isArray(q?.fields) && q.fields.includes('banned')); + expect(banReads).toEqual([ + { where: { id: 'u_ban' }, fields: ['id', 'banned', 'ban_expires'], limit: 1, context: { isSystem: true } }, + ]); + // A bearer that does not verify reads no ban at all. + const unverified = banQl({ id: 'u_ban' }); + await resolveExecutionContext(oauthOpts(unverified.ql, null)); + expect(unverified.userReads.filter((q) => q?.fields?.includes?.('banned'))).toEqual([]); + }); + + it('a forward credential whose agent acts for a banned user resolves as one that does not redeem', async () => { + const forwardBinding: ForwardCredentialBinding = { + method: 'PATCH', + path: '/api/v1/data/crm_lead/r1', + run: { objectName: 'crm_lead', actionName: 'probe', recordId: 'r1' }, + agent: { userId: 'u_ban', clientId: 'agent-client-1', scopes: ['actions:execute', 'data:write'] }, + }; + const forwardedAs = (ql: any) => (handle: string) => resolveExecutionContext({ + getService: async () => undefined, + getQl: async () => ql, + request: { method: 'PATCH', path: '/api/v1/data/crm_lead/r1', headers: { [FORWARD_CREDENTIAL_HEADER]: handle } }, + }); + const control = await forwardedAs(banQl({ id: 'u_ban' }).ql)('not-a-minted-handle'); + expect(control.principalKind, 'control: an unredeemable forward is the guest').toBe('guest'); + + const refused = await withForwardCredential(forwardBinding, forwardedAs(banQl({ id: 'u_ban', banned: true }).ql)); + expect(refused).toEqual(control); + + const served = await withForwardCredential(forwardBinding, forwardedAs(banQl({ id: 'u_ban' }).ql)); + expect(served.principalKind).toBe('agent'); + expect(served.userId).toBe('u_ban'); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 9ca6af118e5..3d6cd3c3379 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -17,7 +17,8 @@ * and throws `AuthzStoreUnavailableError` (503) for the one class of fault * that leaves the answer undetermined: an authorization INPUT that exists and * could not be read (a permission-store read that failed, commit 6a180e42d; a `tenancy` - * service that is registered and failed to build, #13906 decision 1 A). The + * service that is registered and failed to build, #13906 decision 1 A; the ban + * columns of an OAuth credential's user, `oauth-subject-ban.ts`). The * dispatcher's net (`HttpDispatcher.resolveRequestScope`) re-raises exactly * that class and degrades everything else to anonymous, as before. */ @@ -29,6 +30,7 @@ import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import { resolveRequestExecutionContext, type RequestAdmission } from '@objectstack/core'; import { presentedForwardCredential, redeemForwardCredential, type ForwardAgentProvenance } from '@objectstack/rest'; +import { isOAuthSubjectBanned } from './oauth-subject-ban.js'; /** * [#4127 batch 4] The lookup facade, typed by slot like the ones on @@ -170,7 +172,15 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise>>; try { // `verifyMcpAccessToken` is declared by `IAuthService` and reached // through the slot-typed lookup, so the verified result is read as the // contract states it — `organizationId` included. - const verified = await authService?.verifyMcpAccessToken?.(jwtBearer); - if (verified?.userId && Array.isArray(verified.scopes)) { - oauthPrincipal = verified; - } + verified = await authService?.verifyMcpAccessToken?.(jwtBearer); } catch { // verification error → fail closed (anonymous), handled below } + // Read OUTSIDE the verifier's net: a ban read that throws is the store + // outage (503), never a token that did not verify. + if (verified?.userId && Array.isArray(verified.scopes) && !(await isOAuthSubjectBanned(ql, verified.userId))) { + oauthPrincipal = verified; + } } }