feat(express): restore Resource-Server auth glue (requireBearerAuth, mcpAuthMetadataRouter) (#1907)

This commit is contained in:
Felix Weinberger 2026-04-28 15:18:33 +01:00 • committed by GitHub
commit 7cccc2aca8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 693 additions and 184 deletions

View file

@ -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

View file

@ -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",

View 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());
}
}
};
}

View 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;
}

View 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;
}
}

View file

@ -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';

View 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'
);
});
});