From 0135abd62acd859341e532c7782a9e3893c427b1 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 3 Sep 2025 23:50:32 -0400 Subject: [PATCH] Phase 3: Implement MCP server mode switching - Created ExternalAuthVerifier for token validation with external auth server - Added mode switching logic to src/index.ts - Integrated mode uses mcpAuthRouter (current behavior) - Separate mode uses mcpAuthMetadataRouter and external verifier - Fake upstream auth routes only available in integrated mode - Added auth server metadata fetching with error handling --- src/auth/external-verifier.ts | 91 ++++++++++++++++++++++++ src/index.ts | 126 +++++++++++++++++++++++++--------- 2 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 src/auth/external-verifier.ts diff --git a/src/auth/external-verifier.ts b/src/auth/external-verifier.ts new file mode 100644 index 0000000..3ec3541 --- /dev/null +++ b/src/auth/external-verifier.ts @@ -0,0 +1,91 @@ +import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { AuthInfo, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { TokenIntrospectionResponse } from '../../shared/types.js'; +import { logger } from '../utils/logger.js'; + +/** + * Token verifier that validates tokens with an external authorization server. + * Used when the MCP server is running in 'separate' mode. + */ +export class ExternalAuthVerifier implements OAuthTokenVerifier { + /** + * Creates a new external auth verifier. + * @param authServerUrl Base URL of the external authorization server + */ + constructor(private authServerUrl: string) {} + + /** + * Verifies an access token by calling the external auth server's introspection endpoint. + * @param token The access token to verify + * @returns Authentication information if the token is valid + * @throws InvalidTokenError if the token is invalid or expired + */ + async verifyAccessToken(token: string): Promise { + try { + // Token introspection is OAuth 2.0 standard (RFC 7662) for validating tokens + // The auth server checks if the token is valid and returns metadata about it + const response = await fetch(`${this.authServerUrl}/oauth/introspect`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `token=${encodeURIComponent(token)}`, + }); + + if (!response.ok) { + logger.error('Token introspection request failed', undefined, { + status: response.status, + statusText: response.statusText, + }); + throw new InvalidTokenError('Token validation failed'); + } + + const data: TokenIntrospectionResponse = await response.json(); + + // Check if token is active + if (!data.active) { + throw new InvalidTokenError('Token is not active'); + } + + // Check if token is expired + if (data.exp && data.exp < Date.now() / 1000) { + throw new InvalidTokenError('Token has expired'); + } + + // Extract user ID from standard 'sub' claim or custom 'userId' field + const userId = data.sub || data.userId; + if (!userId) { + logger.warn('Token introspection response missing user ID', { + hasS + +: !!data.sub, + hasUserId: !!data.userId, + }); + } + + return { + token, + clientId: data.client_id || 'unknown', + scopes: data.scope?.split(' ') || [], // Empty array if no scopes specified (permissive) + expiresAt: data.exp, + extra: { + userId: userId || 'unknown', + // Include other potentially useful fields + username: data.username, + iss: data.iss, + aud: data.aud, + }, + }; + } catch (error) { + if (error instanceof InvalidTokenError) { + throw error; + } + + logger.error('Failed to verify token with external auth server', error as Error, { + authServerUrl: this.authServerUrl, + }); + + // Network or other errors should be treated as invalid token + // to prevent access with unverifiable tokens + throw new InvalidTokenError('Unable to verify token'); + } + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 3a63374..fd812b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,12 @@ import { BearerAuthMiddlewareOptions, requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; -import { AuthRouterOptions, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; +import { AuthRouterOptions, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter, mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; import cors from "cors"; import express from "express"; import path from "path"; import { fileURLToPath } from "url"; import { EverythingAuthProvider } from "./auth/provider.js"; -import { BASE_URI, PORT } from "./config.js"; +import { ExternalAuthVerifier } from "./auth/external-verifier.js"; +import { BASE_URI, PORT, AUTH_MODE, AUTH_SERVER_URL } from "./config.js"; import { authContext } from "./handlers/common.js"; import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from "./handlers/fakeauth.js"; import { handleStreamableHTTP } from "./handlers/shttp.js"; @@ -122,36 +123,95 @@ app.use(baseSecurityHeaders); app.options('*', cors(corsOptions)); -const authProvider = new EverythingAuthProvider(); -// Auth configuration -const options: AuthRouterOptions = { - provider: new EverythingAuthProvider(), - issuerUrl: new URL(BASE_URI), - tokenOptions: { - rateLimit: { - windowMs: 5 * 1000, - limit: 100, - } - }, - clientRegistrationOptions: { - rateLimit: { - windowMs: 60 * 1000, // 1 minute - limit: 10, // Limit to 10 registrations per minute +// Mode-dependent auth configuration +let bearerAuth: express.RequestHandler; + +if (AUTH_MODE === 'integrated') { + // Integrated mode: MCP server acts as its own OAuth server + logger.info('Starting MCP server in INTEGRATED mode', { + mode: AUTH_MODE, + baseUri: BASE_URI, + port: PORT + }); + + const authProvider = new EverythingAuthProvider(); + + const authRouterOptions: AuthRouterOptions = { + provider: authProvider, + issuerUrl: new URL(BASE_URI), + tokenOptions: { + rateLimit: { + windowMs: 5 * 1000, + limit: 100, + } }, - }, -}; - -const dearerAuthMiddlewareOptions: BearerAuthMiddlewareOptions = { - // verifyAccessToken(token: string): Promise; - verifier: { - verifyAccessToken: authProvider.verifyAccessToken.bind(authProvider), - }, - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(BASE_URI)), + clientRegistrationOptions: { + rateLimit: { + windowMs: 60 * 1000, // 1 minute + limit: 10, // Limit to 10 registrations per minute + }, + }, + }; + + // Serve OAuth endpoints + app.use(mcpAuthRouter(authRouterOptions)); + + // Configure bearer auth middleware + const bearerAuthOptions: BearerAuthMiddlewareOptions = { + verifier: { + verifyAccessToken: authProvider.verifyAccessToken.bind(authProvider), + }, + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(BASE_URI)), + }; + + bearerAuth = requireBearerAuth(bearerAuthOptions); + +} else { + // Separate mode: MCP server uses external auth server + logger.info('Starting MCP server in SEPARATE mode', { + mode: AUTH_MODE, + baseUri: BASE_URI, + port: PORT, + authServerUrl: AUTH_SERVER_URL + }); + + // Fetch metadata from external auth server + let authMetadata; + try { + const authMetadataResponse = await fetch(`${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`); + if (!authMetadataResponse.ok) { + throw new Error(`Failed to fetch auth server metadata: ${authMetadataResponse.status} ${authMetadataResponse.statusText}`); + } + authMetadata = await authMetadataResponse.json(); + logger.info('Successfully fetched auth server metadata', { + issuer: authMetadata.issuer, + authorizationEndpoint: authMetadata.authorization_endpoint, + tokenEndpoint: authMetadata.token_endpoint + }); + } catch (error) { + logger.error('Failed to fetch auth server metadata', error as Error); + logger.error('Make sure the auth server is running at', undefined, { authServerUrl: AUTH_SERVER_URL }); + process.exit(1); + } + + // Serve resource metadata only (not auth endpoints) + app.use(mcpAuthMetadataRouter({ + oauthMetadata: authMetadata, + resourceServerUrl: new URL(BASE_URI), + resourceName: "MCP Everything Server" + })); + + // Configure bearer auth with external verifier + const externalVerifier = new ExternalAuthVerifier(AUTH_SERVER_URL); + + const bearerAuthOptions: BearerAuthMiddlewareOptions = { + verifier: externalVerifier, + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(BASE_URI)), + }; + + bearerAuth = requireBearerAuth(bearerAuthOptions); } -app.use(mcpAuthRouter(options)); -const bearerAuth = requireBearerAuth(dearerAuthMiddlewareOptions); - // MCP routes (legacy SSE transport) app.get("/sse", cors(corsOptions), bearerAuth, authContext, sseHeaders, handleSSEConnection); app.post("/message", cors(corsOptions), bearerAuth, authContext, sensitiveDataHeaders, handleMessage); @@ -179,9 +239,11 @@ app.get("/", (req, res) => { res.sendFile(splashPath); }); -// Upstream auth routes -app.get("/fakeupstreamauth/authorize", cors(corsOptions), handleFakeAuthorize); -app.get("/fakeupstreamauth/callback", cors(corsOptions), handleFakeAuthorizeRedirect); +// Upstream auth routes (only in integrated mode) +if (AUTH_MODE === 'integrated') { + app.get("/fakeupstreamauth/authorize", cors(corsOptions), handleFakeAuthorize); + app.get("/fakeupstreamauth/callback", cors(corsOptions), handleFakeAuthorizeRedirect); +} try { await redisClient.connect();