feat(express): restore Resource-Server auth glue (requireBearerAuth, mcpAuthMetadataRouter) (#1907)
This commit is contained in:
parent
2a7611d46b
commit
7cccc2aca8
20 changed files with 693 additions and 184 deletions
5
.changeset/express-resource-server-auth.md
Normal file
5
.changeset/express-resource-server-auth.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@modelcontextprotocol/express': minor
|
||||
---
|
||||
|
||||
Add OAuth Resource-Server glue to the Express adapter: `requireBearerAuth` middleware (token verification + RFC 6750 `WWW-Authenticate` challenges), `mcpAuthMetadataRouter` (serves RFC 9728 Protected Resource Metadata and mirrors RFC 8414 AS metadata at the resource origin), the `getOAuthProtectedResourceMetadataUrl` helper, and the `OAuthTokenVerifier` interface. These restore the v1 `src/server/auth` Resource-Server pieces as first-class v2 API so MCP servers can plug into an external Authorization Server with a few lines of Express wiring.
|
||||
|
|
@ -69,9 +69,9 @@ For production use, you can either:
|
|||
|
||||
The [server quickstart](./server-quickstart.md) walks you through building a weather server from scratch. Its complete source lives in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/). For more advanced examples (OAuth, streaming, sessions, etc.), see the server examples index in [`examples/server/README.md`](../examples/server/README.md).
|
||||
|
||||
### Why did we remove `server` auth exports?
|
||||
### Where are the server auth helpers?
|
||||
|
||||
Server authentication & authorization is outside of the scope of the SDK, and the recommendation is to use packages that focus on this area specifically (or a full-fledged Authorization Server for those who use such). Example packages provide an example with `better-auth`.
|
||||
Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, etc.) have been removed from the core SDK; new code should use a dedicated IdP/OAuth library. Example packages provide a demo with `better-auth`.
|
||||
|
||||
### Why did we remove `server` SSE transport?
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table.
|
|||
| `@modelcontextprotocol/sdk/server/stdio.js` | `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/server/streamableHttp.js` | `@modelcontextprotocol/node` (class renamed to `NodeStreamableHTTPServerTransport`) OR `@modelcontextprotocol/server` (web-standard `WebStandardStreamableHTTPServerTransport` for Cloudflare Workers, Deno, etc.) |
|
||||
| `@modelcontextprotocol/sdk/server/sse.js` | REMOVED (migrate to Streamable HTTP) |
|
||||
| `@modelcontextprotocol/sdk/server/auth/*` | REMOVED (use external auth library) |
|
||||
| `@modelcontextprotocol/sdk/server/auth/*` | RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`; AS helpers removed (use external IdP/OAuth library) |
|
||||
| `@modelcontextprotocol/sdk/server/middleware.js` | `@modelcontextprotocol/express` (signature changed, see section 8) |
|
||||
|
||||
### Types / shared imports
|
||||
|
|
@ -319,8 +319,7 @@ new URL(ctx.http?.req?.url).searchParams.get('debug')
|
|||
|
||||
### Server-side auth
|
||||
|
||||
All server OAuth exports removed: `mcpAuthRouter`, `OAuthServerProvider`, `OAuthTokenVerifier`, `requireBearerAuth`, `authenticateClient`, `ProxyOAuthServerProvider`, `allowedMethods`, and associated types. Use an external auth library (e.g., `better-auth`). See
|
||||
`examples/server/src/` for demos.
|
||||
Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) are removed from the core SDK; use an external IdP/OAuth library. See `examples/server/src/` for demos.
|
||||
|
||||
### Host header validation (Express)
|
||||
|
||||
|
|
@ -502,6 +501,6 @@ Access validators explicitly:
|
|||
6. Replace plain header objects with `new Headers({...})` and bracket access (`headers['x']`) with `.get()` calls per section 7
|
||||
7. If using `hostHeaderValidation` from server, update import and signature per section 8
|
||||
8. If using server SSE transport, migrate to Streamable HTTP
|
||||
9. If using server auth from the SDK, migrate to an external auth library
|
||||
9. If using server auth from the SDK: RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`) → `@modelcontextprotocol/express`; AS helpers → external IdP/OAuth library
|
||||
10. If relying on `listTools()`/`listPrompts()`/etc. throwing on missing capabilities, set `enforceStrictCapabilities: true`
|
||||
11. Verify: build with `tsc` / run tests
|
||||
|
|
|
|||
|
|
@ -130,11 +130,11 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
|||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'));
|
||||
```
|
||||
|
||||
### Server auth removed
|
||||
### Server auth split
|
||||
|
||||
Server-side OAuth/auth has been removed entirely from the SDK. This includes `mcpAuthRouter`, `OAuthServerProvider`, `OAuthTokenVerifier`, `requireBearerAuth`, `authenticateClient`, `ProxyOAuthServerProvider`, `allowedMethods`, and all associated types.
|
||||
Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are now first-class in `@modelcontextprotocol/express`.
|
||||
|
||||
Use a dedicated auth library (e.g., `better-auth`) or a full Authorization Server instead. See the [examples](../examples/server/src/) for a working demo with `better-auth`.
|
||||
Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) have been removed from the core SDK; new code should use a dedicated IdP/OAuth library. See the [examples](../examples/server/src/) for a working demo with `better-auth`.
|
||||
|
||||
Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`.
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ pnpm tsx src/simpleStreamableHttp.ts
|
|||
| ----------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, tasks, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
|
||||
| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) |
|
||||
| Resource-Server-only auth | Minimal OAuth RS using SDK's `mcpAuthMetadataRouter` + `requireBearerAuth` (no better-auth). | [`src/resourceServerOnly.ts`](src/resourceServerOnly.ts) |
|
||||
| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) |
|
||||
| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) |
|
||||
| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) |
|
||||
|
|
@ -43,7 +44,6 @@ pnpm tsx src/simpleStreamableHttp.ts
|
|||
|
||||
```bash
|
||||
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth
|
||||
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth --oauth-strict
|
||||
```
|
||||
|
||||
## URL elicitation example (server + client)
|
||||
|
|
|
|||
|
|
@ -9,13 +9,8 @@
|
|||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
createProtectedResourceMetadataRouter,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
requireBearerAuth,
|
||||
setupAuthServer
|
||||
} from '@modelcontextprotocol/examples-shared';
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared';
|
||||
import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type { CallToolResult, ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/server';
|
||||
import { isInitializeRequest, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server';
|
||||
|
|
@ -235,7 +230,7 @@ let authMiddleware = null;
|
|||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
|
||||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
|
||||
|
||||
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true, demoMode: true });
|
||||
setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true });
|
||||
|
||||
// Add protected resource metadata route to the MCP server
|
||||
// This allows clients to discover the auth server
|
||||
|
|
@ -243,10 +238,9 @@ setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true, demoMode: t
|
|||
app.use(createProtectedResourceMetadataRouter('/mcp'));
|
||||
|
||||
authMiddleware = requireBearerAuth({
|
||||
verifier: demoTokenVerifier,
|
||||
requiredScopes: [],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
|
||||
strictResource: true,
|
||||
expectedResource: mcpServerUrl
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
87
examples/server/src/resourceServerOnly.ts
Normal file
87
examples/server/src/resourceServerOnly.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Minimal Resource-Server-only auth using the SDK's RS helpers
|
||||
* (`mcpAuthMetadataRouter`, `requireBearerAuth`, `OAuthTokenVerifier`).
|
||||
*
|
||||
* No better-auth. The Authorization Server is external; this example points
|
||||
* its metadata at a placeholder issuer. For a full AS+RS setup with a real
|
||||
* demo Authorization Server, see {@link ./simpleStreamableHttp.ts}.
|
||||
*
|
||||
* Run: pnpm tsx src/resourceServerOnly.ts
|
||||
* Probe: curl http://localhost:3000/.well-known/oauth-protected-resource/mcp
|
||||
* curl -H 'Authorization: Bearer demo-token' -X POST http://localhost:3000/mcp ...
|
||||
*/
|
||||
|
||||
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
|
||||
import {
|
||||
createMcpExpressApp,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
mcpAuthMetadataRouter,
|
||||
requireBearerAuth
|
||||
} from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type { AuthInfo, CallToolResult, OAuthMetadata } from '@modelcontextprotocol/server';
|
||||
import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
||||
import type { Request, Response } from 'express';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const PORT = 3000;
|
||||
const mcpServerUrl = new URL(`http://localhost:${PORT}/mcp`);
|
||||
|
||||
// In a real deployment this is your external Authorization Server's metadata
|
||||
// (RFC 8414). The SDK router serves it verbatim at
|
||||
// /.well-known/oauth-authorization-server so clients probing the RS origin
|
||||
// can still discover the AS.
|
||||
const oauthMetadata: OAuthMetadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code']
|
||||
};
|
||||
|
||||
// Replace with JWT verification, RFC 7662 introspection, etc.
|
||||
const staticTokenVerifier: OAuthTokenVerifier = {
|
||||
async verifyAccessToken(token): Promise<AuthInfo> {
|
||||
if (token !== 'demo-token') {
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token');
|
||||
}
|
||||
return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 };
|
||||
}
|
||||
};
|
||||
|
||||
const server = new McpServer({ name: 'rs-only', version: '1.0.0' }, { capabilities: {} });
|
||||
server.registerTool(
|
||||
'whoami',
|
||||
{ description: 'Returns the authenticated subject.', inputSchema: z.object({}) },
|
||||
async (_args, ctx): Promise<CallToolResult> => ({
|
||||
content: [{ type: 'text', text: `client=${ctx.http?.authInfo?.clientId ?? 'anon'}` }]
|
||||
})
|
||||
);
|
||||
|
||||
const app = createMcpExpressApp();
|
||||
|
||||
app.use(
|
||||
mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: mcpServerUrl,
|
||||
resourceName: 'RS-only example'
|
||||
})
|
||||
);
|
||||
|
||||
const auth = requireBearerAuth({
|
||||
verifier: staticTokenVerifier,
|
||||
requiredScopes: ['mcp'],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
});
|
||||
|
||||
app.post('/mcp', auth, async (req: Request, res: Response) => {
|
||||
const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
res.on('close', () => void transport.close());
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`RS-only MCP server on http://localhost:${PORT}/mcp`);
|
||||
console.log(` PRM: ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`);
|
||||
console.log(` AS metadata mirror: http://localhost:${PORT}/.well-known/oauth-authorization-server`);
|
||||
});
|
||||
|
|
@ -1,12 +1,7 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
createProtectedResourceMetadataRouter,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
requireBearerAuth,
|
||||
setupAuthServer
|
||||
} from '@modelcontextprotocol/examples-shared';
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared';
|
||||
import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type {
|
||||
CallToolResult,
|
||||
|
|
@ -25,7 +20,6 @@ import { InMemoryEventStore } from './inMemoryEventStore.js';
|
|||
|
||||
// Check for OAuth flag
|
||||
const useOAuth = process.argv.includes('--oauth');
|
||||
const strictOAuth = process.argv.includes('--oauth-strict');
|
||||
const dangerousLoggingEnabled = process.argv.includes('--dangerous-logging-enabled');
|
||||
|
||||
// Create shared task store for demonstration
|
||||
|
|
@ -624,7 +618,7 @@ if (useOAuth) {
|
|||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
|
||||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
|
||||
|
||||
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth, demoMode: true, dangerousLoggingEnabled });
|
||||
setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, dangerousLoggingEnabled });
|
||||
|
||||
// Add protected resource metadata route to the MCP server
|
||||
// This allows clients to discover the auth server
|
||||
|
|
@ -632,10 +626,9 @@ if (useOAuth) {
|
|||
app.use(createProtectedResourceMetadataRouter('/mcp'));
|
||||
|
||||
authMiddleware = requireBearerAuth({
|
||||
verifier: demoTokenVerifier,
|
||||
requiredScopes: [],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
|
||||
strictResource: strictOAuth,
|
||||
expectedResource: mcpServerUrl
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -651,8 +644,8 @@ const mcpPostHandler = async (req: Request, res: Response) => {
|
|||
console.log('Request body:', req.body);
|
||||
}
|
||||
|
||||
if (useOAuth && req.app.locals.auth) {
|
||||
console.log('Authenticated user:', req.app.locals.auth);
|
||||
if (useOAuth && req.auth) {
|
||||
console.log('Authenticated user:', req.auth);
|
||||
}
|
||||
try {
|
||||
let transport: NodeStreamableHTTPServerTransport;
|
||||
|
|
@ -742,8 +735,8 @@ const mcpGetHandler = async (req: Request, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (useOAuth && req.app.locals.auth) {
|
||||
console.log('Authenticated SSE connection from user:', req.app.locals.auth);
|
||||
if (useOAuth && req.auth) {
|
||||
console.log('Authenticated SSE connection from user:', req.auth);
|
||||
}
|
||||
|
||||
// Check for Last-Event-ID header for resumability
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
/**
|
||||
* Auth Middleware for MCP Demo Servers
|
||||
*
|
||||
* 🚨 DEMO ONLY - NOT FOR PRODUCTION
|
||||
*
|
||||
* This provides bearer auth middleware for MCP servers.
|
||||
*/
|
||||
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { verifyAccessToken } from './authServer.js';
|
||||
|
||||
export interface RequireBearerAuthOptions {
|
||||
requiredScopes?: string[];
|
||||
resourceMetadataUrl?: URL;
|
||||
strictResource?: boolean;
|
||||
expectedResource?: URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware that requires a valid Bearer token.
|
||||
* Sets `req.app.locals.auth` on success.
|
||||
*/
|
||||
export function requireBearerAuth(
|
||||
options: RequireBearerAuthOptions = {}
|
||||
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
|
||||
const { requiredScopes = [], resourceMetadataUrl, strictResource = false, expectedResource } = options;
|
||||
|
||||
// Build WWW-Authenticate header matching v1.x format
|
||||
const buildWwwAuthHeader = (errorCode: string, message: string): string => {
|
||||
let header = `Bearer error="${errorCode}", error_description="${message}"`;
|
||||
if (requiredScopes.length > 0) {
|
||||
header += `, scope="${requiredScopes.join(' ')}"`;
|
||||
}
|
||||
if (resourceMetadataUrl) {
|
||||
header += `, resource_metadata="${resourceMetadataUrl.toString()}"`;
|
||||
}
|
||||
return header;
|
||||
};
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', 'Missing Authorization header'));
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Missing Authorization header'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7); // Remove 'Bearer ' prefix
|
||||
|
||||
try {
|
||||
const authInfo = await verifyAccessToken(token, {
|
||||
strictResource,
|
||||
expectedResource
|
||||
});
|
||||
|
||||
// Check required scopes
|
||||
if (requiredScopes.length > 0) {
|
||||
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
|
||||
if (!hasAllScopes) {
|
||||
res.set('WWW-Authenticate', buildWwwAuthHeader('insufficient_scope', `Required scopes: ${requiredScopes.join(', ')}`));
|
||||
res.status(403).json({
|
||||
error: 'insufficient_scope',
|
||||
error_description: `Required scopes: ${requiredScopes.join(', ')}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
req.app.locals.auth = authInfo;
|
||||
next();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid token';
|
||||
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', message));
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: message
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the protected resource metadata URL from a server URL.
|
||||
*/
|
||||
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): URL {
|
||||
const metadataUrl = new URL(serverUrl);
|
||||
// Insert well-known between host and path per RFC 9728 Section 3
|
||||
metadataUrl.pathname = `/.well-known/oauth-protected-resource${serverUrl.pathname}`;
|
||||
return metadataUrl;
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@
|
|||
* See: https://www.better-auth.com/docs/plugins/mcp
|
||||
*/
|
||||
|
||||
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
|
||||
import type { AuthInfo } from '@modelcontextprotocol/server';
|
||||
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
||||
import { toNodeHandler } from 'better-auth/node';
|
||||
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins';
|
||||
import cors from 'cors';
|
||||
|
|
@ -21,7 +24,6 @@ import { createDemoAuth, DEMO_USER_CREDENTIALS } from './auth.js';
|
|||
export interface SetupAuthServerOptions {
|
||||
authServerUrl: URL;
|
||||
mcpServerUrl: URL;
|
||||
strictResource?: boolean;
|
||||
/**
|
||||
* Examples should be used for **demo** only and not for production purposes, however this mode disables some logging and other features.
|
||||
*/
|
||||
|
|
@ -284,60 +286,29 @@ export function createProtectedResourceMetadataRouter(resourcePath = '/mcp'): Ro
|
|||
}
|
||||
|
||||
/**
|
||||
* Verifies an access token using better-auth's getMcpSession.
|
||||
* This can be used by MCP servers to validate tokens.
|
||||
* Demo {@link OAuthTokenVerifier} backed by better-auth's `getMcpSession`.
|
||||
* Pass this to `requireBearerAuth({ verifier: demoTokenVerifier, ... })` from
|
||||
* `@modelcontextprotocol/express` to validate Bearer tokens against the demo
|
||||
* Authorization Server started by `setupAuthServer`.
|
||||
*/
|
||||
export async function verifyAccessToken(
|
||||
token: string,
|
||||
options?: { strictResource?: boolean; expectedResource?: URL }
|
||||
): Promise<{
|
||||
token: string;
|
||||
clientId: string;
|
||||
scopes: string[];
|
||||
expiresAt: number;
|
||||
}> {
|
||||
const auth = getAuth();
|
||||
export const demoTokenVerifier: OAuthTokenVerifier = {
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
const auth = getAuth();
|
||||
|
||||
try {
|
||||
// Create a mock request with the Authorization header
|
||||
const headers = new Headers();
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
// Use better-auth's getMcpSession API
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const session = await (auth.api as any).getMcpSession({
|
||||
headers
|
||||
});
|
||||
|
||||
const session = await (auth.api as any).getMcpSession({ headers });
|
||||
if (!session) {
|
||||
throw new Error('Invalid token');
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Invalid token');
|
||||
}
|
||||
|
||||
// OAuthAccessToken has:
|
||||
// - accessToken, refreshToken: string
|
||||
// - accessTokenExpiresAt, refreshTokenExpiresAt: Date
|
||||
// - clientId, userId: string
|
||||
// - scopes: string (space-separated)
|
||||
const scopes = typeof session.scopes === 'string' ? session.scopes.split(' ') : ['openid'];
|
||||
const expiresAt = session.accessTokenExpiresAt
|
||||
? Math.floor(new Date(session.accessTokenExpiresAt).getTime() / 1000)
|
||||
: Math.floor(Date.now() / 1000) + 3600;
|
||||
|
||||
// Note: better-auth's OAuthAccessToken doesn't have a resource field
|
||||
// Resource validation would need to be done at a different layer
|
||||
if (options?.strictResource && options.expectedResource) {
|
||||
// For now, we skip resource validation as it's not in the session
|
||||
// In production, you'd store and validate this separately
|
||||
console.warn('[Auth] Resource validation requested but not available in better-auth session');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
clientId: session.clientId,
|
||||
scopes,
|
||||
expiresAt
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Token verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return { token, clientId: session.clientId, scopes, expiresAt };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@
|
|||
export type { CreateDemoAuthOptions, DemoAuth } from './auth.js';
|
||||
export { createDemoAuth } from './auth.js';
|
||||
|
||||
// Auth middleware
|
||||
export type { RequireBearerAuthOptions } from './authMiddleware.js';
|
||||
export { getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from './authMiddleware.js';
|
||||
|
||||
// Auth server setup
|
||||
// Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express)
|
||||
export type { SetupAuthServerOptions } from './authServer.js';
|
||||
export { createProtectedResourceMetadataRouter, getAuth, setupAuthServer, verifyAccessToken } from './authServer.js';
|
||||
export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer.js';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"paths": {
|
||||
"*": ["./*"],
|
||||
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
|
||||
"@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"],
|
||||
"@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"],
|
||||
"@modelcontextprotocol/core": [
|
||||
"./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ It does **not** implement MCP itself. Instead, it helps you:
|
|||
|
||||
- create an Express app with sensible defaults for MCP servers
|
||||
- add DNS rebinding protection via Host header validation (recommended for localhost servers)
|
||||
- protect routes with `requireBearerAuth` (validates `Authorization: Bearer …` via your `OAuthTokenVerifier`)
|
||||
- serve OAuth Protected Resource Metadata (RFC 9728) via `mcpAuthMetadataRouter`
|
||||
|
||||
## Install
|
||||
|
||||
|
|
@ -23,6 +25,10 @@ npm install @modelcontextprotocol/node
|
|||
- `createMcpExpressApp(options?)`
|
||||
- `hostHeaderValidation(allowedHostnames)`
|
||||
- `localhostHostValidation()`
|
||||
- `requireBearerAuth(options)`
|
||||
- `mcpAuthMetadataRouter(options)`
|
||||
- `getOAuthProtectedResourceMetadataUrl(serverUrl)`
|
||||
- `OAuthTokenVerifier` (interface)
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
|
|||
|
|
@ -42,10 +42,12 @@
|
|||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"cors": "catalog:runtimeServerOnly"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/server": "workspace:^",
|
||||
"express": "catalog:runtimeServerOnly"
|
||||
"express": "^4.18.0 || ^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/server": "workspace:^",
|
||||
|
|
@ -53,8 +55,11 @@
|
|||
"@modelcontextprotocol/vitest-config": "workspace:^",
|
||||
"@modelcontextprotocol/eslint-config": "workspace:^",
|
||||
"@eslint/js": "catalog:devTools",
|
||||
"@types/cors": "catalog:devTools",
|
||||
"@types/express": "catalog:devTools",
|
||||
"@types/express-serve-static-core": "catalog:devTools",
|
||||
"@types/supertest": "catalog:devTools",
|
||||
"supertest": "catalog:devTools",
|
||||
"@typescript/native-preview": "catalog:devTools",
|
||||
"eslint": "catalog:devTools",
|
||||
"eslint-config-prettier": "catalog:devTools",
|
||||
|
|
|
|||
120
packages/middleware/express/src/auth/bearerAuth.ts
Normal file
120
packages/middleware/express/src/auth/bearerAuth.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
import type { OAuthTokenVerifier } from './types.js';
|
||||
|
||||
/**
|
||||
* Options for {@link requireBearerAuth}.
|
||||
*/
|
||||
export interface BearerAuthMiddlewareOptions {
|
||||
/**
|
||||
* A verifier used to validate access tokens.
|
||||
*/
|
||||
verifier: OAuthTokenVerifier;
|
||||
|
||||
/**
|
||||
* Optional scopes that the token must have. When any are missing the
|
||||
* middleware responds with `403 insufficient_scope`.
|
||||
*/
|
||||
requiredScopes?: string[];
|
||||
|
||||
/**
|
||||
* Optional Protected Resource Metadata URL to advertise in the
|
||||
* `WWW-Authenticate` header on 401/403 responses, per
|
||||
* {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728}.
|
||||
*
|
||||
* Typically built with `getOAuthProtectedResourceMetadataUrl`.
|
||||
*/
|
||||
resourceMetadataUrl?: string;
|
||||
}
|
||||
|
||||
function buildWwwAuthenticateHeader(
|
||||
errorCode: string,
|
||||
description: string,
|
||||
requiredScopes: string[],
|
||||
resourceMetadataUrl: string | undefined
|
||||
): string {
|
||||
let header = `Bearer error="${errorCode}", error_description="${description}"`;
|
||||
if (requiredScopes.length > 0) {
|
||||
header += `, scope="${requiredScopes.join(' ')}"`;
|
||||
}
|
||||
if (resourceMetadataUrl) {
|
||||
header += `, resource_metadata="${resourceMetadataUrl}"`;
|
||||
}
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware that requires a valid Bearer token in the `Authorization`
|
||||
* header.
|
||||
*
|
||||
* The token is validated via the supplied {@link OAuthTokenVerifier} and the
|
||||
* resulting `AuthInfo` (from `@modelcontextprotocol/server`) is attached
|
||||
* to `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and
|
||||
* surfaces it to handlers as `ctx.http.authInfo`.
|
||||
*
|
||||
* On failure the middleware sends a JSON OAuth error body and a
|
||||
* `WWW-Authenticate: Bearer …` challenge that includes the configured
|
||||
* `resource_metadata` URL so clients can discover the Authorization Server.
|
||||
*/
|
||||
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header');
|
||||
}
|
||||
|
||||
const [type, token] = authHeader.split(' ');
|
||||
if (type?.toLowerCase() !== 'bearer' || !token) {
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid Authorization header format, expected 'Bearer TOKEN'");
|
||||
}
|
||||
|
||||
const authInfo = await verifier.verifyAccessToken(token);
|
||||
|
||||
// Check if token has the required scopes (if any)
|
||||
if (requiredScopes.length > 0) {
|
||||
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
|
||||
if (!hasAllScopes) {
|
||||
throw new OAuthError(OAuthErrorCode.InsufficientScope, 'Insufficient scope');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the token is set to expire or if it is expired
|
||||
if (typeof authInfo.expiresAt !== 'number' || Number.isNaN(authInfo.expiresAt)) {
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has no expiration time');
|
||||
} else if (authInfo.expiresAt < Date.now() / 1000) {
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired');
|
||||
}
|
||||
|
||||
req.auth = authInfo;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof OAuthError) {
|
||||
const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl);
|
||||
switch (error.code) {
|
||||
case OAuthErrorCode.InvalidToken: {
|
||||
res.set('WWW-Authenticate', challenge);
|
||||
res.status(401).json(error.toResponseObject());
|
||||
break;
|
||||
}
|
||||
case OAuthErrorCode.InsufficientScope: {
|
||||
res.set('WWW-Authenticate', challenge);
|
||||
res.status(403).json(error.toResponseObject());
|
||||
break;
|
||||
}
|
||||
case OAuthErrorCode.ServerError: {
|
||||
res.status(500).json(error.toResponseObject());
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
res.status(400).json(error.toResponseObject());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const serverError = new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
153
packages/middleware/express/src/auth/metadataRouter.ts
Normal file
153
packages/middleware/express/src/auth/metadataRouter.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/server';
|
||||
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
||||
import cors from 'cors';
|
||||
import type { RequestHandler, Router } from 'express';
|
||||
import express from 'express';
|
||||
|
||||
// Dev-only escape hatch: allow http:// issuer URLs (e.g., for local testing).
|
||||
const allowInsecureIssuerUrl =
|
||||
process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === 'true' || process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === '1';
|
||||
if (allowInsecureIssuerUrl) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL is enabled - HTTP issuer URLs are allowed. Do not use in production.');
|
||||
}
|
||||
|
||||
function checkIssuerUrl(issuer: URL): void {
|
||||
// RFC 8414 technically does not permit a localhost HTTPS exemption, but it is necessary for local testing.
|
||||
if (issuer.protocol !== 'https:' && issuer.hostname !== 'localhost' && issuer.hostname !== '127.0.0.1' && !allowInsecureIssuerUrl) {
|
||||
throw new Error('Issuer URL must be HTTPS');
|
||||
}
|
||||
if (issuer.hash) {
|
||||
throw new Error(`Issuer URL must not have a fragment: ${issuer}`);
|
||||
}
|
||||
if (issuer.search) {
|
||||
throw new Error(`Issuer URL must not have a query string: ${issuer}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware that rejects HTTP methods not in the supplied allow-list
|
||||
* with a 405 Method Not Allowed and an OAuth-style error body. Used by
|
||||
* {@link metadataHandler} to restrict metadata endpoints to GET/OPTIONS.
|
||||
*/
|
||||
export function allowedMethods(allowed: string[]): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
if (allowed.includes(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const error = new OAuthError(OAuthErrorCode.MethodNotAllowed, `The method ${req.method} is not allowed for this endpoint`);
|
||||
res.status(405).set('Allow', allowed.join(', ')).json(error.toResponseObject());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a small Express router that serves the given OAuth metadata document
|
||||
* at `/` as JSON, with permissive CORS and a GET/OPTIONS method allow-list.
|
||||
*
|
||||
* Used by {@link mcpAuthMetadataRouter} for both the Authorization Server and
|
||||
* Protected Resource metadata endpoints.
|
||||
*/
|
||||
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
|
||||
const router = express.Router();
|
||||
// Metadata documents must be fetchable from web-based MCP clients on any origin.
|
||||
router.use(cors());
|
||||
router.use(allowedMethods(['GET', 'OPTIONS']));
|
||||
router.get('/', (_req, res) => {
|
||||
res.status(200).json(metadata);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link mcpAuthMetadataRouter}.
|
||||
*/
|
||||
export interface AuthMetadataOptions {
|
||||
/**
|
||||
* Authorization Server metadata (RFC 8414) for the AS this MCP server
|
||||
* relies on. Served at `/.well-known/oauth-authorization-server` so
|
||||
* legacy clients that probe the resource origin still discover the AS.
|
||||
*/
|
||||
oauthMetadata: OAuthMetadata;
|
||||
|
||||
/**
|
||||
* The public URL of this MCP server, used as the `resource` value in the
|
||||
* Protected Resource Metadata document. Any path component is reflected
|
||||
* in the well-known route per RFC 9728.
|
||||
*/
|
||||
resourceServerUrl: URL;
|
||||
|
||||
/**
|
||||
* Optional documentation URL advertised as `resource_documentation`.
|
||||
*/
|
||||
serviceDocumentationUrl?: URL;
|
||||
|
||||
/**
|
||||
* Optional list of scopes this MCP server understands, advertised as
|
||||
* `scopes_supported`.
|
||||
*/
|
||||
scopesSupported?: string[];
|
||||
|
||||
/**
|
||||
* Optional human-readable name advertised as `resource_name`.
|
||||
*/
|
||||
resourceName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an Express router that serves the two OAuth discovery documents an
|
||||
* MCP server acting purely as a Resource Server needs to expose:
|
||||
*
|
||||
* - `/.well-known/oauth-protected-resource[/<path>]` — RFC 9728 Protected
|
||||
* Resource Metadata, derived from the supplied options.
|
||||
* - `/.well-known/oauth-authorization-server` — RFC 8414 Authorization
|
||||
* Server Metadata, passed through verbatim from {@link AuthMetadataOptions.oauthMetadata}.
|
||||
*
|
||||
* Mount this router at the application root:
|
||||
*
|
||||
* ```ts
|
||||
* app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl }));
|
||||
* ```
|
||||
*
|
||||
* Pair with `requireBearerAuth` on your `/mcp` route and pass
|
||||
* `getOAuthProtectedResourceMetadataUrl` as its `resourceMetadataUrl`
|
||||
* so unauthenticated clients can discover the AS from the 401 challenge.
|
||||
*/
|
||||
export function mcpAuthMetadataRouter(options: AuthMetadataOptions): Router {
|
||||
checkIssuerUrl(new URL(options.oauthMetadata.issuer));
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const protectedResourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: options.resourceServerUrl.href,
|
||||
authorization_servers: [options.oauthMetadata.issuer],
|
||||
scopes_supported: options.scopesSupported,
|
||||
resource_name: options.resourceName,
|
||||
resource_documentation: options.serviceDocumentationUrl?.href
|
||||
};
|
||||
|
||||
// Serve PRM at the path-aware URL per RFC 9728 §3.1.
|
||||
const rsPath = new URL(options.resourceServerUrl.href).pathname;
|
||||
router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata));
|
||||
|
||||
// Mirror the AS metadata at this origin for clients that look here first.
|
||||
router.use('/.well-known/oauth-authorization-server', metadataHandler(options.oauthMetadata));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the RFC 9728 Protected Resource Metadata URL for a given MCP server
|
||||
* URL by inserting `/.well-known/oauth-protected-resource` ahead of the path.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))
|
||||
* // → 'https://api.example.com/.well-known/oauth-protected-resource/mcp'
|
||||
* ```
|
||||
*/
|
||||
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
|
||||
const u = new URL(serverUrl.href);
|
||||
const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : '';
|
||||
return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href;
|
||||
}
|
||||
37
packages/middleware/express/src/auth/types.ts
Normal file
37
packages/middleware/express/src/auth/types.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { AuthInfo } from '@modelcontextprotocol/server';
|
||||
|
||||
/**
|
||||
* Minimal token-verifier interface for MCP servers acting as an OAuth 2.0
|
||||
* Resource Server. Implementations introspect or locally validate an access
|
||||
* token and return the resulting {@link AuthInfo}, which is then attached to
|
||||
* the Express request and surfaced to MCP request handlers via
|
||||
* `ctx.http.authInfo`.
|
||||
*
|
||||
* This is intentionally narrower than a full OAuth Authorization Server
|
||||
* provider — it only covers the verification step a Resource Server needs.
|
||||
*/
|
||||
export interface OAuthTokenVerifier {
|
||||
/**
|
||||
* Verifies an access token and returns information about it.
|
||||
*
|
||||
* Implementations should throw an `OAuthError` (from `@modelcontextprotocol/server`)
|
||||
* with `OAuthErrorCode.InvalidToken` when
|
||||
* the token is unknown, revoked, or otherwise invalid; `requireBearerAuth`
|
||||
* maps that to a 401 with a `WWW-Authenticate` challenge.
|
||||
*
|
||||
* Note: `requireBearerAuth` rejects tokens whose `AuthInfo.expiresAt` is unset
|
||||
* (matches v1 behavior). Ensure your verifier populates it (e.g. from RFC 7662
|
||||
* introspection `exp` or the JWT `exp` claim).
|
||||
*/
|
||||
verifyAccessToken(token: string): Promise<AuthInfo>;
|
||||
}
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
/**
|
||||
* Information about the validated access token, populated by
|
||||
* `requireBearerAuth`.
|
||||
*/
|
||||
auth?: AuthInfo;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,9 @@
|
|||
export * from './express.js';
|
||||
export * from './middleware/hostHeaderValidation.js';
|
||||
|
||||
// OAuth Resource-Server glue: bearer-token middleware + PRM/AS metadata router.
|
||||
export type { BearerAuthMiddlewareOptions } from './auth/bearerAuth.js';
|
||||
export { requireBearerAuth } from './auth/bearerAuth.js';
|
||||
export type { AuthMetadataOptions } from './auth/metadataRouter.js';
|
||||
export { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from './auth/metadataRouter.js';
|
||||
export type { OAuthTokenVerifier } from './auth/types.js';
|
||||
|
|
|
|||
218
packages/middleware/express/test/auth/resourceServer.test.ts
Normal file
218
packages/middleware/express/test/auth/resourceServer.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server';
|
||||
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
||||
import type { Request, Response } from 'express';
|
||||
import express from 'express';
|
||||
import supertest from 'supertest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import type { OAuthTokenVerifier } from '../../src/auth/types.js';
|
||||
import { requireBearerAuth } from '../../src/auth/bearerAuth.js';
|
||||
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../src/auth/metadataRouter.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// requireBearerAuth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockVerifyAccessToken = vi.fn();
|
||||
const mockVerifier: OAuthTokenVerifier = { verifyAccessToken: mockVerifyAccessToken };
|
||||
|
||||
function createMockReqResNext(authorization?: string) {
|
||||
const req = { headers: { authorization } } as Request;
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis()
|
||||
} as unknown as Response;
|
||||
const next = vi.fn() as Mock;
|
||||
return { req, res, next };
|
||||
}
|
||||
|
||||
describe('requireBearerAuth middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('attaches AuthInfo to req.auth and calls next on a valid token', async () => {
|
||||
const validAuthInfo: AuthInfo = {
|
||||
token: 'valid-token',
|
||||
clientId: 'client-123',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(validAuthInfo);
|
||||
|
||||
const { req, res, next } = createMockReqResNext('Bearer valid-token');
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
|
||||
expect(req.auth).toEqual(validAuthInfo);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('responds 401 with WWW-Authenticate (incl. resource_metadata) when header is missing', async () => {
|
||||
const { req, res, next } = createMockReqResNext(undefined);
|
||||
const middleware = requireBearerAuth({
|
||||
verifier: mockVerifier,
|
||||
resourceMetadataUrl: 'https://api.example.com/.well-known/oauth-protected-resource'
|
||||
});
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.set).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
expect.stringMatching(
|
||||
/^Bearer error="invalid_token".*resource_metadata="https:\/\/api\.example\.com\/\.well-known\/oauth-protected-resource"$/
|
||||
)
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_token' }));
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('responds 401 when the verifier throws InvalidToken', async () => {
|
||||
mockVerifyAccessToken.mockRejectedValue(new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'));
|
||||
|
||||
const { req, res, next } = createMockReqResNext('Bearer nope');
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_token', error_description: 'unknown token' }));
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('responds 401 when the token is expired', async () => {
|
||||
mockVerifyAccessToken.mockResolvedValue({
|
||||
token: 'expired',
|
||||
clientId: 'client-123',
|
||||
scopes: [],
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 100
|
||||
} satisfies AuthInfo);
|
||||
|
||||
const { req, res, next } = createMockReqResNext('Bearer expired');
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_token', error_description: 'Token has expired' }));
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('responds 403 with scope in WWW-Authenticate when required scopes are missing', async () => {
|
||||
mockVerifyAccessToken.mockResolvedValue({
|
||||
token: 'valid',
|
||||
clientId: 'client-123',
|
||||
scopes: ['read'],
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600
|
||||
} satisfies AuthInfo);
|
||||
|
||||
const { req, res, next } = createMockReqResNext('Bearer valid');
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier, requiredScopes: ['read', 'write'] });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('scope="read write"'));
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'insufficient_scope' }));
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('responds 500 when the verifier throws a non-OAuth error', async () => {
|
||||
mockVerifyAccessToken.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const { req, res, next } = createMockReqResNext('Bearer valid');
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'server_error' }));
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mcpAuthMetadataRouter + getOAuthProtectedResourceMetadataUrl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('mcpAuthMetadataRouter', () => {
|
||||
const oauthMetadata: OAuthMetadata = {
|
||||
issuer: 'https://auth.example.com/',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code']
|
||||
};
|
||||
|
||||
it('serves PRM and AS metadata at the well-known endpoints', async () => {
|
||||
const app = express();
|
||||
app.use(
|
||||
mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: new URL('https://api.example.com/'),
|
||||
scopesSupported: ['read', 'write'],
|
||||
resourceName: 'Test API',
|
||||
serviceDocumentationUrl: new URL('https://docs.example.com/')
|
||||
})
|
||||
);
|
||||
|
||||
const prm = await supertest(app).get('/.well-known/oauth-protected-resource');
|
||||
expect(prm.status).toBe(200);
|
||||
expect(prm.body.resource).toBe('https://api.example.com/');
|
||||
expect(prm.body.authorization_servers).toEqual(['https://auth.example.com/']);
|
||||
expect(prm.body.scopes_supported).toEqual(['read', 'write']);
|
||||
expect(prm.body.resource_name).toBe('Test API');
|
||||
expect(prm.body.resource_documentation).toBe('https://docs.example.com/');
|
||||
|
||||
const as = await supertest(app).get('/.well-known/oauth-authorization-server');
|
||||
expect(as.status).toBe(200);
|
||||
expect(as.body.issuer).toBe('https://auth.example.com/');
|
||||
expect(as.body.token_endpoint).toBe('https://auth.example.com/token');
|
||||
});
|
||||
|
||||
it('serves PRM at a path-aware route when resourceServerUrl has a path', async () => {
|
||||
const app = express();
|
||||
app.use(
|
||||
mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: new URL('https://api.example.com/mcp')
|
||||
})
|
||||
);
|
||||
|
||||
const prm = await supertest(app).get('/.well-known/oauth-protected-resource/mcp');
|
||||
expect(prm.status).toBe(200);
|
||||
expect(prm.body.resource).toBe('https://api.example.com/mcp');
|
||||
});
|
||||
|
||||
it('rejects non-GET methods on metadata endpoints with 405', async () => {
|
||||
const app = express();
|
||||
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: new URL('https://api.example.com/') }));
|
||||
|
||||
const res = await supertest(app).post('/.well-known/oauth-protected-resource');
|
||||
expect(res.status).toBe(405);
|
||||
expect(res.headers.allow).toBe('GET, OPTIONS');
|
||||
expect(res.body.error).toBe('method_not_allowed');
|
||||
});
|
||||
|
||||
it('rejects non-HTTPS issuer URLs', () => {
|
||||
expect(() =>
|
||||
mcpAuthMetadataRouter({
|
||||
oauthMetadata: { ...oauthMetadata, issuer: 'http://auth.example.com/' },
|
||||
resourceServerUrl: new URL('https://api.example.com/')
|
||||
})
|
||||
).toThrow('Issuer URL must be HTTPS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOAuthProtectedResourceMetadataUrl', () => {
|
||||
it('inserts the well-known prefix ahead of the path', () => {
|
||||
expect(getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))).toBe(
|
||||
'https://api.example.com/.well-known/oauth-protected-resource/mcp'
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a bare root path', () => {
|
||||
expect(getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/'))).toBe(
|
||||
'https://api.example.com/.well-known/oauth-protected-resource'
|
||||
);
|
||||
});
|
||||
});
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
|
|
@ -650,8 +650,11 @@ importers:
|
|||
|
||||
packages/middleware/express:
|
||||
dependencies:
|
||||
express:
|
||||
cors:
|
||||
specifier: catalog:runtimeServerOnly
|
||||
version: 2.8.6
|
||||
express:
|
||||
specifier: ^4.18.0 || ^5.0.0
|
||||
version: 5.2.1
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
|
|
@ -669,12 +672,18 @@ importers:
|
|||
'@modelcontextprotocol/vitest-config':
|
||||
specifier: workspace:^
|
||||
version: link:../../../common/vitest-config
|
||||
'@types/cors':
|
||||
specifier: catalog:devTools
|
||||
version: 2.8.19
|
||||
'@types/express':
|
||||
specifier: catalog:devTools
|
||||
version: 5.0.6
|
||||
'@types/express-serve-static-core':
|
||||
specifier: catalog:devTools
|
||||
version: 5.1.1
|
||||
'@types/supertest':
|
||||
specifier: catalog:devTools
|
||||
version: 6.0.3
|
||||
'@typescript/native-preview':
|
||||
specifier: catalog:devTools
|
||||
version: 7.0.0-dev.20260327.2
|
||||
|
|
@ -690,6 +699,9 @@ importers:
|
|||
prettier:
|
||||
specifier: catalog:devTools
|
||||
version: 3.6.2
|
||||
supertest:
|
||||
specifier: catalog:devTools
|
||||
version: 7.2.2
|
||||
tsdown:
|
||||
specifier: catalog:devTools
|
||||
version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3)
|
||||
|
|
|
|||
Loading…
Reference in a new issue