Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions examples/servers/typescript/auth-no-audience-validation.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createRemoteJWKSet> | 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' }
});
}
});
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

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}`);
});
89 changes: 89 additions & 0 deletions examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof createRemoteJWKSet> | 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.
Expand Down
22 changes: 12 additions & 10 deletions examples/servers/typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions examples/servers/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
15 changes: 8 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/scenarios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading