feat: Add support for separate Authorization Server / Resource server in server flow (spec: DRAFT-2025-v2) (#503)

* add support for .oauth-protected-resource metadata endpoint and www-authenticate

* test and types

* thread throughs scopes

* have example separate AS and RS

* make inmemory explicitly demo

* fix type

* fix types

* fix client example

* fixup comments

* refactor metadata endpoints to cleanup

* almost working w/ forwarding

* separate AS/RS working

* add to readme

* de-async the auth server setup for happier top level flow

* add test for new router

* fix test

* remove redundant comment

* clarify comment

* remove more unused endpoints

* simplify router

* slim down

* simplify verifier, and fix router test

* fix doc string
This commit is contained in:
Paul Carleton 2025-05-21 13:13:34 +01:00 • committed by GitHub
commit ac1b80a331
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 787 additions and 82 deletions

View file

@ -67,6 +67,9 @@ A server that implements the Streamable HTTP transport (protocol version 2025-03
```bash
npx tsx src/examples/server/simpleStreamableHttp.ts
# To add a demo of authentication to this example, use:
npx tsx src/examples/server/simpleStreamableHttp.ts --oauth
```
##### JSON Response Mode Server

View file

@ -0,0 +1,200 @@
import { randomUUID } from 'node:crypto';
import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js';
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js';
import express, { Request, Response } from "express";
import { AuthInfo } from 'src/server/auth/types.js';
import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js';
export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
private clients = new Map<string, OAuthClientInformationFull>();
async getClient(clientId: string) {
return this.clients.get(clientId);
}
async registerClient(clientMetadata: OAuthClientInformationFull) {
this.clients.set(clientMetadata.client_id, clientMetadata);
return clientMetadata;
}
}
/**
* 🚨 DEMO ONLY - NOT FOR PRODUCTION
*
* This example demonstrates MCP OAuth flow but lacks some of the features required for production use,
* for example:
* - Persistent token storage
* - Rate limiting
*/
export class DemoInMemoryAuthProvider implements OAuthServerProvider {
clientsStore = new DemoInMemoryClientsStore();
private codes = new Map<string, {
params: AuthorizationParams,
client: OAuthClientInformationFull}>();
private tokens = new Map<string, AuthInfo>();
async authorize(
client: OAuthClientInformationFull,
params: AuthorizationParams,
res: Response
): Promise<void> {
const code = randomUUID();
const searchParams = new URLSearchParams({
code,
});
if (params.state !== undefined) {
searchParams.set('state', params.state);
}
this.codes.set(code, {
client,
params
});
const targetUrl = new URL(client.redirect_uris[0]);
targetUrl.search = searchParams.toString();
res.redirect(targetUrl.toString());
}
async challengeForAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string
): Promise<string> {
// Store the challenge with the code data
const codeData = this.codes.get(authorizationCode);
if (!codeData) {
throw new Error('Invalid authorization code');
}
return codeData.params.codeChallenge;
}
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
// Note: code verifier is checked in token.ts by default
// it's unused here for that reason.
_codeVerifier?: string
): Promise<OAuthTokens> {
const codeData = this.codes.get(authorizationCode);
if (!codeData) {
throw new Error('Invalid authorization code');
}
if (codeData.client.client_id !== client.client_id) {
throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`);
}
this.codes.delete(authorizationCode);
const token = randomUUID();
const tokenData = {
token,
clientId: client.client_id,
scopes: codeData.params.scopes || [],
expiresAt: Date.now() + 3600000, // 1 hour
type: 'access'
};
this.tokens.set(token, tokenData);
return {
access_token: token,
token_type: 'bearer',
expires_in: 3600,
scope: (codeData.params.scopes || []).join(' '),
};
}
async exchangeRefreshToken(
_client: OAuthClientInformationFull,
_refreshToken: string,
_scopes?: string[]
): Promise<OAuthTokens> {
throw new Error('Not implemented for example demo');
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
const tokenData = this.tokens.get(token);
if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) {
throw new Error('Invalid or expired token');
}
return {
token,
clientId: tokenData.clientId,
scopes: tokenData.scopes,
expiresAt: Math.floor(tokenData.expiresAt / 1000),
};
}
}
export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => {
// Create separate auth server app
// NOTE: This is a separate app on a separate port to illustrate
// how to separate an OAuth Authorization Server from a Resource
// server in the SDK. The SDK is not intended to be provide a standalone
// authorization server.
const provider = new DemoInMemoryAuthProvider();
const authApp = express();
authApp.use(express.json());
// For introspection requests
authApp.use(express.urlencoded());
// Add OAuth routes to the auth server
// NOTE: this will also add a protected resource metadata route,
// but it won't be used, so leave it.
authApp.use(mcpAuthRouter({
provider,
issuerUrl: authServerUrl,
scopesSupported: ['mcp:tools'],
}));
authApp.post('/introspect', async (req: Request, res: Response) => {
try {
const { token } = req.body;
if (!token) {
res.status(400).json({ error: 'Token is required' });
return;
}
const tokenInfo = await provider.verifyAccessToken(token);
res.json({
active: true,
client_id: tokenInfo.clientId,
scope: tokenInfo.scopes.join(' '),
exp: tokenInfo.expiresAt
});
return
} catch (error) {
res.status(401).json({
active: false,
error: 'Unauthorized',
error_description: `Invalid token: ${error}`
});
}
});
const auth_port = authServerUrl.port;
// Start the auth server
authApp.listen(auth_port, () => {
console.log(`OAuth Authorization Server listening on port ${auth_port}`);
});
// Note: we could fetch this from the server, but then we end up
// with some top level async which gets annoying.
const oauthMetadata: OAuthMetadata = createOAuthMetadata({
provider,
issuerUrl: authServerUrl,
scopesSupported: ['mcp:tools'],
})
oauthMetadata.introspection_endpoint = new URL("/introspect", authServerUrl).href;
return oauthMetadata;
}

View file

@ -3,8 +3,15 @@ import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js';
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
import { OAuthMetadata } from 'src/shared/auth.js';
// Check for OAuth flag
const useOAuth = process.argv.includes('--oauth');
// Create an MCP server with implementation details
const getServer = () => {
@ -40,7 +47,7 @@ const getServer = () => {
name: z.string().describe('Name to greet'),
},
{
title: 'Multiple Greeting Tool',
title: 'Multiple Greeting Tool',
readOnlyHint: true,
openWorldHint: false
},
@ -159,14 +166,79 @@ const getServer = () => {
return server;
};
const MCP_PORT = 3000;
const AUTH_PORT = 3001;
const app = express();
app.use(express.json());
// Set up OAuth if enabled
let authMiddleware = null;
if (useOAuth) {
// Create auth middleware for MCP endpoints
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`);
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl);
const tokenVerifier = {
verifyAccessToken: async (token: string) => {
const endpoint = oauthMetadata.introspection_endpoint;
if (!endpoint) {
throw new Error('No token verification endpoint available in metadata');
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
token: token
}).toString()
});
if (!response.ok) {
throw new Error(`Invalid or expired token: ${await response.text()}`);
}
const data = await response.json();
// Convert the response to AuthInfo format
return {
token,
clientId: data.client_id,
scopes: data.scope ? data.scope.split(' ') : [],
expiresAt: data.exp,
};
}
}
// Add metadata routes to the main MCP server
app.use(mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: mcpServerUrl,
scopesSupported: ['mcp:tools'],
resourceName: 'MCP Demo Server',
}));
authMiddleware = requireBearerAuth({
verifier: tokenVerifier,
requiredScopes: ['mcp:tools'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
});
}
// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
app.post('/mcp', async (req: Request, res: Response) => {
// MCP POST endpoint with optional auth
const mcpPostHandler = async (req: Request, res: Response) => {
console.log('Received MCP request:', req.body);
if (useOAuth && req.auth) {
console.log('Authenticated user:', req.auth);
}
try {
// Check for existing session ID
const sessionId = req.headers['mcp-session-id'] as string | undefined;
@ -234,16 +306,27 @@ app.post('/mcp', async (req: Request, res: Response) => {
});
}
}
});
};
// Set up routes with conditional auth middleware
if (useOAuth && authMiddleware) {
app.post('/mcp', authMiddleware, mcpPostHandler);
} else {
app.post('/mcp', mcpPostHandler);
}
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
app.get('/mcp', async (req: Request, res: Response) => {
const mcpGetHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
if (useOAuth && req.auth) {
console.log('Authenticated SSE connection from user:', req.auth);
}
// Check for Last-Event-ID header for resumability
const lastEventId = req.headers['last-event-id'] as string | undefined;
if (lastEventId) {
@ -254,10 +337,17 @@ app.get('/mcp', async (req: Request, res: Response) => {
const transport = transports[sessionId];
await transport.handleRequest(req, res);
});
};
// Set up GET route with conditional auth middleware
if (useOAuth && authMiddleware) {
app.get('/mcp', authMiddleware, mcpGetHandler);
} else {
app.get('/mcp', mcpGetHandler);
}
// Handle DELETE requests for session termination (according to MCP spec)
app.delete('/mcp', async (req: Request, res: Response) => {
const mcpDeleteHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
@ -275,12 +365,17 @@ app.delete('/mcp', async (req: Request, res: Response) => {
res.status(500).send('Error processing session termination');
}
}
});
};
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
// Set up DELETE route with conditional auth middleware
if (useOAuth && authMiddleware) {
app.delete('/mcp', authMiddleware, mcpDeleteHandler);
} else {
app.delete('/mcp', mcpDeleteHandler);
}
app.listen(MCP_PORT, () => {
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
});
// Handle server shutdown

View file

@ -1,9 +1,9 @@
import express, { RequestHandler } from "express";
import { OAuthMetadata } from "../../../shared/auth.js";
import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../../shared/auth.js";
import cors from 'cors';
import { allowedMethods } from "../middleware/allowedMethods.js";
export function metadataHandler(metadata: OAuthMetadata): RequestHandler {
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
@ -16,4 +16,4 @@ export function metadataHandler(metadata: OAuthMetadata): RequestHandler {
});
return router;
}
}

View file

@ -2,17 +2,11 @@ import { Request, Response } from "express";
import { requireBearerAuth } from "./bearerAuth.js";
import { AuthInfo } from "../types.js";
import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js";
import { OAuthServerProvider } from "../provider.js";
import { OAuthRegisteredClientsStore } from "../clients.js";
import { OAuthTokenVerifier } from "../provider.js";
// Mock provider
// Mock verifier
const mockVerifyAccessToken = jest.fn();
const mockProvider: OAuthServerProvider = {
clientsStore: {} as OAuthRegisteredClientsStore,
authorize: jest.fn(),
challengeForAuthorizationCode: jest.fn(),
exchangeAuthorizationCode: jest.fn(),
exchangeRefreshToken: jest.fn(),
const mockVerifier: OAuthTokenVerifier = {
verifyAccessToken: mockVerifyAccessToken,
};
@ -50,7 +44,7 @@ describe("requireBearerAuth middleware", () => {
authorization: "Bearer valid-token",
};
const middleware = requireBearerAuth({ provider: mockProvider });
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
@ -59,7 +53,7 @@ describe("requireBearerAuth middleware", () => {
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
it("should reject expired tokens", async () => {
const expiredAuthInfo: AuthInfo = {
token: "expired-token",
@ -73,7 +67,7 @@ describe("requireBearerAuth middleware", () => {
authorization: "Bearer expired-token",
};
const middleware = requireBearerAuth({ provider: mockProvider });
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token");
@ -87,7 +81,7 @@ describe("requireBearerAuth middleware", () => {
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should accept non-expired tokens", async () => {
const nonExpiredAuthInfo: AuthInfo = {
token: "valid-token",
@ -101,7 +95,7 @@ describe("requireBearerAuth middleware", () => {
authorization: "Bearer valid-token",
};
const middleware = requireBearerAuth({ provider: mockProvider });
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
@ -124,7 +118,7 @@ describe("requireBearerAuth middleware", () => {
};
const middleware = requireBearerAuth({
provider: mockProvider,
verifier: mockVerifier,
requiredScopes: ["read", "write"]
});
@ -155,7 +149,7 @@ describe("requireBearerAuth middleware", () => {
};
const middleware = requireBearerAuth({
provider: mockProvider,
verifier: mockVerifier,
requiredScopes: ["read", "write"]
});
@ -169,7 +163,7 @@ describe("requireBearerAuth middleware", () => {
});
it("should return 401 when no Authorization header is present", async () => {
const middleware = requireBearerAuth({ provider: mockProvider });
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
@ -189,7 +183,7 @@ describe("requireBearerAuth middleware", () => {
authorization: "InvalidFormat",
};
const middleware = requireBearerAuth({ provider: mockProvider });
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
@ -214,7 +208,7 @@ describe("requireBearerAuth middleware", () => {
mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired"));
const middleware = requireBearerAuth({ provider: mockProvider });
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token");
@ -236,7 +230,7 @@ describe("requireBearerAuth middleware", () => {
mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: read, write"));
const middleware = requireBearerAuth({ provider: mockProvider });
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
@ -258,7 +252,7 @@ describe("requireBearerAuth middleware", () => {
mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue"));
const middleware = requireBearerAuth({ provider: mockProvider });
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
@ -276,7 +270,7 @@ describe("requireBearerAuth middleware", () => {
mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error"));
const middleware = requireBearerAuth({ provider: mockProvider });
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
@ -294,7 +288,7 @@ describe("requireBearerAuth middleware", () => {
mockVerifyAccessToken.mockRejectedValue(new Error("Unexpected error"));
const middleware = requireBearerAuth({ provider: mockProvider });
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
@ -304,4 +298,125 @@ describe("requireBearerAuth middleware", () => {
);
expect(nextFunction).not.toHaveBeenCalled();
});
});
describe("with resourceMetadataUrl", () => {
const resourceMetadataUrl = "https://api.example.com/.well-known/oauth-protected-resource";
it("should include resource_metadata in WWW-Authenticate header for 401 responses", async () => {
mockRequest.headers = {};
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
"WWW-Authenticate",
`Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="${resourceMetadataUrl}"`
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should include resource_metadata in WWW-Authenticate header when token verification fails", async () => {
mockRequest.headers = {
authorization: "Bearer invalid-token",
};
mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired"));
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
"WWW-Authenticate",
`Bearer error="invalid_token", error_description="Token expired", resource_metadata="${resourceMetadataUrl}"`
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should include resource_metadata in WWW-Authenticate header for insufficient scope errors", async () => {
mockRequest.headers = {
authorization: "Bearer valid-token",
};
mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: admin"));
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.set).toHaveBeenCalledWith(
"WWW-Authenticate",
`Bearer error="insufficient_scope", error_description="Required scopes: admin", resource_metadata="${resourceMetadataUrl}"`
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should include resource_metadata when token is expired", async () => {
const expiredAuthInfo: AuthInfo = {
token: "expired-token",
clientId: "client-123",
scopes: ["read", "write"],
expiresAt: Math.floor(Date.now() / 1000) - 100,
};
mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo);
mockRequest.headers = {
authorization: "Bearer expired-token",
};
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
"WWW-Authenticate",
`Bearer error="invalid_token", error_description="Token has expired", resource_metadata="${resourceMetadataUrl}"`
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should include resource_metadata when scope check fails", async () => {
const authInfo: AuthInfo = {
token: "valid-token",
clientId: "client-123",
scopes: ["read"],
};
mockVerifyAccessToken.mockResolvedValue(authInfo);
mockRequest.headers = {
authorization: "Bearer valid-token",
};
const middleware = requireBearerAuth({
verifier: mockVerifier,
requiredScopes: ["read", "write"],
resourceMetadataUrl
});
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.set).toHaveBeenCalledWith(
"WWW-Authenticate",
`Bearer error="insufficient_scope", error_description="Insufficient scope", resource_metadata="${resourceMetadataUrl}"`
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should not affect server errors (no WWW-Authenticate header)", async () => {
mockRequest.headers = {
authorization: "Bearer valid-token",
};
mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue"));
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.set).not.toHaveBeenCalledWith("WWW-Authenticate", expect.anything());
expect(nextFunction).not.toHaveBeenCalled();
});
});
});

View file

@ -1,18 +1,23 @@
import { RequestHandler } from "express";
import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js";
import { OAuthServerProvider } from "../provider.js";
import { OAuthTokenVerifier } from "../provider.js";
import { AuthInfo } from "../types.js";
export type BearerAuthMiddlewareOptions = {
/**
* A provider used to verify tokens.
*/
provider: OAuthServerProvider;
verifier: OAuthTokenVerifier;
/**
* Optional scopes that the token must have.
*/
requiredScopes?: string[];
/**
* Optional resource metadata URL to include in WWW-Authenticate header.
*/
resourceMetadataUrl?: string;
};
declare module "express-serve-static-core" {
@ -26,10 +31,13 @@ declare module "express-serve-static-core" {
/**
* Middleware that requires a valid Bearer token in the Authorization header.
*
*
* This will validate the token with the auth provider and add the resulting auth info to the request object.
*
* If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header
* for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec.
*/
export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthMiddlewareOptions): RequestHandler {
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
return async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
@ -42,7 +50,7 @@ export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthM
throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'");
}
const authInfo = await provider.verifyAccessToken(token);
const authInfo = await verifier.verifyAccessToken(token);
// Check if token has the required scopes (if any)
if (requiredScopes.length > 0) {
@ -64,10 +72,16 @@ export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthM
next();
} catch (error) {
if (error instanceof InvalidTokenError) {
res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`);
const wwwAuthValue = resourceMetadataUrl
? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`
: `Bearer error="${error.errorCode}", error_description="${error.message}"`;
res.set("WWW-Authenticate", wwwAuthValue);
res.status(401).json(error.toResponseObject());
} else if (error instanceof InsufficientScopeError) {
res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`);
const wwwAuthValue = resourceMetadataUrl
? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`
: `Bearer error="${error.errorCode}", error_description="${error.message}"`;
res.set("WWW-Authenticate", wwwAuthValue);
res.status(403).json(error.toResponseObject());
} else if (error instanceof ServerError) {
res.status(500).json(error.toResponseObject());
@ -80,4 +94,4 @@ export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthM
}
}
};
}
}

View file

@ -20,8 +20,8 @@ export interface OAuthServerProvider {
get clientsStore(): OAuthRegisteredClientsStore;
/**
* Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server.
*
* Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server.
*
* This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1:
* - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters.
* - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter.
@ -50,17 +50,28 @@ export interface OAuthServerProvider {
/**
* Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended).
*
*
* If the given token is invalid or already revoked, this method should do nothing.
*/
revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void>;
/**
* Whether to skip local PKCE validation.
*
*
* If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server.
*
*
* NOTE: This should only be true if the upstream server is performing the actual PKCE validation.
*/
skipLocalPkceValidation?: boolean;
}
}
/**
* Slim implementation useful for token verification
*/
export interface OAuthTokenVerifier {
/**
* Verifies an access token and returns information about it.
*/
verifyAccessToken(token: string): Promise<AuthInfo>;
}

View file

@ -1,12 +1,13 @@
import { mcpAuthRouter, AuthRouterOptions } from './router.js';
import { mcpAuthRouter, AuthRouterOptions, mcpAuthMetadataRouter, AuthMetadataOptions } from './router.js';
import { OAuthServerProvider, AuthorizationParams } from './provider.js';
import { OAuthRegisteredClientsStore } from './clients.js';
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js';
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js';
import express, { Response } from 'express';
import supertest from 'supertest';
import { AuthInfo } from './types.js';
import { InvalidTokenError } from './errors.js';
describe('MCP Auth Router', () => {
// Setup mock provider with full capabilities
const mockClientStore: OAuthRegisteredClientsStore = {
@ -246,6 +247,29 @@ describe('MCP Auth Router', () => {
expect(response.body.revocation_endpoint_auth_methods_supported).toBeUndefined();
expect(response.body.service_documentation).toBeUndefined();
});
it('provides protected resource metadata', async () => {
// Setup router with draft protocol version
const draftApp = express();
const options: AuthRouterOptions = {
provider: mockProvider,
issuerUrl: new URL('https://mcp.example.com'),
scopesSupported: ['read', 'write'],
resourceName: 'Test API'
};
draftApp.use(mcpAuthRouter(options));
const response = await supertest(draftApp)
.get('/.well-known/oauth-protected-resource');
expect(response.status).toBe(200);
// Verify protected resource metadata
expect(response.body.resource).toBe('https://mcp.example.com/');
expect(response.body.authorization_servers).toContain('https://mcp.example.com/');
expect(response.body.scopes_supported).toEqual(['read', 'write']);
expect(response.body.resource_name).toBe('Test API');
});
});
describe('Endpoint routing', () => {
@ -358,4 +382,101 @@ describe('MCP Auth Router', () => {
expect(revokeResponse.status).toBe(404);
});
});
});
});
describe('MCP Auth Metadata Router', () => {
const mockOAuthMetadata : OAuthMetadata = {
issuer: 'https://auth.example.com/',
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["client_secret_post"],
}
describe('Router creation', () => {
it('successfully creates router with valid options', () => {
const options: AuthMetadataOptions = {
oauthMetadata: mockOAuthMetadata,
resourceServerUrl: new URL('https://api.example.com'),
};
expect(() => mcpAuthMetadataRouter(options)).not.toThrow();
});
});
describe('Metadata endpoints', () => {
let app: express.Express;
beforeEach(() => {
app = express();
const options: AuthMetadataOptions = {
oauthMetadata: mockOAuthMetadata,
resourceServerUrl: new URL('https://api.example.com'),
serviceDocumentationUrl: new URL('https://docs.example.com'),
scopesSupported: ['read', 'write'],
resourceName: 'Test API'
};
app.use(mcpAuthMetadataRouter(options));
});
it('returns OAuth authorization server metadata', async () => {
const response = await supertest(app)
.get('/.well-known/oauth-authorization-server');
expect(response.status).toBe(200);
// Verify metadata points to authorization server
expect(response.body.issuer).toBe('https://auth.example.com/');
expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize');
expect(response.body.token_endpoint).toBe('https://auth.example.com/token');
expect(response.body.response_types_supported).toEqual(['code']);
expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']);
expect(response.body.code_challenge_methods_supported).toEqual(['S256']);
expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post']);
});
it('returns OAuth protected resource metadata', async () => {
const response = await supertest(app)
.get('/.well-known/oauth-protected-resource');
expect(response.status).toBe(200);
// Verify protected resource metadata
expect(response.body.resource).toBe('https://api.example.com/');
expect(response.body.authorization_servers).toEqual(['https://auth.example.com/']);
expect(response.body.scopes_supported).toEqual(['read', 'write']);
expect(response.body.resource_name).toBe('Test API');
expect(response.body.resource_documentation).toBe('https://docs.example.com/');
});
it('works with minimal configuration', async () => {
const minimalApp = express();
const options: AuthMetadataOptions = {
oauthMetadata: mockOAuthMetadata,
resourceServerUrl: new URL('https://api.example.com'),
};
minimalApp.use(mcpAuthMetadataRouter(options));
const authResponse = await supertest(minimalApp)
.get('/.well-known/oauth-authorization-server');
expect(authResponse.status).toBe(200);
expect(authResponse.body.issuer).toBe('https://auth.example.com/');
expect(authResponse.body.service_documentation).toBeUndefined();
expect(authResponse.body.scopes_supported).toBeUndefined();
const resourceResponse = await supertest(minimalApp)
.get('/.well-known/oauth-protected-resource');
expect(resourceResponse.status).toBe(200);
expect(resourceResponse.body.resource).toBe('https://api.example.com/');
expect(resourceResponse.body.authorization_servers).toEqual(['https://auth.example.com/']);
expect(resourceResponse.body.scopes_supported).toBeUndefined();
expect(resourceResponse.body.resource_name).toBeUndefined();
expect(resourceResponse.body.resource_documentation).toBeUndefined();
});
});
});

View file

@ -5,6 +5,7 @@ import { authorizationHandler, AuthorizationHandlerOptions } from "./handlers/au
import { revocationHandler, RevocationHandlerOptions } from "./handlers/revoke.js";
import { metadataHandler } from "./handlers/metadata.js";
import { OAuthServerProvider } from "./provider.js";
import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../shared/auth.js";
export type AuthRouterOptions = {
/**
@ -19,7 +20,7 @@ export type AuthRouterOptions = {
/**
* The base URL of the authorization server to use for the metadata endpoints.
*
*
* If not provided, the issuer URL will be used as the base URL.
*/
baseUrl?: URL;
@ -29,6 +30,17 @@ export type AuthRouterOptions = {
*/
serviceDocumentationUrl?: URL;
/**
* An optional list of scopes supported by this authorization server
*/
scopesSupported?: string[];
/**
* The resource name to be displayed in protected resource metadata
*/
resourceName?: string;
// Individual options per route
authorizationOptions?: Omit<AuthorizationHandlerOptions, "provider">;
clientRegistrationOptions?: Omit<ClientRegistrationHandlerOptions, "clientsStore">;
@ -36,37 +48,37 @@ export type AuthRouterOptions = {
tokenOptions?: Omit<TokenHandlerOptions, "provider">;
};
/**
* Installs standard MCP authorization endpoints, including dynamic client registration and token revocation (if supported). Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients.
*
* By default, rate limiting is applied to all endpoints to prevent abuse.
*
* This router MUST be installed at the application root, like so:
*
* const app = express();
* app.use(mcpAuthRouter(...));
*/
export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
const issuer = options.issuerUrl;
const baseUrl = options.baseUrl;
const checkIssuerUrl = (issuer: URL): void => {
// Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of testing
if (issuer.protocol !== "https:" && issuer.hostname !== "localhost" && issuer.hostname !== "127.0.0.1") {
throw new Error("Issuer URL must be HTTPS");
}
if (issuer.hash) {
throw new Error("Issuer URL must not have a fragment");
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");
throw new Error(`Issuer URL must not have a query string: ${issuer}`);
}
}
export const createOAuthMetadata = (options: {
provider: OAuthServerProvider,
issuerUrl: URL,
baseUrl?: URL
serviceDocumentationUrl?: URL,
scopesSupported?: string[];
}): OAuthMetadata => {
const issuer = options.issuerUrl;
const baseUrl = options.baseUrl;
checkIssuerUrl(issuer);
const authorization_endpoint = "/authorize";
const token_endpoint = "/token";
const registration_endpoint = options.provider.clientsStore.registerClient ? "/register" : undefined;
const revocation_endpoint = options.provider.revokeToken ? "/revoke" : undefined;
const metadata = {
const metadata: OAuthMetadata = {
issuer: issuer.href,
service_documentation: options.serviceDocumentationUrl?.href,
@ -78,29 +90,56 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
token_endpoint_auth_methods_supported: ["client_secret_post"],
grant_types_supported: ["authorization_code", "refresh_token"],
scopes_supported: options.scopesSupported,
revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined,
revocation_endpoint_auth_methods_supported: revocation_endpoint ? ["client_secret_post"] : undefined,
registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined,
};
return metadata
}
/**
* Installs standard MCP authorization server endpoints, including dynamic client registration and token revocation (if supported).
* Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients.
* Note: if your MCP server is only a resource server and not an authorization server, use mcpAuthMetadataRouter instead.
*
* By default, rate limiting is applied to all endpoints to prevent abuse.
*
* This router MUST be installed at the application root, like so:
*
* const app = express();
* app.use(mcpAuthRouter(...));
*/
export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
const oauthMetadata = createOAuthMetadata(options);
const router = express.Router();
router.use(
authorization_endpoint,
new URL(oauthMetadata.authorization_endpoint).pathname,
authorizationHandler({ provider: options.provider, ...options.authorizationOptions })
);
router.use(
token_endpoint,
new URL(oauthMetadata.token_endpoint).pathname,
tokenHandler({ provider: options.provider, ...options.tokenOptions })
);
router.use("/.well-known/oauth-authorization-server", metadataHandler(metadata));
router.use(mcpAuthMetadataRouter({
oauthMetadata,
// This router is used for AS+RS combo's, so the issuer is also the resource server
resourceServerUrl: new URL(oauthMetadata.issuer),
serviceDocumentationUrl: options.serviceDocumentationUrl,
scopesSupported: options.scopesSupported,
resourceName: options.resourceName
}));
if (registration_endpoint) {
if (oauthMetadata.registration_endpoint) {
router.use(
registration_endpoint,
new URL(oauthMetadata.registration_endpoint).pathname,
clientRegistrationHandler({
clientsStore: options.provider.clientsStore,
...options,
@ -108,12 +147,80 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
);
}
if (revocation_endpoint) {
if (oauthMetadata.revocation_endpoint) {
router.use(
revocation_endpoint,
new URL(oauthMetadata.revocation_endpoint).pathname,
revocationHandler({ provider: options.provider, ...options.revocationOptions })
);
}
return router;
}
}
export type AuthMetadataOptions = {
/**
* OAuth Metadata as would be returned from the authorization server
* this MCP server relies on
*/
oauthMetadata: OAuthMetadata;
/**
* The url of the MCP server, for use in protected resource metadata
*/
resourceServerUrl: URL;
/**
* The url for documentation for the MCP server
*/
serviceDocumentationUrl?: URL;
/**
* An optional list of scopes supported by this MCP server
*/
scopesSupported?: string[];
/**
* An optional resource name to display in resource metadata
*/
resourceName?: string;
}
export function mcpAuthMetadataRouter(options: AuthMetadataOptions) {
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,
};
router.use("/.well-known/oauth-protected-resource", metadataHandler(protectedResourceMetadata));
// Always add this for backwards compatibility
router.use("/.well-known/oauth-authorization-server", metadataHandler(options.oauthMetadata));
return router;
}
/**
* Helper function to construct the OAuth 2.0 Protected Resource Metadata URL
* from a given server URL. This replaces the path with the standard metadata endpoint.
*
* @param serverUrl - The base URL of the protected resource server
* @returns The URL for the OAuth protected resource metadata endpoint
*
* @example
* getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))
* // Returns: 'https://api.example.com/.well-known/oauth-protected-resource'
*/
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
return new URL('/.well-known/oauth-protected-resource', serverUrl).href;
}

View file

@ -109,6 +109,44 @@ export const OAuthTokenRevocationRequestSchema = z.object({
token_type_hint: z.string().optional(),
}).strip();
/**
* RFC 9728 OAuth Protected Resource Metadata
*/
export const OAuthProtectedResourceMetadataSchema = z.object({
// REQUIRED fields
resource: z.string().url(),
// OPTIONAL fields
authorization_servers: z.array(z.string().url()).optional(),
jwks_uri: z.string().url().optional(),
scopes_supported: z.array(z.string()).optional(),
bearer_methods_supported: z.array(z.string()).optional(),
resource_signing_alg_values_supported: z.array(z.string()).optional(),
resource_name: z.string().optional(),
resource_documentation: z.string().url().optional(),
resource_policy_uri: z.string().url().optional(),
resource_tos_uri: z.string().url().optional(),
tls_client_certificate_bound_access_tokens: z.boolean().optional(),
authorization_details_types_supported: z.array(z.string()).optional(),
dpop_signing_alg_values_supported: z.array(z.string()).optional(),
dpop_bound_access_tokens_required: z.boolean().optional(),
// Signed metadata JWT
signed_metadata: z.string().optional()
}).strict();
export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
export type OAuthTokens = z.infer<typeof OAuthTokensSchema>;
export type OAuthErrorResponse = z.infer<typeof OAuthErrorResponseSchema>;
@ -116,4 +154,5 @@ export type OAuthClientMetadata = z.infer<typeof OAuthClientMetadataSchema>;
export type OAuthClientInformation = z.infer<typeof OAuthClientInformationSchema>;
export type OAuthClientInformationFull = z.infer<typeof OAuthClientInformationFullSchema>;
export type OAuthClientRegistrationError = z.infer<typeof OAuthClientRegistrationErrorSchema>;
export type OAuthTokenRevocationRequest = z.infer<typeof OAuthTokenRevocationRequestSchema>;
export type OAuthTokenRevocationRequest = z.infer<typeof OAuthTokenRevocationRequestSchema>;
export type OAuthProtectedResourceMetadata = z.infer<typeof OAuthProtectedResourceMetadataSchema>;