diff --git a/examples/servers/typescript/auth-no-audience-validation.ts b/examples/servers/typescript/auth-no-audience-validation.ts new file mode 100644 index 00000000..e0a0312a --- /dev/null +++ b/examples/servers/typescript/auth-no-audience-validation.ts @@ -0,0 +1,141 @@ +#!/usr/bin/env node + +/** + * Issue #78 Negative Test Server — broken audience validation. + * + * An OAuth-protected MCP server that verifies Bearer JWTs (signature via the + * issuer's JWKS, issuer, expiry) but deliberately SKIPS audience validation, + * violating "MCP servers MUST validate that access tokens were issued + * specifically for them as the intended audience" (authorization spec, + * Token Handling). Against this server the auth-token-audience-validation + * scenario must emit FAILURE for the wrong-audience and missing-audience + * checks while the signature/expiry checks still pass. + * + * Configuration (same contract as the everything-server's opt-in auth mode): + * PORT - listen port (default 3000) + * MCP_CONFORMANCE_AUTH_ISSUER - trusted authorization server issuer URL + */ + +import express from 'express'; +import { rateLimit } from 'express-rate-limit'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +const PORT = process.env.PORT || 3000; +const AUTH_ISSUER = + process.env.MCP_CONFORMANCE_AUTH_ISSUER ?? 'http://127.0.0.1:9797'; + +const app = express(); +app.use(express.json()); + +// Rate-limit the endpoint (it performs JWKS fetches and signature +// verification per request). Generous enough that conformance runs — a +// handful of probes per scenario — never hit it. +app.use( + '/mcp', + rateLimit({ + windowMs: 60_000, + limit: 10_000, + standardHeaders: true, + legacyHeaders: false + }) +); + +// Lazily-constructed remote JWKS, discovered from the issuer's RFC 8414 +// metadata at first use. +let jwks: ReturnType | undefined; + +app.use('/mcp', async (req, res, next) => { + const header = req.headers.authorization; + if (!header?.startsWith('Bearer ')) { + res.set('WWW-Authenticate', 'Bearer'); + return res.status(401).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: 'Unauthorized: missing Bearer token' } + }); + } + + try { + if (!jwks) { + const metadataRes = await fetch( + `${AUTH_ISSUER}/.well-known/oauth-authorization-server` + ); + if (!metadataRes.ok) { + throw new Error( + `authorization server metadata fetch failed: HTTP ${metadataRes.status}` + ); + } + const metadata = (await metadataRes.json()) as { jwks_uri?: string }; + if (!metadata.jwks_uri) { + throw new Error('authorization server metadata lacks jwks_uri'); + } + jwks = createRemoteJWKSet(new URL(metadata.jwks_uri)); + } + // BROKEN ON PURPOSE: no `audience` option — a token minted for any other + // resource (or with no aud claim at all) passes verification. + await jwtVerify(header.slice('Bearer '.length), jwks, { + issuer: AUTH_ISSUER + }); + return next(); + } catch (error) { + // Strip characters that would break out of the quoted-string. + const description = ( + error instanceof Error ? error.message : 'invalid' + ).replace(/["\r\n]/g, ''); + res.set( + 'WWW-Authenticate', + `Bearer error="invalid_token", error_description="${description}"` + ); + return res.status(401).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: 'Unauthorized: invalid token' } + }); + } +}); + +app.post('/mcp', (req, res) => { + const body = req.body || {}; + const id = body.id ?? null; + const method = body.method; + + switch (method) { + case 'initialize': + return res.json({ + jsonrpc: '2.0', + id, + result: { + protocolVersion: body.params?.protocolVersion ?? '2025-11-25', + capabilities: { tools: {} }, + serverInfo: { name: 'auth-no-audience-validation', version: '1.0.0' } + } + }); + case 'server/discover': + return res.json({ + jsonrpc: '2.0', + id, + result: { + supportedVersions: ['2026-07-28'], + capabilities: { tools: {} }, + serverInfo: { name: 'auth-no-audience-validation', version: '1.0.0' } + } + }); + case 'tools/list': + return res.json({ jsonrpc: '2.0', id, result: { tools: [] } }); + default: + if (id === null) return res.status(202).end(); // notification + return res.json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` } + }); + } +}); + +app.listen(PORT, () => { + console.log( + `Auth no-audience-validation negative test server running on http://localhost:${PORT}` + ); + console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`); + console.log(` - Trusted issuer: ${AUTH_ISSUER}`); +}); diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 39be2f06..9ff1d5d3 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -36,6 +36,8 @@ import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-sc import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import cors from 'cors'; +import { rateLimit } from 'express-rate-limit'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; import { randomUUID, createHmac } from 'crypto'; // Server state @@ -1225,6 +1227,93 @@ app.use( }) ); +// --- Opt-in OAuth 2.1 resource-server mode (auth-token-audience-validation +// scenario, issue #78). When MCP_CONFORMANCE_AUTH_ISSUER is set, every /mcp +// request must carry a Bearer JWT signed by that issuer's JWKS key, issued by +// that issuer, unexpired, and audience-bound to this server's canonical URI +// (MCP_CONFORMANCE_AUTH_AUDIENCE, defaulting to this server's own /mcp URL). +// When the env var is unset — every other conformance run — this block is +// inert and the server remains unauthenticated. +const AUTH_ISSUER = process.env.MCP_CONFORMANCE_AUTH_ISSUER; +if (AUTH_ISSUER) { + const expectedAudience = + process.env.MCP_CONFORMANCE_AUTH_AUDIENCE ?? + `http://localhost:${process.env.PORT || 3000}/mcp`; + // Constructed lazily so the conformance suite's mock authorization server + // only needs to be running when the first token arrives, not at startup. + let jwks: ReturnType | undefined; + + // Rate-limit the endpoint while token verification (JWKS fetch + signature + // check per request) is enabled. Generous enough that conformance runs — a + // handful of probes per scenario — never hit it. Scoped to auth mode so the + // default unauthenticated fixture used by the rest of the suite is + // unaffected. + app.use( + '/mcp', + rateLimit({ + windowMs: 60_000, + limit: 10_000, + standardHeaders: true, + legacyHeaders: false + }) + ); + + app.use('/mcp', async (req, res, next) => { + const unauthorized = (description: string) => { + // Strip characters that would break out of the quoted-string. + const sanitized = description.replace(/["\r\n]/g, ''); + res.set( + 'WWW-Authenticate', + `Bearer error="invalid_token", error_description="${sanitized}"` + ); + res.status(401).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: `Unauthorized: ${description}` } + }); + }; + + const header = req.headers.authorization; + if (!header?.startsWith('Bearer ')) { + res.set('WWW-Authenticate', 'Bearer'); + return res.status(401).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: 'Unauthorized: missing Bearer token' } + }); + } + + try { + if (!jwks) { + // Discover the JWKS location from the issuer's RFC 8414 metadata. + const metadataRes = await fetch( + `${AUTH_ISSUER}/.well-known/oauth-authorization-server` + ); + if (!metadataRes.ok) { + throw new Error( + `authorization server metadata fetch failed: HTTP ${metadataRes.status}` + ); + } + const metadata = (await metadataRes.json()) as { jwks_uri?: string }; + if (!metadata.jwks_uri) { + throw new Error('authorization server metadata lacks jwks_uri'); + } + jwks = createRemoteJWKSet(new URL(metadata.jwks_uri)); + } + // jwtVerify enforces signature, exp, iss, and — critically for the + // audience-validation requirement — that `aud` contains exactly this + // server's canonical URI (RFC 8707 Section 2). + await jwtVerify(header.slice('Bearer '.length), jwks, { + issuer: AUTH_ISSUER, + audience: expectedAudience + }); + return next(); + } catch (error) { + return unauthorized(error instanceof Error ? error.message : 'invalid'); + } + }); +} + // Protocol revisions that use the initialize/session lifecycle. The // per-request `_meta` and header/body validation requirements apply to // 2026-07-28 and later, not to traffic from these revisions. diff --git a/examples/servers/typescript/package-lock.json b/examples/servers/typescript/package-lock.json index d892ee63..a7161e54 100644 --- a/examples/servers/typescript/package-lock.json +++ b/examples/servers/typescript/package-lock.json @@ -12,6 +12,8 @@ "@types/cors": "^2.8.19", "cors": "^2.8.5", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", + "jose": "^6.2.3", "zod": "^4.3.6" }, "devDependencies": { @@ -917,12 +919,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -1157,9 +1159,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -1187,9 +1189,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" diff --git a/examples/servers/typescript/package.json b/examples/servers/typescript/package.json index 4356d425..9b516cab 100644 --- a/examples/servers/typescript/package.json +++ b/examples/servers/typescript/package.json @@ -19,6 +19,8 @@ "@types/cors": "^2.8.19", "cors": "^2.8.5", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", + "jose": "^6.2.3", "zod": "^4.3.6" }, "devDependencies": { diff --git a/package-lock.json b/package-lock.json index 3701aab7..2b04e601 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "cors": "^2.8.5", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", + "express-rate-limit": "^8.5.2", "lefthook": "^2.1.6", "prettier": "^3.8.3", "tsdown": "^0.21.10", @@ -2929,12 +2930,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -3361,9 +3362,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/package.json b/package.json index 7deb70d7..202fe826 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "cors": "^2.8.5", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", + "express-rate-limit": "^8.5.2", "lefthook": "^2.1.6", "prettier": "^3.8.3", "tsdown": "^0.21.10", diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index db1584ea..58dca716 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -66,6 +66,7 @@ import { } from './server/prompts'; import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { TokenAudienceValidationScenario } from './server/auth-token-audience'; import { CachingScenario } from './server/caching'; // InputRequiredResult scenarios from (SEP-2322) @@ -210,6 +211,10 @@ const allClientScenariosList: ClientScenario[] = [ // Security scenarios new DNSRebindingProtectionScenario(), + // Token audience validation (issue #78). SKIPs unless the server under + // test has authorization enabled (see the scenario description). + new TokenAudienceValidationScenario(), + // Caching scenarios (SEP-2549) new CachingScenario(), // HTTP Standardization scenarios (SEP-2243) diff --git a/src/scenarios/server/auth-token-audience.test.ts b/src/scenarios/server/auth-token-audience.test.ts new file mode 100644 index 00000000..8ecc393a --- /dev/null +++ b/src/scenarios/server/auth-token-audience.test.ts @@ -0,0 +1,229 @@ +import { spawn, ChildProcess } from 'child_process'; +import { createServer, type Server } from 'http'; +import { createServer as createNetServer } from 'net'; +import path from 'path'; +import { testContext } from '../../connection/testing'; +import { TokenAudienceValidationScenario } from './auth-token-audience'; + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createNetServer(); + server.listen(0, () => { + const port = (server.address() as { port: number }).port; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + +function startServer( + scriptPath: string, + port: number, + env: Record = {} +): Promise { + return new Promise((resolve, reject) => { + const isWindows = process.platform === 'win32'; + const proc = spawn('npx', ['tsx', scriptPath], { + env: { ...process.env, PORT: port.toString(), ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: isWindows + }); + let stderr = ''; + proc.stderr?.on('data', (d) => (stderr += d.toString())); + const timeout = setTimeout(() => { + proc.kill('SIGKILL'); + reject( + new Error(`Server ${scriptPath} failed to start within 30s: ${stderr}`) + ); + }, 30000); + proc.stdout?.on('data', (data) => { + if (data.toString().includes('running on')) { + clearTimeout(timeout); + resolve(proc); + } + }); + proc.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +function stopServer(proc: ChildProcess | null): Promise { + return new Promise((resolve) => { + if (!proc || proc.killed) return resolve(); + const t = setTimeout(() => { + proc.kill('SIGKILL'); + resolve(); + }, 5000); + proc.once('exit', () => { + clearTimeout(t); + resolve(); + }); + proc.kill('SIGTERM'); + }); +} + +const ALL_CHECK_IDS = [ + 'auth-unauthenticated-request-rejected', + 'auth-valid-audience-token-accepted', + 'auth-wrong-audience-token-rejected', + 'auth-missing-audience-token-rejected', + 'auth-expired-token-rejected', + 'auth-untrusted-token-rejected' +]; + +describe('auth-token-audience-validation', () => { + const savedPortEnv = process.env.MCP_CONFORMANCE_AUTH_SERVER_PORT; + + afterAll(() => { + if (savedPortEnv === undefined) { + delete process.env.MCP_CONFORMANCE_AUTH_SERVER_PORT; + } else { + process.env.MCP_CONFORMANCE_AUTH_SERVER_PORT = savedPortEnv; + } + }); + + describe('against the everything-server with auth enabled', () => { + let serverProcess: ChildProcess | null = null; + let serverUrl: string; + + beforeAll(async () => { + const asPort = await getFreePort(); + const port = await getFreePort(); + process.env.MCP_CONFORMANCE_AUTH_SERVER_PORT = asPort.toString(); + serverUrl = `http://localhost:${port}/mcp`; + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/everything-server.ts' + ), + port, + { + MCP_CONFORMANCE_AUTH_ISSUER: `http://127.0.0.1:${asPort}` + // MCP_CONFORMANCE_AUTH_AUDIENCE is intentionally omitted: the + // fixture's default (its own /mcp URL) must match serverUrl. + } + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits SUCCESS for every check', async () => { + const scenario = new TokenAudienceValidationScenario(); + const checks = await scenario.run(testContext(serverUrl)); + + expect(checks.map((c) => c.id).sort()).toEqual([...ALL_CHECK_IDS].sort()); + for (const check of checks) { + expect(check.status, `${check.id}: ${check.errorMessage}`).toBe( + 'SUCCESS' + ); + } + }, 20000); + }); + + describe('against a server that skips audience validation', () => { + let serverProcess: ChildProcess | null = null; + let serverUrl: string; + + beforeAll(async () => { + const asPort = await getFreePort(); + const port = await getFreePort(); + process.env.MCP_CONFORMANCE_AUTH_SERVER_PORT = asPort.toString(); + serverUrl = `http://localhost:${port}/mcp`; + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/auth-no-audience-validation.ts' + ), + port, + { MCP_CONFORMANCE_AUTH_ISSUER: `http://127.0.0.1:${asPort}` } + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits FAILURE for the audience checks and SUCCESS for the rest', async () => { + const scenario = new TokenAudienceValidationScenario(); + const checks = await scenario.run(testContext(serverUrl)); + const byId = new Map(checks.map((c) => [c.id, c])); + + expect(byId.get('auth-wrong-audience-token-rejected')?.status).toBe( + 'FAILURE' + ); + expect(byId.get('auth-missing-audience-token-rejected')?.status).toBe( + 'FAILURE' + ); + + // Signature, expiry, and baseline behaviour are intact, so the broken + // audience validation is the only thing flagged. + expect(byId.get('auth-unauthenticated-request-rejected')?.status).toBe( + 'SUCCESS' + ); + expect(byId.get('auth-valid-audience-token-accepted')?.status).toBe( + 'SUCCESS' + ); + expect(byId.get('auth-expired-token-rejected')?.status).toBe('SUCCESS'); + expect(byId.get('auth-untrusted-token-rejected')?.status).toBe('SUCCESS'); + }, 20000); + }); + + describe('against a server without authorization enabled', () => { + let plainServer: Server; + let serverUrl: string; + + beforeAll(async () => { + const port = await getFreePort(); + serverUrl = `http://localhost:${port}/mcp`; + // Minimal unauthenticated server: accepts the initialize probe with a + // JSON-RPC result, no token required. + plainServer = createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => (raw += chunk)); + req.on('end', () => { + let id: unknown = null; + try { + id = (JSON.parse(raw) as { id?: unknown }).id ?? null; + } catch { + // ignore malformed body; respond with id null + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id, + result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 'no-auth', version: '1.0.0' } + } + }) + ); + }); + }); + await new Promise((resolve) => + plainServer.listen(port, () => resolve()) + ); + }); + + afterAll(async () => { + await new Promise((resolve, reject) => + plainServer.close((err) => (err ? reject(err) : resolve())) + ); + }); + + it('emits SKIPPED for every check (authorization is optional)', async () => { + const scenario = new TokenAudienceValidationScenario(); + const checks = await scenario.run(testContext(serverUrl)); + + expect(checks.map((c) => c.id).sort()).toEqual([...ALL_CHECK_IDS].sort()); + for (const check of checks) { + expect(check.status, check.id).toBe('SKIPPED'); + } + }, 15000); + }); +}); diff --git a/src/scenarios/server/auth-token-audience.ts b/src/scenarios/server/auth-token-audience.ts new file mode 100644 index 00000000..cf7b43f0 --- /dev/null +++ b/src/scenarios/server/auth-token-audience.ts @@ -0,0 +1,634 @@ +/** + * Token audience validation scenario for MCP servers (issue #78). + * + * Tests that an OAuth-protected MCP server, acting as an OAuth 2.1 resource + * server, validates inbound access tokens and — critically — validates that + * tokens were issued specifically for it as the intended audience (RFC 8707). + * + * The scenario stands up a signing-enabled mock Authorization Server (RS256 + * keypair, RFC 8414 metadata, JWKS endpoint) and mints JWTs with + * correct / wrong / missing `aud` claims, an expired token, and a token + * signed by an untrusted key, asserting the server accepts only the valid + * token and rejects the rest with HTTP 401. + * + * Authorization is OPTIONAL in MCP. If the server under test accepts the + * unauthenticated probe (i.e. it is not an OAuth-protected server), every + * check reports SKIPPED — the requirement is not applicable. + */ + +import { createServer, type Server } from 'http'; +import { randomUUID } from 'crypto'; +import { SignJWT, exportJWK, generateKeyPair } from 'jose'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + type SpecVersion +} from '../../types'; +import { buildStandardHeaders, type RunContext } from '../../connection'; +import { untestableCheck } from '../untestable'; + +/** + * Port the mock Authorization Server listens on while the scenario runs. + * The server under test must be launched trusting + * `http://127.0.0.1:` as its authorization server (issuer) and must + * fetch the issuer's JWKS lazily (at token-validation time), since the mock + * AS is only running while this scenario executes. + * Override with the MCP_CONFORMANCE_AUTH_SERVER_PORT environment variable. + */ +export const DEFAULT_MOCK_AUTH_SERVER_PORT = 9797; + +const SPEC_REFERENCES = { + tokenHandling: { + id: 'MCP-Auth-Token-Handling', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#token-handling' + }, + audienceBinding: { + id: 'MCP-Auth-Token-Audience-Binding-And-Validation', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#token-audience-binding-and-validation' + }, + privilegeRestriction: { + id: 'MCP-Auth-Access-Token-Privilege-Restriction', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#access-token-privilege-restriction' + }, + errorHandling: { + id: 'MCP-Auth-Error-Handling', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#error-handling' + }, + rfc8707: { + id: 'RFC-8707-Section-2', + url: 'https://www.rfc-editor.org/rfc/rfc8707.html#section-2' + } +} as const; + +interface SignTokenOptions { + /** `aud` claim; omitted entirely when undefined. */ + audience?: string; + /** Seconds from now for `exp`; negative mints an already-expired token. */ + expiresInSeconds?: number; +} + +export interface MockAuthorizationServer { + /** Issuer URL, e.g. `http://127.0.0.1:9797`. */ + issuer: string; + /** Mint an RS256 JWT signed with the key published in the JWKS. */ + signToken(options?: SignTokenOptions): Promise; + /** + * Mint a JWT with the requested claims but signed by a keypair that is + * NOT published in the JWKS (i.e. not issued by any trusted AS). + */ + signTokenWithUntrustedKey(options?: SignTokenOptions): Promise; + close(): Promise; +} + +async function buildSignedJwt( + privateKey: CryptoKey, + kid: string, + issuer: string, + options: SignTokenOptions +): Promise { + const nowSeconds = Math.floor(Date.now() / 1000); + const expiresIn = options.expiresInSeconds ?? 300; + const jwt = new SignJWT({ client_id: 'mcp-conformance-client' }) + .setProtectedHeader({ alg: 'RS256', kid, typ: 'at+jwt' }) + .setIssuer(issuer) + .setSubject('mcp-conformance-user') + .setJti(randomUUID()) + .setIssuedAt(expiresIn < 0 ? nowSeconds + expiresIn - 3600 : nowSeconds) + .setExpirationTime(nowSeconds + expiresIn); + if (options.audience !== undefined) { + jwt.setAudience(options.audience); + } + return jwt.sign(privateKey); +} + +/** + * Start a minimal signing-enabled mock Authorization Server: RFC 8414 + * metadata at `/.well-known/oauth-authorization-server` and the signing + * key's public JWK at `/.well-known/jwks.json`. + */ +export async function startMockAuthorizationServer( + port: number +): Promise { + const kid = 'mcp-conformance-mock-as'; + const { publicKey, privateKey } = await generateKeyPair('RS256', { + extractable: true + }); + const untrustedPair = await generateKeyPair('RS256', { extractable: true }); + + const issuer = `http://127.0.0.1:${port}`; + const publicJwk = await exportJWK(publicKey); + publicJwk.kid = kid; + publicJwk.alg = 'RS256'; + publicJwk.use = 'sig'; + + const metadata = { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/.well-known/jwks.json`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + id_token_signing_alg_values_supported: ['RS256'] + }; + + const server: Server = createServer((req, res) => { + const url = req.url?.split('?')[0]; + if (url === '/.well-known/oauth-authorization-server') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(metadata)); + return; + } + if (url === '/.well-known/jwks.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ keys: [publicJwk] })); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not_found' })); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + // Loopback only: the mock AS serves the local server-under-test and + // must not be reachable from other hosts. + server.listen(port, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + + return { + issuer, + signToken: (options = {}) => + buildSignedJwt(privateKey, kid, issuer, options), + signTokenWithUntrustedKey: (options = {}) => + buildSignedJwt(untrustedPair.privateKey, kid, issuer, options), + close: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ) + }; +} + +/** + * Build a request body any server for the given spec version should accept + * without prior setup (initialize for the stateful lifecycle, server/discover + * with _meta for the stateless lifecycle). Mirrors the DNS-rebinding probe. + */ +function probeBody( + specVersion: SpecVersion, + id: number +): { + jsonrpc: '2.0'; + id: number; + method: string; + params: Record; +} { + const clientInfo = { + name: 'conformance-token-audience-test', + version: '1.0.0' + }; + if (specVersion === DRAFT_PROTOCOL_VERSION) { + return { + jsonrpc: '2.0', + id, + method: 'server/discover', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': specVersion, + 'io.modelcontextprotocol/clientInfo': clientInfo, + 'io.modelcontextprotocol/clientCapabilities': {} + } + } + }; + } + return { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: specVersion, + capabilities: {}, + clientInfo + } + }; +} + +interface ProbeResponse { + status: number; + wwwAuthenticate?: string; + bodySnippet?: string; +} + +let probeRequestId = 1; + +/** + * POST an MCP probe request, optionally with a Bearer token. Only the HTTP + * status matters to this scenario — token validation happens before any MCP + * processing — so the body is read only for diagnostics. + */ +async function sendProbe( + serverUrl: string, + specVersion: SpecVersion, + token?: string +): Promise { + const probe = probeBody(specVersion, probeRequestId++); + const headers = buildStandardHeaders(probe.method, probe.params, { + specVersion, + headers: token !== undefined ? { Authorization: `Bearer ${token}` } : {} + }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + try { + const res = await fetch(serverUrl, { + method: 'POST', + headers, + body: JSON.stringify(probe), + signal: controller.signal + }); + const wwwAuthenticate = res.headers.get('www-authenticate') ?? undefined; + let bodySnippet: string | undefined; + if (res.headers.get('content-type')?.includes('application/json')) { + bodySnippet = (await res.text()).slice(0, 500); + } else { + // SSE or other streaming body: don't wait on it, just drop it. + await res.body?.cancel().catch(() => {}); + } + return { status: res.status, wwwAuthenticate, bodySnippet }; + } finally { + clearTimeout(timeout); + controller.abort(); + } +} + +interface CheckDefinition { + id: string; + name: string; + description: string; + specReferences: { id: string; url: string }[]; +} + +const CHECKS: Record = { + unauthenticated: { + id: 'auth-unauthenticated-request-rejected', + name: 'UnauthenticatedRequestRejected', + description: + 'Server rejects a request without an access token with HTTP 401 ' + + '("MCP servers MUST validate access tokens before processing the request")', + specReferences: [ + SPEC_REFERENCES.privilegeRestriction, + SPEC_REFERENCES.errorHandling + ] + }, + validAccepted: { + id: 'auth-valid-audience-token-accepted', + name: 'ValidAudienceTokenAccepted', + description: + 'Server accepts a baseline valid access token (trusted signature, its ' + + 'own canonical URI as audience, unexpired) — the control that shows ' + + 'the rejection checks below reflect targeted validation per "MCP ' + + 'servers, acting in their role as an OAuth 2.1 resource server, MUST ' + + 'validate access tokens", not blanket rejection', + specReferences: [SPEC_REFERENCES.tokenHandling, SPEC_REFERENCES.rfc8707] + }, + wrongAudience: { + id: 'auth-wrong-audience-token-rejected', + name: 'WrongAudienceTokenRejected', + description: + 'Server rejects with HTTP 401 a validly-signed token whose aud claim ' + + 'names a different resource ("MCP servers MUST validate that access ' + + 'tokens were issued specifically for them as the intended audience")', + specReferences: [ + SPEC_REFERENCES.tokenHandling, + SPEC_REFERENCES.audienceBinding, + SPEC_REFERENCES.rfc8707 + ] + }, + missingAudience: { + id: 'auth-missing-audience-token-rejected', + name: 'MissingAudienceTokenRejected', + description: + 'Server rejects with HTTP 401 a validly-signed token that carries no ' + + 'aud claim ("MCP servers ... MUST reject tokens that do not include ' + + 'them in the audience claim or otherwise verify that they are the ' + + 'intended recipient of the token")', + specReferences: [ + SPEC_REFERENCES.privilegeRestriction, + SPEC_REFERENCES.audienceBinding + ] + }, + expired: { + id: 'auth-expired-token-rejected', + name: 'ExpiredTokenRejected', + description: + 'Server rejects an expired token with HTTP 401 ("Invalid or expired ' + + 'tokens MUST receive a HTTP 401 response")', + specReferences: [SPEC_REFERENCES.tokenHandling] + }, + untrusted: { + id: 'auth-untrusted-token-rejected', + name: 'UntrustedTokenRejected', + description: + 'Server rejects with HTTP 401 a token signed by a key its ' + + 'authorization server never published ("MCP servers ... MUST validate ' + + 'access tokens as described in OAuth 2.1 Section 5.2")', + specReferences: [SPEC_REFERENCES.tokenHandling] + } +}; + +const TOKEN_CHECK_KEYS = [ + 'wrongAudience', + 'missingAudience', + 'expired', + 'untrusted' +] as const; + +export class TokenAudienceValidationScenario implements ClientScenario { + name = 'auth-token-audience-validation'; + readonly source = { introducedIn: '2025-06-18' } as const; + description = `Tests that an OAuth-protected MCP server validates access-token audiences (server-side token audience validation, RFC 8707). + +**Scope:** Authorization is OPTIONAL in MCP. If the server under test accepts an unauthenticated request, it has not enabled authorization and every check reports SKIPPED. + +**Setup contract for servers that enable authorization:** the conformance suite runs a signing-enabled mock Authorization Server at \`http://127.0.0.1:9797\` (override the port with \`MCP_CONFORMANCE_AUTH_SERVER_PORT\`) for the duration of the scenario. It serves RFC 8414 metadata at \`/.well-known/oauth-authorization-server\` and its signing key at \`/.well-known/jwks.json\`. Before running the scenario, launch the server under test configured to: + +1. trust \`http://127.0.0.1:\` as its authorization server (issuer), fetching the JWKS lazily (at token-validation time) rather than at startup, and +2. expect its own URL — exactly the \`--url\` passed to the conformance CLI — as the token audience, and +3. not require particular scopes for \`initialize\` / \`server/discover\`. + +The reference fixture is \`examples/servers/typescript/everything-server.ts\` launched with \`MCP_CONFORMANCE_AUTH_ISSUER\` (and optionally \`MCP_CONFORMANCE_AUTH_AUDIENCE\`) set. Run this scenario as a dedicated auth-enabled launch (e.g. \`conformance server --url ... --scenario auth-token-audience-validation\`): the rest of the default suite sends no Bearer tokens, so it would fail with 401s against an auth-enabled server. + +**Checks (all MUST-level):** +1. Request without a token is rejected with HTTP 401 +2. Valid token (trusted signature, correct \`aud\`, unexpired) is accepted — the baseline that makes checks 3-6 meaningful +3. Token with a different resource in \`aud\` is rejected with HTTP 401 +4. Token with no \`aud\` claim is rejected with HTTP 401 +5. Expired token is rejected with HTTP 401 +6. Token signed by an untrusted key is rejected with HTTP 401 + +If the baseline valid token (check 2) is rejected, checks 3-6 cannot distinguish audience validation from blanket rejection and are reported as not testable (issue #248). + +**Spec:** MCP Authorization (2025-06-18 and later), sections "Token Handling", "Token Audience Binding and Validation", "Access Token Privilege Restriction", "Error Handling"; RFC 8707 Section 2.`; + + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // Gate: authorization is optional. A server that accepts the + // unauthenticated probe has not enabled it — nothing here applies. + let unauthenticated: ProbeResponse; + try { + unauthenticated = await sendProbe(serverUrl, specVersion); + } catch (error) { + return Object.values(CHECKS).map((def) => + untestableCheck( + def.id, + def.name, + def.description, + `Could not reach the server under test: ${ + error instanceof Error ? error.message : String(error) + }`, + def.specReferences + ) + ); + } + + if (unauthenticated.status >= 200 && unauthenticated.status < 300) { + // Legitimately not applicable: optional capability not enabled. + return Object.values(CHECKS).map((def) => ({ + ...def, + status: 'SKIPPED' as const, + timestamp: timestamp(), + details: { + reason: + 'Server accepted an unauthenticated request; authorization ' + + '(optional in MCP) is not enabled, so token-audience ' + + 'requirements do not apply.', + statusCode: unauthenticated.status + } + })); + } + + // Only an authorization challenge (401, or 403 for servers that + // misreport the missing-token case) shows authorization is enabled. + // Any other non-2xx status means the probe failed for unrelated + // reasons (wrong URL, server error, ...) and nothing can be concluded. + if (unauthenticated.status !== 401 && unauthenticated.status !== 403) { + return Object.values(CHECKS).map((def) => + untestableCheck( + def.id, + def.name, + def.description, + `the unauthenticated probe returned HTTP ` + + `${unauthenticated.status}; expected 2xx (authorization not ` + + `enabled) or an authorization challenge (401/403), so it cannot ` + + `be determined whether authorization is enabled`, + def.specReferences + ) + ); + } + + checks.push({ + ...CHECKS.unauthenticated, + status: unauthenticated.status === 401 ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + ...(unauthenticated.status !== 401 && { + errorMessage: + `Expected HTTP 401 for a request without an access token, got ` + + `${unauthenticated.status}` + }), + details: { + statusCode: unauthenticated.status, + wwwAuthenticate: unauthenticated.wwwAuthenticate + } + }); + + let port = DEFAULT_MOCK_AUTH_SERVER_PORT; + const portEnv = process.env.MCP_CONFORMANCE_AUTH_SERVER_PORT; + if (portEnv !== undefined && portEnv !== '') { + const parsed = Number(portEnv); + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) { + port = parsed; + } else { + console.warn( + `Ignoring MCP_CONFORMANCE_AUTH_SERVER_PORT="${portEnv}" (not a ` + + `valid port); using default port ${DEFAULT_MOCK_AUTH_SERVER_PORT}.` + ); + } + } + + let mockAs: MockAuthorizationServer; + try { + mockAs = await startMockAuthorizationServer(port); + } catch (error) { + for (const key of ['validAccepted', ...TOKEN_CHECK_KEYS] as const) { + const def = CHECKS[key]; + checks.push( + untestableCheck( + def.id, + def.name, + def.description, + `Could not start the mock authorization server on port ${port}: ` + + `${error instanceof Error ? error.message : String(error)}. ` + + `Set MCP_CONFORMANCE_AUTH_SERVER_PORT to a free port.`, + def.specReferences + ) + ); + } + return checks; + } + + try { + // Baseline: a valid token — trusted signature, this server's canonical + // URI as audience, unexpired. Its acceptance is what makes the + // rejection checks below evidence of targeted validation rather than + // blanket rejection. + const validToken = await mockAs.signToken({ audience: serverUrl }); + let validResponse: ProbeResponse | undefined; + let validProbeError: unknown; + try { + validResponse = await sendProbe(serverUrl, specVersion, validToken); + } catch (error) { + validProbeError = error; + } + const accepted = + validResponse !== undefined && + validResponse.status >= 200 && + validResponse.status < 300; + + checks.push({ + ...CHECKS.validAccepted, + status: accepted ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + ...(!accepted && { + errorMessage: + validResponse === undefined + ? `Request with the baseline valid token failed: ${ + validProbeError instanceof Error + ? validProbeError.message + : String(validProbeError) + }` + : `Server rejected a valid access token (HTTP ` + + `${validResponse.status}) issued by ${mockAs.issuer} with ` + + `aud="${serverUrl}". If the server is not configured to ` + + `trust the conformance mock authorization server, see the ` + + `scenario description for the setup contract.` + }), + details: { + statusCode: validResponse?.status, + issuer: mockAs.issuer, + audience: serverUrl, + wwwAuthenticate: validResponse?.wwwAuthenticate + } + }); + + if (!accepted) { + // Rejection of the targeted tokens below would be indistinguishable + // from blanket rejection, so the audience checks cannot be exercised. + for (const key of TOKEN_CHECK_KEYS) { + const def = CHECKS[key]; + checks.push( + untestableCheck( + def.id, + def.name, + def.description, + 'the server rejected the baseline valid token, so targeted ' + + 'rejection cannot be distinguished from blanket rejection. ' + + 'Configure the server under test to trust the conformance ' + + 'mock authorization server (see scenario description).', + def.specReferences + ) + ); + } + return checks; + } + + const rejectionCases: { + key: (typeof TOKEN_CHECK_KEYS)[number]; + token: string; + tokenDescription: string; + }[] = [ + { + key: 'wrongAudience', + token: await mockAs.signToken({ + audience: 'https://another-resource.example.com/mcp' + }), + tokenDescription: + 'validly-signed token with aud="https://another-resource.example.com/mcp"' + }, + { + key: 'missingAudience', + token: await mockAs.signToken({}), + tokenDescription: 'validly-signed token with no aud claim' + }, + { + key: 'expired', + token: await mockAs.signToken({ + audience: serverUrl, + expiresInSeconds: -600 + }), + tokenDescription: + 'token with correct audience that expired 10 minutes ago' + }, + { + key: 'untrusted', + token: await mockAs.signTokenWithUntrustedKey({ + audience: serverUrl + }), + tokenDescription: + 'token with correct audience signed by a key absent from the JWKS' + } + ]; + + for (const { key, token, tokenDescription } of rejectionCases) { + const def = CHECKS[key]; + let response: ProbeResponse; + try { + response = await sendProbe(serverUrl, specVersion, token); + } catch (error) { + // A timeout or connection reset on one probe must not throw away + // the checks already collected (mirrors the DNS-rebinding pattern). + checks.push({ + ...def, + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: `Request with a ${tokenDescription} failed: ${ + error instanceof Error ? error.message : String(error) + }`, + details: { token: tokenDescription } + }); + continue; + } + const rejectedWith401 = response.status === 401; + + checks.push({ + ...def, + status: rejectedWith401 ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + ...(!rejectedWith401 && { + errorMessage: + response.status >= 200 && response.status < 300 + ? `Server accepted a ${tokenDescription} (HTTP ` + + `${response.status}); it MUST be rejected` + : `Server rejected a ${tokenDescription} with HTTP ` + + `${response.status}; invalid or expired tokens MUST ` + + `receive a HTTP 401 response` + }), + details: { + statusCode: response.status, + token: tokenDescription, + wwwAuthenticate: response.wwwAuthenticate + } + }); + } + } finally { + await mockAs.close(); + } + + return checks; + } +}