|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#28178] An AI agent connected to MCP over OAuth runs a same-origin |
| 4 | +// `type: 'api'` action. The action doors forward it with the caller's OWN |
| 5 | +// credentials, and the platform honours an OAuth access token on the MCP |
| 6 | +// endpoint only, so the target read the agent as nobody and answered its |
| 7 | +// anonymous 401. Ruled B (fable 总监决裁 5, Elon 2026-10-31): refuse by name |
| 8 | +// before the forward, from one table of credential kind × target door that |
| 9 | +// `run_action` and `list_actions` share, and MARK such an action in the |
| 10 | +// listing instead of hiding it. A server-built forward credential that would |
| 11 | +// let the agent through is a separate decision (#28367), so the last pin here |
| 12 | +// is that the token is still refused when presented to a target directly. |
| 13 | +// |
| 14 | +// Booted showcase, a REAL OAuth access token (DCR → authorize with the MCP |
| 15 | +// resource → consent → token), a session and an API key for the unchanged lanes. |
| 16 | +import { createHash, randomBytes } from 'node:crypto'; |
| 17 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 18 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 19 | +import { type VerifyStack } from '@objectstack/verify'; |
| 20 | +import { MCPServerPlugin } from '@objectstack/mcp'; |
| 21 | +import showcaseStack from '@objectstack/example-showcase'; |
| 22 | +import { bootShowcase } from './showcase-boot.js'; |
| 23 | + |
| 24 | +const SYS = { isSystem: true } as const; |
| 25 | +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); |
| 26 | +const REDIRECT_URI = 'http://127.0.0.1:33418/callback'; |
| 27 | +const ai = (d: string) => ({ exposed: true, description: d }); |
| 28 | +let banTarget = ''; |
| 29 | + |
| 30 | +const Probe = ObjectSchema.create({ |
| 31 | + name: 'probe_fwd_thing', |
| 32 | + label: 'Forward Probe', |
| 33 | + sharingModel: 'public_read_write', |
| 34 | + fields: { name: Field.text({ label: 'Name' }) }, |
| 35 | + actions: [ |
| 36 | + { name: 'probe_get_session', label: 'Get Session', type: 'api', method: 'POST', target: '/api/v1/auth/get-session', locations: ['record_header'], ai: ai('Read the session the forward target sees for this caller.') }, |
| 37 | + { name: 'probe_data_patch', label: 'Data Patch', type: 'api', method: 'PATCH', target: '/api/v1/data/probe_fwd_thing/${ctx.recordId}', bodyExtra: { name: 'patched' }, locations: ['record_header'], ai: ai('Patch the probe record through the data door as the caller.') }, |
| 38 | + { name: 'probe_ban', label: 'Ban', type: 'api', method: 'POST', target: '/api/v1/auth/admin/ban-user', params: [{ name: 'userId', label: 'User', type: 'text', required: true }], locations: ['record_header'], ai: ai('Ban the probe target user through the better-auth admin endpoint.') }, |
| 39 | + ], |
| 40 | +} as any); |
| 41 | + |
| 42 | +describe('[#28178] an OAuth-connected agent and api actions: refused by name, marked in list_actions, boundary unmoved', () => { |
| 43 | + let stack: VerifyStack; |
| 44 | + let ql: any; |
| 45 | + let session: string; |
| 46 | + let apiKey: string; |
| 47 | + let oauth: string; |
| 48 | + let recordId: string; |
| 49 | + let prior: string | undefined; |
| 50 | + let priorOidc: string | undefined; |
| 51 | + |
| 52 | + const rpc = async (headers: Record<string, string>, name: string, args: Record<string, unknown>) => { |
| 53 | + const res = await stack.api('/mcp', { |
| 54 | + method: 'POST', |
| 55 | + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers }, |
| 56 | + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }), |
| 57 | + }); |
| 58 | + const text = await res.text(); |
| 59 | + const parsed: any = JSON.parse(text); |
| 60 | + const content = parsed?.result?.content?.[0]?.text; |
| 61 | + return { http: res.status, isError: parsed?.result?.isError === true, body: content ? JSON.parse(content) : parsed }; |
| 62 | + }; |
| 63 | + const runAction = (headers: Record<string, string>, actionName: string, args: Record<string, unknown> = {}) => |
| 64 | + rpc(headers, 'run_action', { actionName, objectName: 'probe_fwd_thing', recordId, ...args }); |
| 65 | + const listProbeActions = async (headers: Record<string, string>) => { |
| 66 | + const r = await rpc(headers, 'list_actions', {}); |
| 67 | + expect(r.isError, JSON.stringify(r.body)).toBe(false); |
| 68 | + return Object.fromEntries((r.body.actions as any[]).filter((a) => a.objectName === 'probe_fwd_thing').map((a) => [a.name, a])); |
| 69 | + }; |
| 70 | + const rowName = async () => |
| 71 | + rowsOf(await ql.find('probe_fwd_thing', { where: { id: recordId }, limit: 1 }, { context: SYS }))[0]?.name; |
| 72 | + const isBanned = async () => |
| 73 | + rowsOf(await ql.find('sys_user', { where: { id: banTarget }, limit: 1 }, { context: SYS }))[0]?.banned === true; |
| 74 | + |
| 75 | + beforeAll(async () => { |
| 76 | + prior = process.env.OS_SCIM_ENABLED; |
| 77 | + priorOidc = process.env.OS_OIDC_PROVIDER_ENABLED; |
| 78 | + process.env.OS_SCIM_ENABLED = 'true'; |
| 79 | + process.env.OS_OIDC_PROVIDER_ENABLED = 'true'; |
| 80 | + const declared = showcaseStack as unknown as { objects?: unknown[] }; |
| 81 | + stack = await bootShowcase( |
| 82 | + { extraPlugins: [new MCPServerPlugin()] }, |
| 83 | + { ...(showcaseStack as unknown as Record<string, unknown>), objects: [...(declared.objects ?? []), Probe] }, |
| 84 | + ); |
| 85 | + session = await stack.signIn(); |
| 86 | + ql = await stack.kernel.getServiceAsync('objectql'); |
| 87 | + const created = await stack.apiAs(session, 'POST', '/data/probe_fwd_thing', { name: 'probe' }); |
| 88 | + const cj: any = await created.json(); |
| 89 | + recordId = String(cj?.data?.id ?? cj?.id); |
| 90 | + await stack.signUp('fwd.target@example.com', 'Target-Pass-123'); |
| 91 | + banTarget = String(rowsOf(await ql.find('sys_user', { where: { email: 'fwd.target@example.com' }, limit: 1 }, { context: SYS }))[0]?.id); |
| 92 | + // API key |
| 93 | + const k = await stack.apiAs(session, 'POST', '/keys', { name: 'fwd-probe' }); |
| 94 | + const kj: any = await k.json(); |
| 95 | + apiKey = String(kj?.data?.key); |
| 96 | + // OAuth: DCR → authorize → consent → token |
| 97 | + const authSvc: any = await stack.kernel.getServiceAsync('auth'); |
| 98 | + const resource = authSvc.getMcpResourceUrl(); |
| 99 | + const scope = 'openid profile email offline_access data:read data:write actions:execute'; |
| 100 | + const reg = await stack.api('/auth/oauth2/register', { |
| 101 | + method: 'POST', headers: { 'content-type': 'application/json' }, |
| 102 | + body: JSON.stringify({ client_name: 'fwd probe', redirect_uris: [REDIRECT_URI], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', application_type: 'native', scope }), |
| 103 | + }); |
| 104 | + const regBody: any = await reg.json(); |
| 105 | + expect(reg.status, JSON.stringify(regBody)).toBe(201); |
| 106 | + const signin = await stack.api('/auth/sign-in/email', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }) }); |
| 107 | + const cookie = ((signin.headers as any).getSetCookie() as string[]).map((c) => c.split(';')[0]).join('; '); |
| 108 | + const verifier = randomBytes(32).toString('base64url'); |
| 109 | + const challenge = createHash('sha256').update(verifier).digest('base64url'); |
| 110 | + const qs = new URLSearchParams({ client_id: regBody.client_id, redirect_uri: REDIRECT_URI, response_type: 'code', scope, state: 'st', code_challenge: challenge, code_challenge_method: 'S256', resource }); |
| 111 | + const az = await stack.api(`/auth/oauth2/authorize?${qs}`, { headers: { cookie }, redirect: 'manual' }); |
| 112 | + const loc = az.headers.get('location') ?? ''; |
| 113 | + let code = new URL(loc, 'http://localhost:3000').searchParams.get('code'); |
| 114 | + if (!code) { |
| 115 | + 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('?')) }) }); |
| 116 | + const cb: any = await consent.json().catch(() => null); |
| 117 | + code = new URL(cb?.redirect_uri ?? cb?.url).searchParams.get('code'); |
| 118 | + } |
| 119 | + const tok = await stack.api('/auth/oauth2/token', { |
| 120 | + method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, |
| 121 | + body: new URLSearchParams({ grant_type: 'authorization_code', code: code!, redirect_uri: REDIRECT_URI, client_id: regBody.client_id, code_verifier: verifier, resource }).toString(), |
| 122 | + }); |
| 123 | + const tj: any = await tok.json(); |
| 124 | + expect(tok.status, JSON.stringify(tj)).toBe(200); |
| 125 | + oauth = tj.access_token; |
| 126 | + }, 300_000); |
| 127 | + |
| 128 | + afterAll(async () => { |
| 129 | + await stack?.stop?.(); |
| 130 | + if (prior === undefined) delete process.env.OS_SCIM_ENABLED; else process.env.OS_SCIM_ENABLED = prior; |
| 131 | + if (priorOidc === undefined) delete process.env.OS_OIDC_PROVIDER_ENABLED; else process.env.OS_OIDC_PROVIDER_ENABLED = priorOidc; |
| 132 | + }); |
| 133 | + |
| 134 | + it('list_actions LISTS the api actions for the OAuth agent and marks each with the refusal, code UNAUTHENTICATED', async () => { |
| 135 | + const listed = await listProbeActions({ Authorization: `Bearer ${oauth}` }); |
| 136 | + |
| 137 | + expect(Object.keys(listed).sort()).toEqual(['probe_ban', 'probe_data_patch', 'probe_get_session']); |
| 138 | + expect(listed.probe_data_patch.unavailable?.code).toBe('UNAUTHENTICATED'); |
| 139 | + expect(listed.probe_data_patch.unavailable?.reason).toMatch( |
| 140 | + /^Action 'probe_data_patch' forwards to an endpoint \('\/api\/v1\/data\/probe_fwd_thing\/.*'\) that accepts a signed-in session or an API key, and this request was authenticated with an OAuth access token, which is honoured on the MCP endpoint only\. The action was not run\. Connect with an API key/, |
| 141 | + ); |
| 142 | + expect(listed.probe_ban.unavailable?.code).toBe('UNAUTHENTICATED'); |
| 143 | + expect(listed.probe_ban.unavailable?.reason).toMatch(/identity endpoint \('\/api\/v1\/auth\/admin\/ban-user'\) that accepts a signed-in session only/); |
| 144 | + }); |
| 145 | + |
| 146 | + it('run_action: the OAuth agent gets the named refusal the listing carried, not the target\'s anonymous 401, and the row is untouched', async () => { |
| 147 | + const before = await rowName(); |
| 148 | + const listed = await listProbeActions({ Authorization: `Bearer ${oauth}` }); |
| 149 | + |
| 150 | + const r = await runAction({ Authorization: `Bearer ${oauth}` }, 'probe_data_patch'); |
| 151 | + |
| 152 | + expect(r.isError).toBe(true); |
| 153 | + expect(r.body.error).toMatchObject({ code: 'UNAUTHENTICATED', status: 401, message: listed.probe_data_patch.unavailable.reason }); |
| 154 | + expect(r.body.error.message).not.toBe('Authentication is required to access this endpoint.'); |
| 155 | + expect(await rowName()).toBe(before); |
| 156 | + }); |
| 157 | + |
| 158 | + it('run_action: the OAuth agent running the identity-route Ban action is refused by name and nobody is banned', async () => { |
| 159 | + const r = await runAction({ Authorization: `Bearer ${oauth}` }, 'probe_ban', { params: { userId: banTarget } }); |
| 160 | + |
| 161 | + expect(r.isError).toBe(true); |
| 162 | + expect(r.body.error.code).toBe('UNAUTHENTICATED'); |
| 163 | + expect(r.body.error.message).toMatch(/^Action 'probe_ban' forwards to an identity endpoint .* OAuth access token, which is honoured on the MCP endpoint only\. The action was not run\./); |
| 164 | + expect(await isBanned()).toBe(false); |
| 165 | + }); |
| 166 | + |
| 167 | + it('unchanged lanes: a session lists the actions unmarked; an API key is marked on the identity route only and its data-door action is served', async () => { |
| 168 | + const asSession = await listProbeActions({ Authorization: `Bearer ${session}` }); |
| 169 | + expect(Object.values(asSession).filter((a: any) => a.unavailable)).toEqual([]); |
| 170 | + |
| 171 | + const asKey = await listProbeActions({ 'x-api-key': apiKey }); |
| 172 | + expect(asKey.probe_ban.unavailable?.code).toBe('UNAUTHENTICATED'); |
| 173 | + expect(asKey.probe_data_patch.unavailable).toBeUndefined(); |
| 174 | + |
| 175 | + const r = await runAction({ 'x-api-key': apiKey }, 'probe_data_patch'); |
| 176 | + expect(r.isError, JSON.stringify(r.body)).toBe(false); |
| 177 | + expect(await rowName()).toBe('patched'); |
| 178 | + }); |
| 179 | + |
| 180 | + it('the boundary is unmoved: the same OAuth token presented to the data door or an identity route directly is still refused', async () => { |
| 181 | + const data = await stack.api(`/data/probe_fwd_thing/${recordId}`, { headers: { Authorization: `Bearer ${oauth}` } }); |
| 182 | + expect(data.status).toBe(401); |
| 183 | + const identity = await stack.api('/auth/get-session', { headers: { Authorization: `Bearer ${oauth}` } }); |
| 184 | + expect(identity.status).toBe(401); |
| 185 | + }); |
| 186 | +}); |
0 commit comments