From 27d1a330e9f2765c684471d72ab8c84c561e04d6 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 15 Apr 2026 12:00:52 +0000 Subject: [PATCH] feat(compat): restore Resource-Server auth glue in @modelcontextprotocol/express Adds first-class (not deprecated) OAuth Resource-Server helpers to the Express adapter, restoring the v1 src/server/auth pieces that an MCP server needs when it delegates to an external Authorization Server: - requireBearerAuth: Express middleware that validates a Bearer token via a pluggable OAuthTokenVerifier, attaches AuthInfo to req.auth, and on failure emits RFC 6750 WWW-Authenticate challenges (with optional resource_metadata pointer per RFC 9728). - mcpAuthMetadataRouter: serves RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource[/] and mirrors the AS metadata at /.well-known/oauth-authorization-server, with permissive CORS and a GET/OPTIONS allow-list. - getOAuthProtectedResourceMetadataUrl: builds the path-aware PRM URL for a given server URL. - OAuthTokenVerifier interface, plus metadataHandler / allowedMethods building blocks. Adapted to v2's single OAuthError + OAuthErrorCode (no per-code subclasses) and to types re-exported via @modelcontextprotocol/server. Adds cors as a runtime dependency and supertest as a dev dependency for the integration tests. --- .changeset/express-resource-server-auth.md | 5 + examples/server/src/elicitationUrlExample.ts | 16 +- examples/server/src/simpleStreamableHttp.ts | 25 +- examples/shared/src/authMiddleware.ts | 95 -------- examples/shared/src/authServer.ts | 57 ++--- examples/shared/src/index.ts | 8 +- examples/shared/tsconfig.json | 1 + packages/middleware/express/package.json | 9 +- .../middleware/express/src/auth/bearerAuth.ts | 120 ++++++++++ .../express/src/auth/metadataRouter.ts | 153 ++++++++++++ packages/middleware/express/src/auth/types.ts | 33 +++ packages/middleware/express/src/index.ts | 7 + .../express/test/auth/resourceServer.test.ts | 218 ++++++++++++++++++ pnpm-lock.yaml | 14 +- 14 files changed, 587 insertions(+), 174 deletions(-) create mode 100644 .changeset/express-resource-server-auth.md delete mode 100644 examples/shared/src/authMiddleware.ts create mode 100644 packages/middleware/express/src/auth/bearerAuth.ts create mode 100644 packages/middleware/express/src/auth/metadataRouter.ts create mode 100644 packages/middleware/express/src/auth/types.ts create mode 100644 packages/middleware/express/test/auth/resourceServer.test.ts diff --git a/.changeset/express-resource-server-auth.md b/.changeset/express-resource-server-auth.md new file mode 100644 index 000000000..a9e16227b --- /dev/null +++ b/.changeset/express-resource-server-auth.md @@ -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. diff --git a/examples/server/src/elicitationUrlExample.ts b/examples/server/src/elicitationUrlExample.ts index 462252deb..93b59152f 100644 --- a/examples/server/src/elicitationUrlExample.ts +++ b/examples/server/src/elicitationUrlExample.ts @@ -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) }); /** diff --git a/examples/server/src/simpleStreamableHttp.ts b/examples/server/src/simpleStreamableHttp.ts index 1fc1522a1..6da0841ec 100644 --- a/examples/server/src/simpleStreamableHttp.ts +++ b/examples/server/src/simpleStreamableHttp.ts @@ -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 diff --git a/examples/shared/src/authMiddleware.ts b/examples/shared/src/authMiddleware.ts deleted file mode 100644 index 8683a7e24..000000000 --- a/examples/shared/src/authMiddleware.ts +++ /dev/null @@ -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 { - 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 => { - 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; -} diff --git a/examples/shared/src/authServer.ts b/examples/shared/src/authServer.ts index e967b23d9..995fedc7d 100644 --- a/examples/shared/src/authServer.ts +++ b/examples/shared/src/authServer.ts @@ -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 { + 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 }; } -} +}; diff --git a/examples/shared/src/index.ts b/examples/shared/src/index.ts index 6a9873c11..47c4d6710 100644 --- a/examples/shared/src/index.ts +++ b/examples/shared/src/index.ts @@ -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'; diff --git a/examples/shared/tsconfig.json b/examples/shared/tsconfig.json index 61a052691..bfc4eab52 100644 --- a/examples/shared/tsconfig.json +++ b/examples/shared/tsconfig.json @@ -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" diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json index 39d671b81..a10c85ad9 100644 --- a/packages/middleware/express/package.json +++ b/packages/middleware/express/package.json @@ -41,10 +41,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:^", @@ -52,8 +54,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", diff --git a/packages/middleware/express/src/auth/bearerAuth.ts b/packages/middleware/express/src/auth/bearerAuth.ts new file mode 100644 index 000000000..5f46be792 --- /dev/null +++ b/packages/middleware/express/src/auth/bearerAuth.ts @@ -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()); + } + } + }; +} diff --git a/packages/middleware/express/src/auth/metadataRouter.ts b/packages/middleware/express/src/auth/metadataRouter.ts new file mode 100644 index 000000000..7913c8817 --- /dev/null +++ b/packages/middleware/express/src/auth/metadataRouter.ts @@ -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[/]` โ€” 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; +} diff --git a/packages/middleware/express/src/auth/types.ts b/packages/middleware/express/src/auth/types.ts new file mode 100644 index 000000000..0806f3ca3 --- /dev/null +++ b/packages/middleware/express/src/auth/types.ts @@ -0,0 +1,33 @@ +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. + */ + verifyAccessToken(token: string): Promise; +} + +declare module 'express-serve-static-core' { + interface Request { + /** + * Information about the validated access token, populated by + * `requireBearerAuth`. + */ + auth?: AuthInfo; + } +} diff --git a/packages/middleware/express/src/index.ts b/packages/middleware/express/src/index.ts index 2d7d20a64..35f1a8e67 100644 --- a/packages/middleware/express/src/index.ts +++ b/packages/middleware/express/src/index.ts @@ -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 { allowedMethods, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, metadataHandler } from './auth/metadataRouter.js'; +export type { OAuthTokenVerifier } from './auth/types.js'; diff --git a/packages/middleware/express/test/auth/resourceServer.test.ts b/packages/middleware/express/test/auth/resourceServer.test.ts new file mode 100644 index 000000000..e9ab4b617 --- /dev/null +++ b/packages/middleware/express/test/auth/resourceServer.test.ts @@ -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' + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 899586750..1bfe5c19c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)