Add lazy-auth example server mounted at /lazy-auth
Mounts @modelcontextprotocol/server-lazy-auth as an additional example, served at /lazy-auth/mcp alongside the other example MCP App servers. Unlike the other examples (plain createServer() factories behind the stateless handler), lazy-auth's value is its HTTP layer: public tools work unauthenticated, protected tools answer 401 + WWW-Authenticate, and a built-in mock OAuth authorization server completes the flow. So it mounts its full Express app (createApp()) under /lazy-auth. - New src/modules/example-apps/lazy-auth.ts mounts the app before the host middleware and adds the RFC 8414/9728 path-insertion well-known rewrite (/.well-known/oauth-authorization-server/lazy-auth -> into the mount) that SDK clients require for discovery under a base path. - PUBLIC_URL for the example is derived from this server's own BASE_URI, so no extra deploy-time configuration is needed; an explicit PUBLIC_URL still wins (e.g. a tunnel). - Integration test covers the full flow: public initialize/tools/list, 401 on the protected tool, mount-prefixed discovery metadata, and the PKCE -> token -> authed get_secret round trip.
This commit is contained in:
parent
8b919a9d22
commit
2476540fab
4 changed files with 240 additions and 2 deletions
|
|
@ -20,6 +20,7 @@ import { config } from './config.js';
|
||||||
import { AuthModule } from './modules/auth/index.js';
|
import { AuthModule } from './modules/auth/index.js';
|
||||||
import { MCPModule } from './modules/mcp/index.js';
|
import { MCPModule } from './modules/mcp/index.js';
|
||||||
import { ExampleAppsModule, AVAILABLE_EXAMPLES } from './modules/example-apps/index.js';
|
import { ExampleAppsModule, AVAILABLE_EXAMPLES } from './modules/example-apps/index.js';
|
||||||
|
import { mountLazyAuthExample } from './modules/example-apps/lazy-auth.js';
|
||||||
import { ExternalTokenValidator, InternalTokenValidator, ITokenValidator } from './interfaces/auth-validator.js';
|
import { ExternalTokenValidator, InternalTokenValidator, ITokenValidator } from './interfaces/auth-validator.js';
|
||||||
import { redisClient } from './modules/shared/redis.js';
|
import { redisClient } from './modules/shared/redis.js';
|
||||||
import { logger } from './modules/shared/logger.js';
|
import { logger } from './modules/shared/logger.js';
|
||||||
|
|
@ -48,6 +49,13 @@ async function main() {
|
||||||
// This is required for rate limiting to work correctly with real client IPs
|
// This is required for rate limiting to work correctly with real client IPs
|
||||||
app.set('trust proxy', true);
|
app.set('trust proxy', true);
|
||||||
|
|
||||||
|
// Lazy-auth MCP App example: a full Express app with its own mock OAuth
|
||||||
|
// authorization server, mounted at /lazy-auth. Registered before the host
|
||||||
|
// middleware so its CORS and body handling stay self-contained.
|
||||||
|
if (config.auth.mode !== 'auth_server') {
|
||||||
|
mountLazyAuthExample(app, config.baseUri);
|
||||||
|
}
|
||||||
|
|
||||||
// Basic middleware
|
// Basic middleware
|
||||||
// Intentionally permissive CORS for public MCP reference server
|
// Intentionally permissive CORS for public MCP reference server
|
||||||
// This allows any MCP client to test against this reference implementation
|
// This allows any MCP client to test against this reference implementation
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import { createServer as createTranscriptServer } from '@modelcontextprotocol/se
|
||||||
import { createServer as createVideoResourceServer } from '@modelcontextprotocol/server-video-resource';
|
import { createServer as createVideoResourceServer } from '@modelcontextprotocol/server-video-resource';
|
||||||
import { createServer as createWikiExplorerServer } from '@modelcontextprotocol/server-wiki-explorer';
|
import { createServer as createWikiExplorerServer } from '@modelcontextprotocol/server-wiki-explorer';
|
||||||
|
|
||||||
|
import { LAZY_AUTH_SLUG } from './lazy-auth.js';
|
||||||
|
|
||||||
declare module "express-serve-static-core" {
|
declare module "express-serve-static-core" {
|
||||||
interface Request {
|
interface Request {
|
||||||
auth?: AuthInfo;
|
auth?: AuthInfo;
|
||||||
|
|
@ -161,5 +163,8 @@ export class ExampleAppsModule {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export list of available examples for documentation
|
// Export list of available examples for documentation. The lazy-auth example
|
||||||
export const AVAILABLE_EXAMPLES = Object.keys(EXAMPLE_SERVERS);
|
// is served at the same /:slug/mcp path shape, but as a full Express app with
|
||||||
|
// its own OAuth endpoints rather than through the stateless handler above
|
||||||
|
// (see ./lazy-auth.ts).
|
||||||
|
export const AVAILABLE_EXAMPLES = [...Object.keys(EXAMPLE_SERVERS), LAZY_AUTH_SLUG];
|
||||||
|
|
|
||||||
174
src/modules/example-apps/lazy-auth.integration.test.ts
Normal file
174
src/modules/example-apps/lazy-auth.integration.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
/**
|
||||||
|
* Integration tests for the lazy-auth example mount.
|
||||||
|
*
|
||||||
|
* Verifies that the @modelcontextprotocol/server-lazy-auth app, mounted at
|
||||||
|
* /lazy-auth, advertises URLs under the mount path, that RFC 8414/9728
|
||||||
|
* path-insertion well-known URLs at the host root reach the example, that the
|
||||||
|
* host's own root OAuth endpoints are untouched, and that the full lazy-auth
|
||||||
|
* flow (401 -> discovery -> PKCE -> token -> authed tool call) works
|
||||||
|
* end-to-end over HTTP.
|
||||||
|
*/
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import http from 'http';
|
||||||
|
import express from 'express';
|
||||||
|
import { AddressInfo } from 'net';
|
||||||
|
import { mountLazyAuthExample } from './lazy-auth.js';
|
||||||
|
|
||||||
|
interface HttpResult {
|
||||||
|
status: number;
|
||||||
|
headers: http.IncomingHttpHeaders;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(url: string, options: http.RequestOptions = {}, body?: string): Promise<HttpResult> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(url, options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data }));
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (body !== undefined) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function callTool(base: string, name: string, accessToken?: string): Promise<HttpResult> {
|
||||||
|
return request(
|
||||||
|
`${base}/lazy-auth/mcp`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json, text/event-stream',
|
||||||
|
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: {} } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Lazy Auth example mount', () => {
|
||||||
|
let server: http.Server;
|
||||||
|
let base: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const app = express();
|
||||||
|
// Mirror src/index.ts: the example mounts before the host's middleware
|
||||||
|
// and routes.
|
||||||
|
mountLazyAuthExample(app);
|
||||||
|
app.use(express.json());
|
||||||
|
// Sentinels for the host's own root OAuth surface, which the example's
|
||||||
|
// well-known rewrite must not shadow.
|
||||||
|
app.get('/.well-known/oauth-authorization-server', (_req, res) => {
|
||||||
|
res.json({ issuer: 'HOST-OWN-AS' });
|
||||||
|
});
|
||||||
|
app.get('/authorize', (_req, res) => {
|
||||||
|
res.send('HOST-OWN-AUTHORIZE');
|
||||||
|
});
|
||||||
|
|
||||||
|
server = await new Promise((resolve) => {
|
||||||
|
const s = app.listen(0, () => resolve(s));
|
||||||
|
});
|
||||||
|
base = `http://localhost:${(server.address() as AddressInfo).port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves public MCP requests without auth', async () => {
|
||||||
|
const res = await request(
|
||||||
|
`${base}/lazy-auth/mcp`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||||
|
},
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '0' } },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toContain('Lazy Auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 401 with resource_metadata under the mount path for protected tools', async () => {
|
||||||
|
const res = await callTool(base, 'get_secret');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.headers['www-authenticate']).toContain(`resource_metadata="${base}/lazy-auth/auth/prm"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advertises mount-prefixed URLs in PRM and AS metadata', async () => {
|
||||||
|
const prm = JSON.parse((await request(`${base}/lazy-auth/auth/prm`)).body);
|
||||||
|
expect(prm.resource).toBe(`${base}/lazy-auth/mcp`);
|
||||||
|
expect(prm.authorization_servers).toEqual([`${base}/lazy-auth`]);
|
||||||
|
|
||||||
|
// RFC 8414 path-insertion form at the host root (the only form MCP SDK
|
||||||
|
// clients try for an issuer with a path).
|
||||||
|
const asRes = await request(`${base}/.well-known/oauth-authorization-server/lazy-auth`);
|
||||||
|
expect(asRes.status).toBe(200);
|
||||||
|
const as = JSON.parse(asRes.body);
|
||||||
|
expect(as.issuer).toBe(`${base}/lazy-auth`);
|
||||||
|
expect(as.authorization_endpoint).toBe(`${base}/lazy-auth/authorize`);
|
||||||
|
expect(as.token_endpoint).toBe(`${base}/lazy-auth/token`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves TTL-scoped PRM through the path-insertion form', async () => {
|
||||||
|
const res = await request(`${base}/.well-known/oauth-protected-resource/lazy-auth/ttl/3600/mcp`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).resource).toBe(`${base}/lazy-auth/ttl/3600/mcp`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the host root OAuth surface untouched', async () => {
|
||||||
|
const as = await request(`${base}/.well-known/oauth-authorization-server`);
|
||||||
|
expect(JSON.parse(as.body).issuer).toBe('HOST-OWN-AS');
|
||||||
|
const authorize = await request(`${base}/authorize`);
|
||||||
|
expect(authorize.body).toBe('HOST-OWN-AUTHORIZE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes the full lazy-auth flow: PKCE -> token -> authed tool call', async () => {
|
||||||
|
const verifier = 'v'.repeat(43);
|
||||||
|
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||||
|
|
||||||
|
const authorizeUrl = new URL(`${base}/lazy-auth/authorize`);
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
client_id: 'test-client',
|
||||||
|
redirect_uri: 'http://localhost:1234/callback',
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state: 'test-state',
|
||||||
|
approved: '1',
|
||||||
|
resource: `${base}/lazy-auth/mcp`,
|
||||||
|
};
|
||||||
|
for (const [k, v] of Object.entries(params)) authorizeUrl.searchParams.set(k, v);
|
||||||
|
|
||||||
|
const authorizeRes = await request(authorizeUrl.href);
|
||||||
|
expect(authorizeRes.status).toBe(302);
|
||||||
|
const redirect = new URL(authorizeRes.headers.location!);
|
||||||
|
const code = redirect.searchParams.get('code')!;
|
||||||
|
expect(code).toBeTruthy();
|
||||||
|
expect(redirect.searchParams.get('state')).toBe('test-state');
|
||||||
|
|
||||||
|
const tokenRes = await request(
|
||||||
|
`${base}/lazy-auth/token`,
|
||||||
|
{ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' } },
|
||||||
|
new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
resource: `${base}/lazy-auth/mcp`,
|
||||||
|
}).toString()
|
||||||
|
);
|
||||||
|
expect(tokenRes.status).toBe(200);
|
||||||
|
const token = JSON.parse(tokenRes.body);
|
||||||
|
expect(token.access_token).toBeTruthy();
|
||||||
|
expect(token.refresh_token).toBeTruthy();
|
||||||
|
|
||||||
|
const secretRes = await callTool(base, 'get_secret', token.access_token);
|
||||||
|
expect(secretRes.status).toBe(200);
|
||||||
|
expect(secretRes.body).toContain('the-answer-is-42');
|
||||||
|
});
|
||||||
|
});
|
||||||
51
src/modules/example-apps/lazy-auth.ts
Normal file
51
src/modules/example-apps/lazy-auth.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* Lazy Auth Example - Mounts @modelcontextprotocol/server-lazy-auth at /lazy-auth
|
||||||
|
*
|
||||||
|
* Unlike the other example servers (plain createServer() factories in
|
||||||
|
* ./index.ts), the lazy-auth example's whole point is its HTTP layer: public
|
||||||
|
* tools work unauthenticated, protected tools answer 401 + WWW-Authenticate,
|
||||||
|
* and a built-in mock OAuth authorization server completes the flow. So we
|
||||||
|
* mount its full Express app under /lazy-auth instead of adding it to the
|
||||||
|
* generic stateless handler.
|
||||||
|
*
|
||||||
|
* Call mountLazyAuthExample() BEFORE registering the host's own middleware
|
||||||
|
* (CORS, body parsing, logging) so the example's responses stay fully
|
||||||
|
* self-contained, and before any other /.well-known routes.
|
||||||
|
*
|
||||||
|
* The example advertises absolute OAuth URLs (issuer, authorize/token
|
||||||
|
* endpoints, PRM resource, WWW-Authenticate resource_metadata). It resolves
|
||||||
|
* them from PUBLIC_URL, falling back to the request Host header for loopback
|
||||||
|
* hosts only. We derive PUBLIC_URL from this server's own public base URI -
|
||||||
|
* already the source of truth for the host's OAuth issuer - so deployments
|
||||||
|
* need no separate env var. An explicit PUBLIC_URL still wins (e.g. a tunnel);
|
||||||
|
* omitting baseUri (e.g. tests on an ephemeral port) keeps the package's
|
||||||
|
* per-request Host resolution.
|
||||||
|
*/
|
||||||
|
import { Express, Request, Response, NextFunction } from 'express';
|
||||||
|
import { createApp as createLazyAuthApp } from '@modelcontextprotocol/server-lazy-auth';
|
||||||
|
|
||||||
|
export const LAZY_AUTH_SLUG = 'lazy-auth';
|
||||||
|
|
||||||
|
export function mountLazyAuthExample(app: Express, baseUri?: string): void {
|
||||||
|
if (baseUri && !process.env.PUBLIC_URL) {
|
||||||
|
process.env.PUBLIC_URL = `${baseUri.replace(/\/+$/, '')}/${LAZY_AUTH_SLUG}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 8414/9728 place well-known discovery documents at the origin root,
|
||||||
|
// with the resource path inserted after the well-known prefix
|
||||||
|
// (e.g. /.well-known/oauth-authorization-server/lazy-auth), and MCP SDK
|
||||||
|
// clients only try that insertion form. Rewrite those root paths into the
|
||||||
|
// mount - rather than dispatching to the sub-app directly - so req.baseUrl
|
||||||
|
// (and therefore every URL the example advertises) stays consistent.
|
||||||
|
app.use((req: Request, _res: Response, next: NextFunction) => {
|
||||||
|
const wellKnown = req.url.match(
|
||||||
|
/^\/\.well-known\/(oauth-authorization-server|oauth-protected-resource)\/lazy-auth(\/.*)?$/
|
||||||
|
);
|
||||||
|
if (wellKnown) {
|
||||||
|
req.url = `/${LAZY_AUTH_SLUG}/.well-known/${wellKnown[1]}${wellKnown[2] ?? ''}`;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(`/${LAZY_AUTH_SLUG}`, createLazyAuthApp());
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue