From d634d5a1a501bc4a63cfb0b174525cdf8c92e638 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 May 2025 14:33:34 +0100 Subject: [PATCH 01/23] 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 02/23] 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 03/23] 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 04/23] 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 05/23] 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 06/23] 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 07/23] 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 08/23] 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({ From bb9f560fbef9febe94d6cb7200454357d16e11dd Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 11:26:17 +0100 Subject: [PATCH 09/23] fixup comments --- .../server/demoInMemoryOAuthProvider.ts | 22 ++++++++++++------- src/examples/server/simpleStreamableHttp.ts | 6 ++++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 48c5b78b..eaa2320d 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -7,8 +7,12 @@ 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. + * 🚨 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 DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { private clients = new Map(); @@ -24,8 +28,12 @@ export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { } /** - * Simple in-memory implementation of OAuth server provider for demo purposes. - * Do not use this in production. + * 🚨 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(); @@ -75,6 +83,8 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { 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 { const codeData = this.codes.get(authorizationCode); @@ -86,10 +96,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { 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 token = randomUUID(); const tokenData = { @@ -100,7 +107,6 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { type: 'access' }; - // Store the token this.tokens.set(token, tokenData); return { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index a966a422..3bbd2400 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -181,6 +181,9 @@ if (useOAuth) { const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); // Create separate auth server app + // NOTE: This is a separate app on a separate domain to illustrate + // how to use a separate OAuth Authorization Server for demonstration + // purposes. Creating const authApp = express(); authApp.use(express.json()); @@ -189,7 +192,8 @@ if (useOAuth) { provider, issuerUrl: authServerUrl, scopesSupported: ['mcp:tools'], - // This endpoint is set up on the Authorization server, but really shouldn't be. + // This endpoint is set up on the Authorization server, because + // we're abusing the SDK to create a standalone Authorization server. protectedResourceOptions: { serverUrl: mcpServerUrl, resourceName: 'MCP Demo Server', From 1f0c45fcbe1235d423d126a7eba2bb37b136e495 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 12:10:55 +0100 Subject: [PATCH 10/23] refactor metadata endpoints to cleanup --- src/examples/server/simpleStreamableHttp.ts | 22 +++--- src/server/auth/router.ts | 87 ++++++++++++++++----- 2 files changed, 77 insertions(+), 32 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 3bbd2400..723384ea 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, 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'; @@ -181,9 +181,10 @@ if (useOAuth) { const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); // Create separate auth server app - // NOTE: This is a separate app on a separate domain to illustrate - // how to use a separate OAuth Authorization Server for demonstration - // purposes. Creating + // 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 authApp = express(); authApp.use(express.json()); @@ -205,15 +206,12 @@ if (useOAuth) { console.log(`OAuth Authorization Server listening on port ${AUTH_PORT}`); }); - // Add both resource metadata and oauth server metadata (for backwards compatiblity) to the main MCP server - app.use(mcpAuthRouter({ + // Add metadata routes to the main MCP server + app.use(mcpAuthMetadataRouter({ provider, - issuerUrl: authServerUrl, - scopesSupported: ['mcp:tools'], - protectedResourceOptions: { - serverUrl: mcpServerUrl, - resourceName: 'MCP Demo Server', - }, + resourceServerUrl: mcpServerUrl, + authorizationServerUrl: authServerUrl, + resourceName: 'MCP Demo Server', })); authMiddleware = requireBearerAuth({ diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index 795764ed..06e957af 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -56,17 +56,13 @@ const checkIssuerUrl = (issuer: URL): void => { } } -/** - * 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 createOAuthMetadata = (options: { + provider: OAuthServerProvider, + issuerUrl: URL, + baseUrl?: URL + serviceDocumentationUrl?: URL, + scopesSupported?: string[]; +}): OAuthMetadata => { const issuer = options.issuerUrl; const baseUrl = options.baseUrl; @@ -97,16 +93,33 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { 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 metadata = createOAuthMetadata(options); const router = express.Router(); router.use( - authorization_endpoint, + new URL(metadata.authorization_endpoint).pathname, authorizationHandler({ provider: options.provider, ...options.authorizationOptions }) ); router.use( - token_endpoint, + new URL(metadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions }) ); @@ -114,20 +127,21 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { // Always include protected resource metadata const defaultProtectedResourceOptions = { - serverUrl: issuer, // Use issuer as default server URL + // Use issuer as the server URL if no override provided + serverUrl: new URL(metadata.issuer), }; router.use(mcpProtectedResourceRouter({ - issuerUrl: issuer, + issuerUrl: options.issuerUrl, serviceDocumentationUrl: options.serviceDocumentationUrl, scopesSupported: options.scopesSupported, ...defaultProtectedResourceOptions, ...options.protectedResourceOptions })) - if (registration_endpoint) { + if (metadata.registration_endpoint) { router.use( - registration_endpoint, + new URL(metadata.registration_endpoint).pathname, clientRegistrationHandler({ clientsStore: options.provider.clientsStore, ...options, @@ -135,9 +149,9 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { ); } - if (revocation_endpoint) { + if (metadata.revocation_endpoint) { router.use( - revocation_endpoint, + new URL(metadata.revocation_endpoint).pathname, revocationHandler({ provider: options.provider, ...options.revocationOptions }) ); } @@ -145,6 +159,40 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { return router; } +export function mcpAuthMetadataRouter(options: { + provider: OAuthServerProvider; + resourceServerUrl: URL; + authorizationServerUrl: URL; + authorizationServerBaseUrl?: URL; + serviceDocumentationUrl?: URL; + scopesSupported?: string[]; + resourceName?: string; +}) { + const router = express.Router(); + + const { provider, + serviceDocumentationUrl, + scopesSupported + } = options; + + const metadata = createOAuthMetadata({ + provider, + issuerUrl: options.authorizationServerUrl, + baseUrl: options.authorizationServerBaseUrl, + serviceDocumentationUrl, + scopesSupported, + }); + router.use("/.well-known/oauth-authorization-server", metadataHandler(metadata)); + + router.use(mcpProtectedResourceRouter({ + serverUrl: options.resourceServerUrl, + issuerUrl: options.authorizationServerUrl, + serviceDocumentationUrl, + scopesSupported, + })) + + return router; +} export type ProtectedResourceRouterOptions = { /** @@ -174,7 +222,6 @@ export type ProtectedResourceRouterOptions = { resourceName?: string; }; - export function mcpProtectedResourceRouter(options: ProtectedResourceRouterOptions) { const issuer = options.issuerUrl; checkIssuerUrl(issuer); From fb1c3b71331450fd6473e96e9c0f5129d2d87eb6 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 13:36:26 +0100 Subject: [PATCH 11/23] almost working w/ forwarding --- .../server/demoRemoteOAuthProvider.ts | 142 ++++++++++++++++++ src/examples/server/simpleStreamableHttp.ts | 55 ++++--- src/server/auth/router.ts | 12 +- 3 files changed, 185 insertions(+), 24 deletions(-) create mode 100644 src/examples/server/demoRemoteOAuthProvider.ts diff --git a/src/examples/server/demoRemoteOAuthProvider.ts b/src/examples/server/demoRemoteOAuthProvider.ts new file mode 100644 index 00000000..1a5c820d --- /dev/null +++ b/src/examples/server/demoRemoteOAuthProvider.ts @@ -0,0 +1,142 @@ +import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from "src/shared/auth.js"; +import { Response } from "express"; +import { AuthorizationParams, OAuthServerProvider } from "src/server/auth/provider.js"; +import { OAuthRegisteredClientsStore } from "src/server/auth/clients.js"; +import { AuthInfo } from "src/server/auth/types.js"; + + +/** + * An OAuthProvider that forwards requests to endpoints provided in OAuthMetadata. + * + * This provider acts as a client to an external OAuth server and forwards + * all authorization, token, and verification requests. + */ +export class ForwardingOAuthProvider implements OAuthServerProvider { + private metadata: OAuthMetadata; + // private clients = new Map(); + + constructor(oauthMetadata: OAuthMetadata) { + this.metadata = oauthMetadata; + } + + // This is not needed. + clientsStore: OAuthRegisteredClientsStore = { + async getClient(_clientId: string) { + throw new Error("Not Implemented"); + }, + + async registerClient(_clientMetadata: OAuthClientInformationFull) { + throw new Error("Not Implemented"); + } + }; + + async authorize( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response + ): Promise { + // Forward to authorization endpoint from metadata + const authEndpoint = this.metadata.authorization_endpoint; + + const searchParams = new URLSearchParams({ + client_id: client.client_id, + response_type: 'code', + redirect_uri: client.redirect_uris[0], + code_challenge: params.codeChallenge, + code_challenge_method: 'S256', + }); + + if (params.state) { + searchParams.set('state', params.state); + } + + if (params.scopes && params.scopes.length > 0) { + searchParams.set('scope', params.scopes.join(' ')); + } + + const authUrl = new URL(authEndpoint); + authUrl.search = searchParams.toString(); + + res.redirect(authUrl.toString()); + } + + async challengeForAuthorizationCode( + _client: OAuthClientInformationFull, + _authorizationCode: string + ): Promise { + throw new Error("Not Implemented"); + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + codeVerifier?: string + ): Promise { + // Forward to token endpoint from metadata + const tokenEndpoint = this.metadata.token_endpoint; + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code: authorizationCode, + client_id: client.client_id, + redirect_uri: client.redirect_uris[0], + }); + + if (codeVerifier) { + params.append('code_verifier', codeVerifier); + } + + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!response.ok) { + throw new Error(`Failed to exchange token: ${response.statusText}`); + } + + return await response.json(); + } + + async exchangeRefreshToken( + _client: OAuthClientInformationFull, + _refreshToken: string, + _scopes?: string[] + ): Promise { + // Not implemented for brevity, but follows token above. + throw new Error("Not Implemented"); + } + + async verifyAccessToken(token: string): Promise { + // Use introspection endpoint if available, or userinfo endpoint + const endpoint = this.metadata.introspection_endpoint; + + if (!endpoint) { + throw new Error('No token verification endpoint available in metadata'); + } + + const response = await fetch(endpoint, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error('Invalid or expired token'); + } + + const data = await response.json(); + + // Convert the response to AuthInfo format + return { + token, + clientId: data.client_id || data.azp, + scopes: data.scope ? data.scope.split(' ') : [], + expiresAt: data.exp || Math.floor(Date.now() / 1000) + 3600, + }; + } +} diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 723384ea..2712de4b 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -1,13 +1,15 @@ -import express, { Request, Response } from 'express'; +import express, { Request, Response, Express } from 'express'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; +import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, createOAuthMetadata } 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 { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; +import { ForwardingOAuthProvider } from './demoRemoteOAuthProvider.js'; +import { OAuthMetadata } from 'src/shared/auth.js'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); @@ -165,26 +167,13 @@ 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) { - const provider = new DemoInMemoryAuthProvider(); - - // Create auth middleware for MCP endpoints - const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); - const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - +const setupAuthServer = async (authServerUrl: URL): Promise => { // 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()); @@ -196,8 +185,7 @@ if (useOAuth) { // This endpoint is set up on the Authorization server, because // we're abusing the SDK to create a standalone Authorization server. protectedResourceOptions: { - serverUrl: mcpServerUrl, - resourceName: 'MCP Demo Server', + serverUrl: authServerUrl, }, })); @@ -206,16 +194,41 @@ if (useOAuth) { console.log(`OAuth Authorization Server listening on port ${AUTH_PORT}`); }); + const resp = await fetch(new URL("/.well-known/oauth-authorization-server", authServerUrl)); + const oauthMetadata: OAuthMetadata = await resp.json(); + + return oauthMetadata; +} + +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 = await setupAuthServer(authServerUrl); + + const forwardingProvider = new ForwardingOAuthProvider( + oauthMetadata + ); // Add metadata routes to the main MCP server app.use(mcpAuthMetadataRouter({ - provider, + provider: forwardingProvider, resourceServerUrl: mcpServerUrl, authorizationServerUrl: authServerUrl, resourceName: 'MCP Demo Server', })); authMiddleware = requireBearerAuth({ - provider, + provider: forwardingProvider, requiredScopes: ['mcp:tools'], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), }); diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index 06e957af..ccc975ec 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -56,7 +56,7 @@ const checkIssuerUrl = (issuer: URL): void => { } } -const createOAuthMetadata = (options: { +export const createOAuthMetadata = (options: { provider: OAuthServerProvider, issuerUrl: URL, baseUrl?: URL @@ -159,7 +159,11 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { return router; } -export function mcpAuthMetadataRouter(options: { +export type AuthMetadataOptions = { + /** + * A provider implementing the actual authorization logic for this router. + * Note: the provider should reference an authorization server + */ provider: OAuthServerProvider; resourceServerUrl: URL; authorizationServerUrl: URL; @@ -167,7 +171,9 @@ export function mcpAuthMetadataRouter(options: { serviceDocumentationUrl?: URL; scopesSupported?: string[]; resourceName?: string; -}) { +} + +export function mcpAuthMetadataRouter(options: AuthMetadataOptions) { const router = express.Router(); const { provider, From 689d9b354ab9ce61a0767a7921e5cf7012c4edf2 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 15:23:36 +0100 Subject: [PATCH 12/23] separate AS/RS working --- .../server/demoInMemoryOAuthProvider.ts | 70 ++++++++++++++++++- .../server/demoRemoteOAuthProvider.ts | 34 ++++++--- src/examples/server/simpleStreamableHttp.ts | 51 +++----------- 3 files changed, 102 insertions(+), 53 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index eaa2320d..556d669c 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -1,9 +1,10 @@ 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 { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js'; +import express, { Request, Response } from "express"; import { AuthInfo } from 'src/server/auth/types.js'; +import { mcpAuthRouter } from 'src/server/auth/router.js'; /** @@ -139,3 +140,68 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { }; } } + + +export const setupAuthServer = async (authServerUrl: URL): Promise => { + // 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 + authApp.use(mcpAuthRouter({ + provider, + issuerUrl: authServerUrl, + scopesSupported: ['mcp:tools'], + // This metadata doesn't make sense on the Authorization server, but + // we're abusing the SDK to create a standalone Authorization server. + // Instead, developers should use established Authorization servers, + // and create the router based on their metadata. + protectedResourceOptions: { + serverUrl: authServerUrl, + }, + })); + + 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}`); + }); + + const resp = await fetch(new URL("/.well-known/oauth-authorization-server", authServerUrl)); + const oauthMetadata: OAuthMetadata = await resp.json(); + oauthMetadata.introspection_endpoint = new URL("/introspect", authServerUrl).href; + + return oauthMetadata; +} diff --git a/src/examples/server/demoRemoteOAuthProvider.ts b/src/examples/server/demoRemoteOAuthProvider.ts index 1a5c820d..630bd3b6 100644 --- a/src/examples/server/demoRemoteOAuthProvider.ts +++ b/src/examples/server/demoRemoteOAuthProvider.ts @@ -11,15 +11,26 @@ import { AuthInfo } from "src/server/auth/types.js"; * This provider acts as a client to an external OAuth server and forwards * all authorization, token, and verification requests. */ -export class ForwardingOAuthProvider implements OAuthServerProvider { +export class DemoRemoteOAuthProvider implements OAuthServerProvider { private metadata: OAuthMetadata; - // private clients = new Map(); constructor(oauthMetadata: OAuthMetadata) { - this.metadata = oauthMetadata; + this.metadata = oauthMetadata;// Validate required endpoints exist in the metadata + if (!oauthMetadata.authorization_endpoint) { + throw new Error('Missing required authorization_endpoint in OAuth metadata'); + } + + if (!oauthMetadata.token_endpoint) { + throw new Error('Missing required token_endpoint in OAuth metadata'); + } + + // For token verification, either introspection endpoint or userinfo endpoint is needed + if (!oauthMetadata.introspection_endpoint) { + throw new Error('Missing required introspection_endpoint in OAuth metadata'); + } } - // This is not needed. + // This is not needed, since the AS handles client registration clientsStore: OAuthRegisteredClientsStore = { async getClient(_clientId: string) { throw new Error("Not Implemented"); @@ -64,6 +75,7 @@ export class ForwardingOAuthProvider implements OAuthServerProvider { _client: OAuthClientInformationFull, _authorizationCode: string ): Promise { + // This won't be called in the forwarding flow throw new Error("Not Implemented"); } @@ -111,7 +123,7 @@ export class ForwardingOAuthProvider implements OAuthServerProvider { } async verifyAccessToken(token: string): Promise { - // Use introspection endpoint if available, or userinfo endpoint + // Use introspection endpoint if available const endpoint = this.metadata.introspection_endpoint; if (!endpoint) { @@ -119,14 +131,18 @@ export class ForwardingOAuthProvider implements OAuthServerProvider { } const response = await fetch(endpoint, { - method: 'GET', + method: 'POST', headers: { - 'Authorization': `Bearer ${token}` - } + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + token: token + }).toString() }); + if (!response.ok) { - throw new Error('Invalid or expired token'); + throw new Error(`Invalid or expired token: ${await response.text()}`); } const data = await response.json(); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 2712de4b..56e4192a 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -1,14 +1,14 @@ -import express, { Request, Response, Express } from 'express'; +import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, createOAuthMetadata } from '../../server/auth/router.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 { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; -import { ForwardingOAuthProvider } from './demoRemoteOAuthProvider.js'; +import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; +import { DemoRemoteOAuthProvider } from './demoRemoteOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; // Check for OAuth flag @@ -167,39 +167,6 @@ const getServer = () => { return server; }; -const setupAuthServer = async (authServerUrl: URL): Promise => { - // 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()); - - // Add OAuth routes to the auth server - authApp.use(mcpAuthRouter({ - provider, - issuerUrl: authServerUrl, - scopesSupported: ['mcp:tools'], - // This endpoint is set up on the Authorization server, because - // we're abusing the SDK to create a standalone Authorization server. - protectedResourceOptions: { - serverUrl: authServerUrl, - }, - })); - - // Start the auth server - authApp.listen(AUTH_PORT, () => { - console.log(`OAuth Authorization Server listening on port ${AUTH_PORT}`); - }); - - const resp = await fetch(new URL("/.well-known/oauth-authorization-server", authServerUrl)); - const oauthMetadata: OAuthMetadata = await resp.json(); - - return oauthMetadata; -} - const MCP_PORT = 3000; const AUTH_PORT = 3001; @@ -209,26 +176,26 @@ 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 = await setupAuthServer(authServerUrl); + const oauthMetadata: OAuthMetadata = await setupAuthServer(authServerUrl); - const forwardingProvider = new ForwardingOAuthProvider( + const remoteProvider = new DemoRemoteOAuthProvider( oauthMetadata ); // Add metadata routes to the main MCP server app.use(mcpAuthMetadataRouter({ - provider: forwardingProvider, + provider: remoteProvider, resourceServerUrl: mcpServerUrl, authorizationServerUrl: authServerUrl, + scopesSupported: ['mcp:tools'], resourceName: 'MCP Demo Server', })); authMiddleware = requireBearerAuth({ - provider: forwardingProvider, + provider: remoteProvider, requiredScopes: ['mcp:tools'], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), }); From cef35c9aca39222adee50b1588cc92e695f582f4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 15:28:21 +0100 Subject: [PATCH 13/23] add to readme --- src/examples/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/examples/README.md b/src/examples/README.md index 611c081e..e5654b9d 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -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 From 6374d547b764ddb4b278615d74e0a48ea5b0d5a8 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 15:36:09 +0100 Subject: [PATCH 14/23] de-async the auth server setup for happier top level flow --- src/examples/server/demoInMemoryOAuthProvider.ts | 14 ++++++++++---- src/examples/server/simpleStreamableHttp.ts | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 556d669c..ba09e3d4 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -4,7 +4,7 @@ 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 { mcpAuthRouter } from 'src/server/auth/router.js'; +import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js'; /** @@ -142,7 +142,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { } -export const setupAuthServer = async (authServerUrl: URL): Promise => { +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 @@ -199,8 +199,14 @@ export const setupAuthServer = async (authServerUrl: URL): Promise Date: Tue, 20 May 2025 15:41:46 +0100 Subject: [PATCH 15/23] add test for new router --- src/server/auth/router.test.ts | 240 ++++++++++++++++++++++++--------- 1 file changed, 176 insertions(+), 64 deletions(-) diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts index ef1e412c..3540e7cb 100644 --- a/src/server/auth/router.test.ts +++ b/src/server/auth/router.test.ts @@ -1,4 +1,4 @@ -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'; @@ -7,75 +7,76 @@ 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 = { - async getClient(clientId: string): Promise { - if (clientId === 'valid-client') { - return { - client_id: 'valid-client', - client_secret: 'valid-secret', - redirect_uris: ['https://example.com/callback'] - }; - } - return undefined; - }, - - async registerClient(client: OAuthClientInformationFull): Promise { - return client; - } - }; - - const mockProvider: OAuthServerProvider = { - clientsStore: mockClientStore, - - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - const redirectUrl = new URL(params.redirectUri); - redirectUrl.searchParams.set('code', 'mock_auth_code'); - if (params.state) { - redirectUrl.searchParams.set('state', params.state); - } - res.redirect(302, redirectUrl.toString()); - }, - - async challengeForAuthorizationCode(): Promise { - return 'mock_challenge'; - }, - - async exchangeAuthorizationCode(): Promise { +// Setup mock provider with full capabilities +const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { return { - access_token: 'mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'mock_refresh_token' + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] }; - }, - - async exchangeRefreshToken(): Promise { + } + return undefined; + }, + + async registerClient(client: OAuthClientInformationFull): Promise { + return client; + } +}; + +const mockProvider: OAuthServerProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + const redirectUrl = new URL(params.redirectUri); + redirectUrl.searchParams.set('code', 'mock_auth_code'); + if (params.state) { + redirectUrl.searchParams.set('state', params.state); + } + res.redirect(302, redirectUrl.toString()); + }, + + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, + + async exchangeAuthorizationCode(): Promise { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + }, + + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + }, + + async verifyAccessToken(token: string): Promise { + if (token === 'valid_token') { return { - access_token: 'new_mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'new_mock_refresh_token' + token, + clientId: 'valid-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 }; - }, + } + throw new InvalidTokenError('Token is invalid or expired'); + }, - async verifyAccessToken(token: string): Promise { - if (token === 'valid_token') { - return { - token, - clientId: 'valid-client', - scopes: ['read', 'write'], - expiresAt: Date.now() / 1000 + 3600 - }; - } - throw new InvalidTokenError('Token is invalid or expired'); - }, + async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { + // Success - do nothing in mock + } +}; - async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { - // Success - do nothing in mock - } - }; +describe('MCP Auth Router', () => { // Provider without registration and revocation const mockProviderMinimal: OAuthServerProvider = { @@ -385,3 +386,114 @@ describe('MCP Auth Router', () => { }); }); }); + +describe('MCP Auth Metadata Router', () => { + + describe('Router creation', () => { + it('successfully creates router with valid options', () => { + const options: AuthMetadataOptions = { + provider: mockProvider, + resourceServerUrl: new URL('https://api.example.com'), + authorizationServerUrl: new URL('https://auth.example.com') + }; + + expect(() => mcpAuthMetadataRouter(options)).not.toThrow(); + }); + }); + + describe('Metadata endpoints', () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + const options: AuthMetadataOptions = { + provider: mockProvider, + resourceServerUrl: new URL('https://api.example.com'), + authorizationServerUrl: new URL('https://auth.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']); + expect(response.body.service_documentation).toBe('https://docs.example.com/'); + expect(response.body.scopes_supported).toEqual(['read', 'write']); + }); + + 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 separate base URL for authorization server', async () => { + const separateApp = express(); + const options: AuthMetadataOptions = { + provider: mockProvider, + resourceServerUrl: new URL('https://api.example.com'), + authorizationServerUrl: new URL('https://auth.example.com'), + authorizationServerBaseUrl: new URL('https://oauth.example.com') + }; + separateApp.use(mcpAuthMetadataRouter(options)); + + const response = await supertest(separateApp) + .get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + expect(response.body.issuer).toBe('https://auth.example.com/'); + expect(response.body.authorization_endpoint).toBe('https://oauth.example.com/authorize'); + expect(response.body.token_endpoint).toBe('https://oauth.example.com/token'); + }); + + it('works with minimal configuration', async () => { + const minimalApp = express(); + const options: AuthMetadataOptions = { + provider: mockProvider, + resourceServerUrl: new URL('https://api.example.com'), + authorizationServerUrl: new URL('https://auth.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(); + }); + }); +}); From 544547b2726736b75de52189e69f1e3d7d249eb1 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 15:43:01 +0100 Subject: [PATCH 16/23] fix test --- src/server/auth/router.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index ccc975ec..65054884 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -178,7 +178,8 @@ export function mcpAuthMetadataRouter(options: AuthMetadataOptions) { const { provider, serviceDocumentationUrl, - scopesSupported + scopesSupported, + resourceName } = options; const metadata = createOAuthMetadata({ @@ -195,6 +196,7 @@ export function mcpAuthMetadataRouter(options: AuthMetadataOptions) { issuerUrl: options.authorizationServerUrl, serviceDocumentationUrl, scopesSupported, + resourceName, })) return router; From 75a12aec2e5971df55235db6a6912dd456ef490e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 15:45:57 +0100 Subject: [PATCH 17/23] remove redundant comment --- src/examples/server/demoInMemoryOAuthProvider.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index ba09e3d4..ad7044ee 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -7,14 +7,6 @@ import { AuthInfo } from 'src/server/auth/types.js'; import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js'; -/** - * 🚨 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 DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { private clients = new Map(); From 43dceff02e465ce624e74c24fd4a77a109de0a52 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 15:49:36 +0100 Subject: [PATCH 18/23] clarify comment --- src/examples/server/demoInMemoryOAuthProvider.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index ad7044ee..ae3258b4 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -151,10 +151,12 @@ export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => { provider, issuerUrl: authServerUrl, scopesSupported: ['mcp:tools'], - // This metadata doesn't make sense on the Authorization server, but - // we're abusing the SDK to create a standalone Authorization server. - // Instead, developers should use established Authorization servers, - // and create the router based on their metadata. + // Resource metadata doesn't make sense on an Authorization server, + // but, we're abusing the SDK to create a standalone Authorization server here. + // In practice, developers should use an existing authorization server + // at their organization, a 3rd party authorization server, or + // have their MCP server be an authorization server via using `mcpAuthRouter` + // directly in their server. protectedResourceOptions: { serverUrl: authServerUrl, }, From f76bb965740f4fab0700b94c113767d2dfd5ea9c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 15:59:15 +0100 Subject: [PATCH 19/23] remove more unused endpoints --- .../server/demoRemoteOAuthProvider.ts | 86 ++++--------------- 1 file changed, 18 insertions(+), 68 deletions(-) diff --git a/src/examples/server/demoRemoteOAuthProvider.ts b/src/examples/server/demoRemoteOAuthProvider.ts index 630bd3b6..72b5ee9d 100644 --- a/src/examples/server/demoRemoteOAuthProvider.ts +++ b/src/examples/server/demoRemoteOAuthProvider.ts @@ -8,6 +8,10 @@ import { AuthInfo } from "src/server/auth/types.js"; /** * An OAuthProvider that forwards requests to endpoints provided in OAuthMetadata. * + * This is meant for an MCP server acting solely as a Resource Server and + * relying on a separate Authorization server. For more details, see: + * https://modelcontextprotocol.io/specification/draft/basic/authorization#2-2-roles + * * This provider acts as a client to an external OAuth server and forwards * all authorization, token, and verification requests. */ @@ -16,14 +20,6 @@ export class DemoRemoteOAuthProvider implements OAuthServerProvider { constructor(oauthMetadata: OAuthMetadata) { this.metadata = oauthMetadata;// Validate required endpoints exist in the metadata - if (!oauthMetadata.authorization_endpoint) { - throw new Error('Missing required authorization_endpoint in OAuth metadata'); - } - - if (!oauthMetadata.token_endpoint) { - throw new Error('Missing required token_endpoint in OAuth metadata'); - } - // For token verification, either introspection endpoint or userinfo endpoint is needed if (!oauthMetadata.introspection_endpoint) { throw new Error('Missing required introspection_endpoint in OAuth metadata'); @@ -42,75 +38,29 @@ export class DemoRemoteOAuthProvider implements OAuthServerProvider { }; async authorize( - client: OAuthClientInformationFull, - params: AuthorizationParams, - res: Response + _client: OAuthClientInformationFull, + _params: AuthorizationParams, + _res: Response ): Promise { - // Forward to authorization endpoint from metadata - const authEndpoint = this.metadata.authorization_endpoint; - - const searchParams = new URLSearchParams({ - client_id: client.client_id, - response_type: 'code', - redirect_uri: client.redirect_uris[0], - code_challenge: params.codeChallenge, - code_challenge_method: 'S256', - }); - - if (params.state) { - searchParams.set('state', params.state); - } - - if (params.scopes && params.scopes.length > 0) { - searchParams.set('scope', params.scopes.join(' ')); - } - - const authUrl = new URL(authEndpoint); - authUrl.search = searchParams.toString(); - - res.redirect(authUrl.toString()); + // Not implemented, this is handled by the authorization server. + throw new Error("Not Implemented"); } async challengeForAuthorizationCode( _client: OAuthClientInformationFull, _authorizationCode: string ): Promise { - // This won't be called in the forwarding flow + // Not implemented, this is handled by the authorization server. throw new Error("Not Implemented"); } async exchangeAuthorizationCode( - client: OAuthClientInformationFull, - authorizationCode: string, - codeVerifier?: string + _client: OAuthClientInformationFull, + _authorizationCode: string, + _codeVerifier?: string ): Promise { - // Forward to token endpoint from metadata - const tokenEndpoint = this.metadata.token_endpoint; - - const params = new URLSearchParams({ - grant_type: 'authorization_code', - code: authorizationCode, - client_id: client.client_id, - redirect_uri: client.redirect_uris[0], - }); - - if (codeVerifier) { - params.append('code_verifier', codeVerifier); - } - - const response = await fetch(tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: params.toString(), - }); - - if (!response.ok) { - throw new Error(`Failed to exchange token: ${response.statusText}`); - } - - return await response.json(); + // Not implemented, this is handled by the authorization server. + throw new Error("Not Implemented"); } async exchangeRefreshToken( @@ -118,7 +68,7 @@ export class DemoRemoteOAuthProvider implements OAuthServerProvider { _refreshToken: string, _scopes?: string[] ): Promise { - // Not implemented for brevity, but follows token above. + // Not implemented, this is handled by the authorization server. throw new Error("Not Implemented"); } @@ -150,9 +100,9 @@ export class DemoRemoteOAuthProvider implements OAuthServerProvider { // Convert the response to AuthInfo format return { token, - clientId: data.client_id || data.azp, + clientId: data.client_id, scopes: data.scope ? data.scope.split(' ') : [], - expiresAt: data.exp || Math.floor(Date.now() / 1000) + 3600, + expiresAt: data.exp, }; } } From d608390893e49de4d7b30e5852ae4d3a1bcfa596 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 16:24:30 +0100 Subject: [PATCH 20/23] simplify router --- src/examples/server/simpleStreamableHttp.ts | 3 +- src/server/auth/provider.ts | 23 ++-- src/server/auth/router.ts | 111 +++++--------------- 3 files changed, 44 insertions(+), 93 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 438be200..b23a8075 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -187,9 +187,8 @@ if (useOAuth) { ); // Add metadata routes to the main MCP server app.use(mcpAuthMetadataRouter({ - provider: remoteProvider, + oauthMetadata, resourceServerUrl: mcpServerUrl, - authorizationServerUrl: authServerUrl, scopesSupported: ['mcp:tools'], resourceName: 'MCP Demo Server', })); diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index dc186bca..e347a7bb 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -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; /** * 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; -} \ No newline at end of file +} + + +/** + * Slim implementation useful for token verification + */ +export interface TokenVerifier { + /** + * Verifies an access token and returns information about it. + */ + verifyAccessToken(token: string): Promise; +} diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index 65054884..9b3042e9 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -35,12 +35,17 @@ export type AuthRouterOptions = { */ scopesSupported?: string[]; + + /** + * The resource name to be displayed in protected resource metadata + */ + resourceName?: string; + // Individual options per route authorizationOptions?: Omit; clientRegistrationOptions?: Omit; revocationOptions?: Omit; tokenOptions?: Omit; - protectedResourceOptions?: Omit; }; const checkIssuerUrl = (issuer: URL): void => { @@ -109,39 +114,32 @@ export const createOAuthMetadata = (options: { * app.use(mcpAuthRouter(...)); */ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { - const metadata = createOAuthMetadata(options); + const oauthMetadata = createOAuthMetadata(options); const router = express.Router(); router.use( - new URL(metadata.authorization_endpoint).pathname, + new URL(oauthMetadata.authorization_endpoint).pathname, authorizationHandler({ provider: options.provider, ...options.authorizationOptions }) ); router.use( - new URL(metadata.token_endpoint).pathname, + new URL(oauthMetadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions }) ); - router.use("/.well-known/oauth-authorization-server", metadataHandler(metadata)); - - // Always include protected resource metadata - const defaultProtectedResourceOptions = { - // Use issuer as the server URL if no override provided - serverUrl: new URL(metadata.issuer), - }; - - router.use(mcpProtectedResourceRouter({ - issuerUrl: options.issuerUrl, + 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, - ...defaultProtectedResourceOptions, - ...options.protectedResourceOptions - })) + resourceName: options.resourceName + })); - if (metadata.registration_endpoint) { + if (oauthMetadata.registration_endpoint) { router.use( - new URL(metadata.registration_endpoint).pathname, + new URL(oauthMetadata.registration_endpoint).pathname, clientRegistrationHandler({ clientsStore: options.provider.clientsStore, ...options, @@ -149,9 +147,9 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { ); } - if (metadata.revocation_endpoint) { + if (oauthMetadata.revocation_endpoint) { router.use( - new URL(metadata.revocation_endpoint).pathname, + new URL(oauthMetadata.revocation_endpoint).pathname, revocationHandler({ provider: options.provider, ...options.revocationOptions }) ); } @@ -164,83 +162,23 @@ export type AuthMetadataOptions = { * A provider implementing the actual authorization logic for this router. * Note: the provider should reference an authorization server */ - provider: OAuthServerProvider; + oauthMetadata: OAuthMetadata; resourceServerUrl: URL; - authorizationServerUrl: URL; - authorizationServerBaseUrl?: URL; serviceDocumentationUrl?: URL; scopesSupported?: string[]; resourceName?: string; } export function mcpAuthMetadataRouter(options: AuthMetadataOptions) { - const router = express.Router(); - - const { provider, - serviceDocumentationUrl, - scopesSupported, - resourceName - } = options; - - const metadata = createOAuthMetadata({ - provider, - issuerUrl: options.authorizationServerUrl, - baseUrl: options.authorizationServerBaseUrl, - serviceDocumentationUrl, - scopesSupported, - }); - router.use("/.well-known/oauth-authorization-server", metadataHandler(metadata)); - - router.use(mcpProtectedResourceRouter({ - serverUrl: options.resourceServerUrl, - issuerUrl: options.authorizationServerUrl, - serviceDocumentationUrl, - scopesSupported, - resourceName, - })) - - return router; -} - -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); + checkIssuerUrl(new URL(options.oauthMetadata.issuer)); const router = express.Router(); const protectedResourceMetadata: OAuthProtectedResourceMetadata = { - resource: options.serverUrl.href, + resource: options.resourceServerUrl.href, authorization_servers: [ - issuer.href + options.oauthMetadata.issuer ], scopes_supported: options.scopesSupported, @@ -250,6 +188,9 @@ export function mcpProtectedResourceRouter(options: ProtectedResourceRouterOptio 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; } From 8acd5ed15711cba54fb1fcb1106344269e40c85a Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 16:27:09 +0100 Subject: [PATCH 21/23] slim down --- .../server/demoRemoteOAuthProvider.ts | 108 ------------------ src/examples/server/simpleStreamableHttp.ts | 40 ++++++- src/server/auth/middleware/bearerAuth.ts | 10 +- src/server/auth/provider.ts | 2 +- 4 files changed, 41 insertions(+), 119 deletions(-) delete mode 100644 src/examples/server/demoRemoteOAuthProvider.ts diff --git a/src/examples/server/demoRemoteOAuthProvider.ts b/src/examples/server/demoRemoteOAuthProvider.ts deleted file mode 100644 index 72b5ee9d..00000000 --- a/src/examples/server/demoRemoteOAuthProvider.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from "src/shared/auth.js"; -import { Response } from "express"; -import { AuthorizationParams, OAuthServerProvider } from "src/server/auth/provider.js"; -import { OAuthRegisteredClientsStore } from "src/server/auth/clients.js"; -import { AuthInfo } from "src/server/auth/types.js"; - - -/** - * An OAuthProvider that forwards requests to endpoints provided in OAuthMetadata. - * - * This is meant for an MCP server acting solely as a Resource Server and - * relying on a separate Authorization server. For more details, see: - * https://modelcontextprotocol.io/specification/draft/basic/authorization#2-2-roles - * - * This provider acts as a client to an external OAuth server and forwards - * all authorization, token, and verification requests. - */ -export class DemoRemoteOAuthProvider implements OAuthServerProvider { - private metadata: OAuthMetadata; - - constructor(oauthMetadata: OAuthMetadata) { - this.metadata = oauthMetadata;// Validate required endpoints exist in the metadata - // For token verification, either introspection endpoint or userinfo endpoint is needed - if (!oauthMetadata.introspection_endpoint) { - throw new Error('Missing required introspection_endpoint in OAuth metadata'); - } - } - - // This is not needed, since the AS handles client registration - clientsStore: OAuthRegisteredClientsStore = { - async getClient(_clientId: string) { - throw new Error("Not Implemented"); - }, - - async registerClient(_clientMetadata: OAuthClientInformationFull) { - throw new Error("Not Implemented"); - } - }; - - async authorize( - _client: OAuthClientInformationFull, - _params: AuthorizationParams, - _res: Response - ): Promise { - // Not implemented, this is handled by the authorization server. - throw new Error("Not Implemented"); - } - - async challengeForAuthorizationCode( - _client: OAuthClientInformationFull, - _authorizationCode: string - ): Promise { - // Not implemented, this is handled by the authorization server. - throw new Error("Not Implemented"); - } - - async exchangeAuthorizationCode( - _client: OAuthClientInformationFull, - _authorizationCode: string, - _codeVerifier?: string - ): Promise { - // Not implemented, this is handled by the authorization server. - throw new Error("Not Implemented"); - } - - async exchangeRefreshToken( - _client: OAuthClientInformationFull, - _refreshToken: string, - _scopes?: string[] - ): Promise { - // Not implemented, this is handled by the authorization server. - throw new Error("Not Implemented"); - } - - async verifyAccessToken(token: string): Promise { - // Use introspection endpoint if available - const endpoint = this.metadata.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, - }; - } -} diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index b23a8075..6c331192 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -8,7 +8,6 @@ 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 { DemoRemoteOAuthProvider } from './demoRemoteOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; // Check for OAuth flag @@ -182,9 +181,40 @@ if (useOAuth) { const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl); - const remoteProvider = new DemoRemoteOAuthProvider( - oauthMetadata - ); + 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, @@ -194,7 +224,7 @@ if (useOAuth) { })); authMiddleware = requireBearerAuth({ - provider: remoteProvider, + verifier: tokenVerifier, requiredScopes: ['mcp:tools'], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), }); diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index 5ea0cd1d..fd96055a 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -1,13 +1,13 @@ 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. @@ -37,7 +37,7 @@ declare module "express-serve-static-core" { * 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 = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { +export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { return async (req, res, next) => { try { const authHeader = req.headers.authorization; @@ -50,7 +50,7 @@ export function requireBearerAuth({ provider, requiredScopes = [], resourceMetad 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) { @@ -94,4 +94,4 @@ export function requireBearerAuth({ provider, requiredScopes = [], resourceMetad } } }; -} \ No newline at end of file +} diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index e347a7bb..8a0bf0f1 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -69,7 +69,7 @@ export interface OAuthServerProvider { /** * Slim implementation useful for token verification */ -export interface TokenVerifier { +export interface OAuthTokenVerifier { /** * Verifies an access token and returns information about it. */ From e55037ef64cec973f43ebe9fbedb89274fca2f1d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 16:48:43 +0100 Subject: [PATCH 22/23] simplify verifier, and fix router test --- .../server/demoInMemoryOAuthProvider.ts | 11 +- src/server/auth/middleware/bearerAuth.test.ts | 54 +++--- src/server/auth/router.test.ts | 179 ++++++++---------- 3 files changed, 107 insertions(+), 137 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index ae3258b4..024208d6 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -147,19 +147,12 @@ export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => { 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'], - // Resource metadata doesn't make sense on an Authorization server, - // but, we're abusing the SDK to create a standalone Authorization server here. - // In practice, developers should use an existing authorization server - // at their organization, a 3rd party authorization server, or - // have their MCP server be an authorization server via using `mcpAuthRouter` - // directly in their server. - protectedResourceOptions: { - serverUrl: authServerUrl, - }, })); authApp.post('/introspect', async (req: Request, res: Response) => { diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index c672f175..b8953e5c 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -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"); @@ -311,7 +305,7 @@ describe("requireBearerAuth middleware", () => { it("should include resource_metadata in WWW-Authenticate header for 401 responses", async () => { mockRequest.headers = {}; - const middleware = requireBearerAuth({ provider: mockProvider, resourceMetadataUrl }); + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockResponse.status).toHaveBeenCalledWith(401); @@ -329,7 +323,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired")); - const middleware = requireBearerAuth({ provider: mockProvider, resourceMetadataUrl }); + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockResponse.status).toHaveBeenCalledWith(401); @@ -347,7 +341,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: admin")); - const middleware = requireBearerAuth({ provider: mockProvider, resourceMetadataUrl }); + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockResponse.status).toHaveBeenCalledWith(403); @@ -371,7 +365,7 @@ describe("requireBearerAuth middleware", () => { authorization: "Bearer expired-token", }; - const middleware = requireBearerAuth({ provider: mockProvider, resourceMetadataUrl }); + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockResponse.status).toHaveBeenCalledWith(401); @@ -395,7 +389,7 @@ describe("requireBearerAuth middleware", () => { }; const middleware = requireBearerAuth({ - provider: mockProvider, + verifier: mockVerifier, requiredScopes: ["read", "write"], resourceMetadataUrl }); @@ -417,7 +411,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue")); - const middleware = requireBearerAuth({ provider: mockProvider, resourceMetadataUrl }); + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockResponse.status).toHaveBeenCalledWith(500); @@ -425,4 +419,4 @@ describe("requireBearerAuth middleware", () => { expect(nextFunction).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts index 3540e7cb..bcf0a51a 100644 --- a/src/server/auth/router.test.ts +++ b/src/server/auth/router.test.ts @@ -1,82 +1,82 @@ 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'; -// Setup mock provider with full capabilities -const mockClientStore: OAuthRegisteredClientsStore = { - async getClient(clientId: string): Promise { - if (clientId === 'valid-client') { - return { - client_id: 'valid-client', - client_secret: 'valid-secret', - redirect_uris: ['https://example.com/callback'] - }; + +describe('MCP Auth Router', () => { + // Setup mock provider with full capabilities + const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] + }; + } + return undefined; + }, + + async registerClient(client: OAuthClientInformationFull): Promise { + return client; } - return undefined; - }, + }; - async registerClient(client: OAuthClientInformationFull): Promise { - return client; - } -}; + const mockProvider: OAuthServerProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + const redirectUrl = new URL(params.redirectUri); + redirectUrl.searchParams.set('code', 'mock_auth_code'); + if (params.state) { + redirectUrl.searchParams.set('state', params.state); + } + res.redirect(302, redirectUrl.toString()); + }, -const mockProvider: OAuthServerProvider = { - clientsStore: mockClientStore, + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - const redirectUrl = new URL(params.redirectUri); - redirectUrl.searchParams.set('code', 'mock_auth_code'); - if (params.state) { - redirectUrl.searchParams.set('state', params.state); - } - res.redirect(302, redirectUrl.toString()); - }, - - async challengeForAuthorizationCode(): Promise { - return 'mock_challenge'; - }, - - async exchangeAuthorizationCode(): Promise { - return { - access_token: 'mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'mock_refresh_token' - }; - }, - - async exchangeRefreshToken(): Promise { - return { - access_token: 'new_mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'new_mock_refresh_token' - }; - }, - - async verifyAccessToken(token: string): Promise { - if (token === 'valid_token') { + async exchangeAuthorizationCode(): Promise { return { - token, - clientId: 'valid-client', - scopes: ['read', 'write'], - expiresAt: Date.now() / 1000 + 3600 + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' }; - } - throw new InvalidTokenError('Token is invalid or expired'); - }, + }, - async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { - // Success - do nothing in mock - } -}; + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + }, -describe('MCP Auth Router', () => { + async verifyAccessToken(token: string): Promise { + if (token === 'valid_token') { + return { + token, + clientId: 'valid-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }; + } + throw new InvalidTokenError('Token is invalid or expired'); + }, + + async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { + // Success - do nothing in mock + } + }; // Provider without registration and revocation const mockProviderMinimal: OAuthServerProvider = { @@ -248,17 +248,14 @@ describe('MCP Auth Router', () => { expect(response.body.service_documentation).toBeUndefined(); }); - it('provides protected resource metadata when protocol version is draft', async () => { + it('provides protected resource metadata', async () => { // Setup router with draft protocol version const draftApp = express(); const options: AuthRouterOptions = { provider: mockProvider, - issuerUrl: new URL('https://auth.example.com'), + issuerUrl: new URL('https://mcp.example.com'), scopesSupported: ['read', 'write'], - protectedResourceOptions: { - serverUrl: new URL('https://api.example.com'), - resourceName: 'Test API' - } + resourceName: 'Test API' }; draftApp.use(mcpAuthRouter(options)); @@ -268,8 +265,8 @@ describe('MCP Auth Router', () => { 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.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'); }); @@ -389,12 +386,21 @@ describe('MCP Auth Router', () => { 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 = { - provider: mockProvider, + oauthMetadata: mockOAuthMetadata, resourceServerUrl: new URL('https://api.example.com'), - authorizationServerUrl: new URL('https://auth.example.com') }; expect(() => mcpAuthMetadataRouter(options)).not.toThrow(); @@ -407,9 +413,8 @@ describe('MCP Auth Metadata Router', () => { beforeEach(() => { app = express(); const options: AuthMetadataOptions = { - provider: mockProvider, + oauthMetadata: mockOAuthMetadata, resourceServerUrl: new URL('https://api.example.com'), - authorizationServerUrl: new URL('https://auth.example.com'), serviceDocumentationUrl: new URL('https://docs.example.com'), scopesSupported: ['read', 'write'], resourceName: 'Test API' @@ -431,8 +436,6 @@ describe('MCP Auth Metadata Router', () => { 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']); - expect(response.body.service_documentation).toBe('https://docs.example.com/'); - expect(response.body.scopes_supported).toEqual(['read', 'write']); }); it('returns OAuth protected resource metadata', async () => { @@ -449,31 +452,11 @@ describe('MCP Auth Metadata Router', () => { expect(response.body.resource_documentation).toBe('https://docs.example.com/'); }); - it('works with separate base URL for authorization server', async () => { - const separateApp = express(); - const options: AuthMetadataOptions = { - provider: mockProvider, - resourceServerUrl: new URL('https://api.example.com'), - authorizationServerUrl: new URL('https://auth.example.com'), - authorizationServerBaseUrl: new URL('https://oauth.example.com') - }; - separateApp.use(mcpAuthMetadataRouter(options)); - - const response = await supertest(separateApp) - .get('/.well-known/oauth-authorization-server'); - - expect(response.status).toBe(200); - expect(response.body.issuer).toBe('https://auth.example.com/'); - expect(response.body.authorization_endpoint).toBe('https://oauth.example.com/authorize'); - expect(response.body.token_endpoint).toBe('https://oauth.example.com/token'); - }); - it('works with minimal configuration', async () => { const minimalApp = express(); const options: AuthMetadataOptions = { - provider: mockProvider, + oauthMetadata: mockOAuthMetadata, resourceServerUrl: new URL('https://api.example.com'), - authorizationServerUrl: new URL('https://auth.example.com') }; minimalApp.use(mcpAuthMetadataRouter(options)); From 9e7f72d4759ed458235d5191655f645fecce5332 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 16:54:44 +0100 Subject: [PATCH 23/23] fix doc string --- src/server/auth/router.ts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index 9b3042e9..3e752e7a 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -159,14 +159,30 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { export type AuthMetadataOptions = { /** - * A provider implementing the actual authorization logic for this router. - * Note: the provider should reference an authorization server + * 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 */ - oauthMetadata: OAuthMetadata; - resourceServerUrl: URL; - serviceDocumentationUrl?: URL; - scopesSupported?: string[]; - resourceName?: string; + resourceName?: string; } export function mcpAuthMetadataRouter(options: AuthMetadataOptions) {