feat(express): restore Resource-Server auth glue (requireBearerAuth, mcpAuthMetadataRouter) (#1907)
This commit is contained in:
parent
2a7611d46b
commit
7cccc2aca8
7 changed files with 693 additions and 184 deletions
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue