From d634d5a1a501bc4a63cfb0b174525cdf8c92e638 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 May 2025 14:33:34 +0100 Subject: [PATCH 1/8] add support for .oauth-protected-resource metadata endpoint and www-authenticate --- src/examples/server/inMemoryOAuthProvider.ts | 134 +++++++++++++++++++ src/examples/server/simpleStreamableHttp.ts | 82 ++++++++++-- src/server/auth/handlers/metadata.ts | 6 +- src/server/auth/middleware/bearerAuth.ts | 22 ++- src/server/auth/router.test.ts | 28 +++- src/server/auth/router.ts | 117 +++++++++++++--- src/shared/auth.ts | 41 +++++- 7 files changed, 396 insertions(+), 34 deletions(-) create mode 100644 src/examples/server/inMemoryOAuthProvider.ts diff --git a/src/examples/server/inMemoryOAuthProvider.ts b/src/examples/server/inMemoryOAuthProvider.ts new file mode 100644 index 00000000..58161d68 --- /dev/null +++ b/src/examples/server/inMemoryOAuthProvider.ts @@ -0,0 +1,134 @@ +import { randomUUID } from 'node:crypto'; +import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js'; +import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js'; +import { OAuthClientInformationFull, OAuthTokens } from 'src/shared/auth.js'; +import { Response } from "express"; +import { AuthInfo } from 'src/server/auth/types.js'; + + +/** + * Simple in-memory implementation of OAuth clients store for demo purposes. + * In production, this should be backed by a persistent database. + */ +export class InMemoryClientsStore implements OAuthRegisteredClientsStore { + private clients = new Map(); + + async getClient(clientId: string) { + return this.clients.get(clientId); + } + + async registerClient(clientMetadata: OAuthClientInformationFull) { + this.clients.set(clientMetadata.client_id, clientMetadata); + return clientMetadata; + } +} + +/** + * Simple in-memory implementation of OAuth server provider for demo purposes. + * In production, this should be backed by a persistent database with proper security measures. + */ +export class InMemoryAuthProvider implements OAuthServerProvider { + clientsStore = new InMemoryClientsStore(); + private codes = new Map(); + private tokens = new Map(); + + async authorize( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response + ): Promise { + const code = randomUUID(); + + const searchParams = new URLSearchParams({ + code, + }); + + 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 { + + // 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, + _codeVerifier?: string + ): Promise { + 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}`); + } + + // Remove the used code + this.codes.delete(authorizationCode); + + // Generate access token + const accessToken = randomUUID(); + const refreshToken = randomUUID(); + + const tokenData = { + accessToken, + refreshToken, + clientId: client.client_id, + scopes: codeData.params.scopes || [], + expiresAt: Date.now() + 3600000, // 1 hour + type: 'access' + }; + + // Store the token + this.tokens.set(accessToken, tokenData); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: 3600, + scope: (codeData.params.scopes || []).join(' '), + }; + } + + async exchangeRefreshToken( + _client: OAuthClientInformationFull, + _refreshToken: string, + _scopes?: string[] + ): Promise { + throw new Error('Not implemented for example demo'); + } + + async verifyAccessToken(token: string): Promise { + const tokenData = this.tokens.get(token); + if (!tokenData || tokenData.expiresAt < Date.now() || tokenData.type === 'refresh') { + throw new Error('Invalid or expired token'); + } + + return { + token, + clientId: tokenData.clientId, + scopes: tokenData.scopes, + expiresAt: Math.floor(tokenData.expiresAt / 1000), + }; + } +} diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 1933cc94..5bca0533 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -3,8 +3,14 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } 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 { InMemoryAuthProvider } from './inMemoryOAuthProvider.js'; + +// Check for OAuth flag +const useOAuth = process.argv.includes('--oauth'); // Create an MCP server with implementation details const getServer = () => { @@ -40,7 +46,7 @@ const getServer = () => { name: z.string().describe('Name to greet'), }, { - title: 'Multiple Greeting Tool', + title: 'Multiple Greeting Tool', readOnlyHint: true, openWorldHint: false }, @@ -159,14 +165,47 @@ const getServer = () => { return server; }; +const PORT = 3000; const app = express(); app.use(express.json()); +// Set up OAuth if enabled +let authMiddleware: any = null; +if (useOAuth) { + const provider = new InMemoryAuthProvider(); + // Create auth middleware for MCP endpoints + const serverUrl = new URL(`http://localhost:${PORT}`); + const issuerUrl = serverUrl; + + // Add OAuth routes + app.use(mcpAuthRouter({ + provider, + issuerUrl, + baseUrl: issuerUrl, + protectedResourceOptions: { + serverUrl, + resourceName: 'MCP Demo Server', + scopesSupported: ['mcp:tools'], + }, + })); + + + authMiddleware = requireBearerAuth({ + provider, + requiredScopes: ['mcp:tools'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(serverUrl), + }); +} + // 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 +273,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 +304,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 +332,17 @@ app.delete('/mcp', async (req: Request, res: Response) => { res.status(500).send('Error processing session termination'); } } -}); +}; + +// Set up DELETE route with conditional auth middleware +if (useOAuth && authMiddleware) { + app.delete('/mcp', authMiddleware, mcpDeleteHandler); +} else { + app.delete('/mcp', mcpDeleteHandler); +} -// Start the server -const PORT = 3000; app.listen(PORT, () => { - console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); + console.log(`MCP Streamable HTTP Server listening on port ${PORT} auth:${(useOAuth) ? 'enabled' : 'disabled'}`); }); // Handle server shutdown diff --git a/src/server/auth/handlers/metadata.ts b/src/server/auth/handlers/metadata.ts index 048a4d4a..444b8505 100644 --- a/src/server/auth/handlers/metadata.ts +++ b/src/server/auth/handlers/metadata.ts @@ -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; -} \ No newline at end of file +} diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index cd1b314a..5ea0cd1d 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -13,6 +13,11 @@ export type BearerAuthMiddlewareOptions = { * 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({ provider, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { return async (req, res, next) => { try { const authHeader = req.headers.authorization; @@ -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()); diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts index 86eda221..8af4edf7 100644 --- a/src/server/auth/router.test.ts +++ b/src/server/auth/router.test.ts @@ -246,6 +246,32 @@ describe('MCP Auth Router', () => { expect(response.body.revocation_endpoint_auth_methods_supported).toBeUndefined(); expect(response.body.service_documentation).toBeUndefined(); }); + + it('provides protected resource metadata when protocol version is draft', async () => { + // Setup router with draft protocol version + const draftApp = express(); + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('https://auth.example.com'), + protectedResourceOptions: { + serverUrl: new URL('https://api.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://api.example.com/'); + expect(response.body.authorization_servers).toContain('https://auth.example.com/'); + expect(response.body.scopes_supported).toEqual(['read', 'write']); + expect(response.body.resource_name).toBe('Test API'); + }); }); describe('Endpoint routing', () => { @@ -358,4 +384,4 @@ describe('MCP Auth Router', () => { expect(revokeResponse.status).toBe(404); }); }); -}); \ No newline at end of file +}); diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index 49d451c2..a988ef15 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -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 { 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; @@ -34,15 +35,29 @@ export type AuthRouterOptions = { clientRegistrationOptions?: Omit; revocationOptions?: Omit; tokenOptions?: Omit; + protectedResourceOptions?: Omit; }; +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: ${issuer}`); + } + if (issuer.search) { + throw new Error(`Issuer URL must not have a query string: ${issuer}`); + } +} + /** * 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(...)); */ @@ -50,16 +65,7 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { const issuer = options.issuerUrl; const baseUrl = options.baseUrl; - // 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"); - } - if (issuer.search) { - throw new Error("Issuer URL must not have a query string"); - } + checkIssuerUrl(issuer); const authorization_endpoint = "/authorize"; const token_endpoint = "/token"; @@ -84,6 +90,7 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined, }; + const router = express.Router(); router.use( @@ -98,6 +105,18 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { router.use("/.well-known/oauth-authorization-server", metadataHandler(metadata)); + // Always include protected resource metadata + const defaultProtectedResourceOptions = { + serverUrl: issuer, // Use issuer as default server URL + }; + + router.use(mcpProtectedResourceRouter({ + issuerUrl: issuer, + serviceDocumentationUrl: options.serviceDocumentationUrl, + ...defaultProtectedResourceOptions, + ...options.protectedResourceOptions + })) + if (registration_endpoint) { router.use( registration_endpoint, @@ -116,4 +135,72 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { } return router; -} \ No newline at end of file +} + + +export type ProtectedResourceRouterOptions = { + /** + * The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components. + */ + issuerUrl: URL; + + /** + * The MCP server URL that is proteted. + * + */ + serverUrl: URL; + + /** + * An optional URL of a page containing human-readable information that developers might want or need to know when using the authorization server. + */ + serviceDocumentationUrl?: URL; + + /** + * A list of valid scopes for the resource. + */ + scopesSupported?: Array; + + /** + * A human readable resource name for the MCP server + */ + resourceName?: string; +}; + + +export function mcpProtectedResourceRouter(options: ProtectedResourceRouterOptions) { + const issuer = options.issuerUrl; + checkIssuerUrl(issuer); + + const router = express.Router(); + + const protectedResourceMetadata: OAuthProtectedResourceMetadata = { + resource: options.serverUrl.href, + + authorization_servers: [ + issuer.href + ], + + scopes_supported: options.scopesSupported, + resource_name: options.resourceName, + resource_documentation: options.serviceDocumentationUrl?.href, + }; + + router.use("/.well-known/oauth-protected-resource", metadataHandler(protectedResourceMetadata)); + + 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; +} diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 60a28b80..d28cfa9d 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -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; export type OAuthTokens = z.infer; export type OAuthErrorResponse = z.infer; @@ -116,4 +154,5 @@ export type OAuthClientMetadata = z.infer; export type OAuthClientInformation = z.infer; export type OAuthClientInformationFull = z.infer; export type OAuthClientRegistrationError = z.infer; -export type OAuthTokenRevocationRequest = z.infer; \ No newline at end of file +export type OAuthTokenRevocationRequest = z.infer; +export type OAuthProtectedResourceMetadata = z.infer; From 055d95d7bb86e1d7ad885d624432196bdd9dbaaf Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 16 May 2025 19:53:13 +0100 Subject: [PATCH 2/8] test and types --- src/examples/server/inMemoryOAuthProvider.ts | 12 +- src/examples/server/simpleStreamableHttp.ts | 2 +- src/server/auth/middleware/bearerAuth.test.ts | 121 ++++++++++++++++++ 3 files changed, 127 insertions(+), 8 deletions(-) diff --git a/src/examples/server/inMemoryOAuthProvider.ts b/src/examples/server/inMemoryOAuthProvider.ts index 58161d68..b5d260bf 100644 --- a/src/examples/server/inMemoryOAuthProvider.ts +++ b/src/examples/server/inMemoryOAuthProvider.ts @@ -32,7 +32,7 @@ export class InMemoryAuthProvider implements OAuthServerProvider { private codes = new Map(); - private tokens = new Map(); + private tokens = new Map(); async authorize( client: OAuthClientInformationFull, @@ -87,12 +87,10 @@ export class InMemoryAuthProvider implements OAuthServerProvider { this.codes.delete(authorizationCode); // Generate access token - const accessToken = randomUUID(); - const refreshToken = randomUUID(); + const token = randomUUID(); const tokenData = { - accessToken, - refreshToken, + token, clientId: client.client_id, scopes: codeData.params.scopes || [], expiresAt: Date.now() + 3600000, // 1 hour @@ -100,10 +98,10 @@ export class InMemoryAuthProvider implements OAuthServerProvider { }; // Store the token - this.tokens.set(accessToken, tokenData); + this.tokens.set(token, tokenData); return { - access_token: accessToken, + access_token: token, token_type: 'Bearer', expires_in: 3600, scope: (codeData.params.scopes || []).join(' '), diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 5bca0533..d7f3d5a2 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -170,7 +170,7 @@ const app = express(); app.use(express.json()); // Set up OAuth if enabled -let authMiddleware: any = null; +let authMiddleware = null; if (useOAuth) { const provider = new InMemoryAuthProvider(); // Create auth middleware for MCP endpoints diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index 43cbfa0a..c672f175 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -304,4 +304,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({ provider: mockProvider, 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({ provider: mockProvider, 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({ provider: mockProvider, 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({ provider: mockProvider, 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({ + provider: mockProvider, + 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({ provider: mockProvider, 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(); + }); + }); }); \ No newline at end of file From 2270239343af8b655f9cbb25ac2f1e2ea7d663cd Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 16 May 2025 20:07:16 +0100 Subject: [PATCH 3/8] thread throughs scopes --- src/examples/server/simpleStreamableHttp.ts | 2 +- src/server/auth/router.ts | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index d7f3d5a2..b5158194 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -182,10 +182,10 @@ if (useOAuth) { provider, issuerUrl, baseUrl: issuerUrl, + scopesSupported: ['mcp:tools'], protectedResourceOptions: { serverUrl, resourceName: 'MCP Demo Server', - scopesSupported: ['mcp:tools'], }, })); diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index a988ef15..795764ed 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -5,7 +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 { OAuthProtectedResourceMetadata } from "../../shared/auth.js"; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../shared/auth.js"; export type AuthRouterOptions = { /** @@ -30,12 +30,17 @@ export type AuthRouterOptions = { */ serviceDocumentationUrl?: URL; + /** + * An optional list of scopes supported by this authorization server + */ + scopesSupported?: string[]; + // Individual options per route authorizationOptions?: Omit; clientRegistrationOptions?: Omit; revocationOptions?: Omit; tokenOptions?: Omit; - protectedResourceOptions?: Omit; + protectedResourceOptions?: Omit; }; const checkIssuerUrl = (issuer: URL): void => { @@ -72,7 +77,7 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { 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, @@ -84,6 +89,8 @@ 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, @@ -113,6 +120,7 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { router.use(mcpProtectedResourceRouter({ issuerUrl: issuer, serviceDocumentationUrl: options.serviceDocumentationUrl, + scopesSupported: options.scopesSupported, ...defaultProtectedResourceOptions, ...options.protectedResourceOptions })) From 374580fb71e1ecf79e8dcdbc87ab2f291664b541 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 16 May 2025 20:16:57 +0100 Subject: [PATCH 4/8] have example separate AS and RS --- src/examples/server/simpleStreamableHttp.ts | 44 +++++++++++++++------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index b5158194..c3b9a112 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from '../../server/auth/router.js'; +import { mcpAuthRouter, mcpProtectedResourceRouter, getOAuthProtectedResourceMetadataUrl } 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'; @@ -165,7 +165,9 @@ const getServer = () => { return server; }; -const PORT = 3000; +const MCP_PORT = 3000; +const AUTH_PORT = 3001; + const app = express(); app.use(express.json()); @@ -173,27 +175,45 @@ app.use(express.json()); let authMiddleware = null; if (useOAuth) { const provider = new InMemoryAuthProvider(); + // Create auth middleware for MCP endpoints - const serverUrl = new URL(`http://localhost:${PORT}`); - const issuerUrl = serverUrl; + const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); + const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - // Add OAuth routes - app.use(mcpAuthRouter({ + // Create separate auth server app + const authApp = express(); + authApp.use(express.json()); + + // Add OAuth routes to the auth server + authApp.use(mcpAuthRouter({ provider, - issuerUrl, - baseUrl: issuerUrl, + issuerUrl: authServerUrl, + baseUrl: authServerUrl, scopesSupported: ['mcp:tools'], + // This endpoint is set up on the Authorization server, but really shouldn't be. protectedResourceOptions: { - serverUrl, + serverUrl: mcpServerUrl, resourceName: 'MCP Demo Server', }, })); + // Start the auth server + authApp.listen(AUTH_PORT, () => { + console.log(`OAuth Authorization Server listening on port ${AUTH_PORT}`); + }); + + // Add protected resource metadata to the main MCP server + app.use(mcpProtectedResourceRouter({ + issuerUrl: authServerUrl, + serverUrl: mcpServerUrl, + scopesSupported: ['mcp:tools'], + resourceName: 'MCP Demo Server', + })); authMiddleware = requireBearerAuth({ provider, requiredScopes: ['mcp:tools'], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(serverUrl), + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), }); } @@ -341,8 +361,8 @@ if (useOAuth && authMiddleware) { app.delete('/mcp', mcpDeleteHandler); } -app.listen(PORT, () => { - console.log(`MCP Streamable HTTP Server listening on port ${PORT} auth:${(useOAuth) ? 'enabled' : 'disabled'}`); +app.listen(MCP_PORT, () => { + console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); }); // Handle server shutdown From 2cc5a8c5dec9205d1577b26299806263cb0e7216 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 16 May 2025 20:27:19 +0100 Subject: [PATCH 5/8] make inmemory explicitly demo --- ...emoryOAuthProvider.ts => demoInMemoryOAuthProvider.ts} | 8 ++++---- src/examples/server/simpleStreamableHttp.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) rename src/examples/server/{inMemoryOAuthProvider.ts => demoInMemoryOAuthProvider.ts} (92%) diff --git a/src/examples/server/inMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts similarity index 92% rename from src/examples/server/inMemoryOAuthProvider.ts rename to src/examples/server/demoInMemoryOAuthProvider.ts index b5d260bf..79a5b3ae 100644 --- a/src/examples/server/inMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -10,7 +10,7 @@ import { AuthInfo } from 'src/server/auth/types.js'; * Simple in-memory implementation of OAuth clients store for demo purposes. * In production, this should be backed by a persistent database. */ -export class InMemoryClientsStore implements OAuthRegisteredClientsStore { +export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { private clients = new Map(); async getClient(clientId: string) { @@ -25,10 +25,10 @@ export class InMemoryClientsStore implements OAuthRegisteredClientsStore { /** * Simple in-memory implementation of OAuth server provider for demo purposes. - * In production, this should be backed by a persistent database with proper security measures. + * Do not use this in production. */ -export class InMemoryAuthProvider implements OAuthServerProvider { - clientsStore = new InMemoryClientsStore(); +export class DemoInMemoryAuthProvider implements OAuthServerProvider { + clientsStore = new DemoInMemoryClientsStore(); private codes = new Map(); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index c3b9a112..411299de 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -7,7 +7,7 @@ import { mcpAuthRouter, mcpProtectedResourceRouter, getOAuthProtectedResourceMet import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; -import { InMemoryAuthProvider } from './inMemoryOAuthProvider.js'; +import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); @@ -174,7 +174,7 @@ app.use(express.json()); // Set up OAuth if enabled let authMiddleware = null; if (useOAuth) { - const provider = new InMemoryAuthProvider(); + const provider = new DemoInMemoryAuthProvider(); // Create auth middleware for MCP endpoints const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); From 34ada58ae47828a1ceef517e78f56b289ff586c0 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 16 May 2025 20:38:05 +0100 Subject: [PATCH 6/8] fix type --- src/examples/server/demoInMemoryOAuthProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 79a5b3ae..dd93e849 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -118,7 +118,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { async verifyAccessToken(token: string): Promise { const tokenData = this.tokens.get(token); - if (!tokenData || tokenData.expiresAt < Date.now() || tokenData.type === 'refresh') { + if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { throw new Error('Invalid or expired token'); } From bbafc8523464ef8d0e04f28f7a1d35a1fd7d0bcc Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 16 May 2025 20:46:03 +0100 Subject: [PATCH 7/8] fix types --- src/server/auth/router.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts index 8af4edf7..ef1e412c 100644 --- a/src/server/auth/router.test.ts +++ b/src/server/auth/router.test.ts @@ -253,9 +253,9 @@ describe('MCP Auth Router', () => { const options: AuthRouterOptions = { provider: mockProvider, issuerUrl: new URL('https://auth.example.com'), + scopesSupported: ['read', 'write'], protectedResourceOptions: { serverUrl: new URL('https://api.example.com'), - scopesSupported: ['read', 'write'], resourceName: 'Test API' } }; From b4e8dcda13d19023a8af23a35d4b65bb017d4ecb Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 19 May 2025 11:30:12 +0100 Subject: [PATCH 8/8] fix client example --- src/examples/server/demoInMemoryOAuthProvider.ts | 5 ++++- src/examples/server/simpleStreamableHttp.ts | 14 ++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index dd93e849..48c5b78b 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -44,6 +44,9 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { const searchParams = new URLSearchParams({ code, }); + if (params.state !== undefined) { + searchParams.set('state', params.state); + } this.codes.set(code, { client, @@ -102,7 +105,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { return { access_token: token, - token_type: 'Bearer', + token_type: 'bearer', expires_in: 3600, scope: (codeData.params.scopes || []).join(' '), }; diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 411299de..a966a422 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { mcpAuthRouter, mcpProtectedResourceRouter, getOAuthProtectedResourceMetadataUrl } from '../../server/auth/router.js'; +import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } 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'; @@ -188,7 +188,6 @@ if (useOAuth) { authApp.use(mcpAuthRouter({ provider, issuerUrl: authServerUrl, - baseUrl: authServerUrl, scopesSupported: ['mcp:tools'], // This endpoint is set up on the Authorization server, but really shouldn't be. protectedResourceOptions: { @@ -202,12 +201,15 @@ if (useOAuth) { console.log(`OAuth Authorization Server listening on port ${AUTH_PORT}`); }); - // Add protected resource metadata to the main MCP server - app.use(mcpProtectedResourceRouter({ + // Add both resource metadata and oauth server metadata (for backwards compatiblity) to the main MCP server + app.use(mcpAuthRouter({ + provider, issuerUrl: authServerUrl, - serverUrl: mcpServerUrl, scopesSupported: ['mcp:tools'], - resourceName: 'MCP Demo Server', + protectedResourceOptions: { + serverUrl: mcpServerUrl, + resourceName: 'MCP Demo Server', + }, })); authMiddleware = requireBearerAuth({