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
This commit is contained in:
parent
98cec052e4
commit
0135abd62a
2 changed files with 185 additions and 32 deletions
91
src/auth/external-verifier.ts
Normal file
91
src/auth/external-verifier.ts
Normal file
|
|
@ -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<AuthInfo> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/index.ts
126
src/index.ts
|
|
@ -1,11 +1,12 @@
|
||||||
import { BearerAuthMiddlewareOptions, requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
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 cors from "cors";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { EverythingAuthProvider } from "./auth/provider.js";
|
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 { authContext } from "./handlers/common.js";
|
||||||
import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from "./handlers/fakeauth.js";
|
import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from "./handlers/fakeauth.js";
|
||||||
import { handleStreamableHTTP } from "./handlers/shttp.js";
|
import { handleStreamableHTTP } from "./handlers/shttp.js";
|
||||||
|
|
@ -122,36 +123,95 @@ app.use(baseSecurityHeaders);
|
||||||
app.options('*', cors(corsOptions));
|
app.options('*', cors(corsOptions));
|
||||||
|
|
||||||
|
|
||||||
const authProvider = new EverythingAuthProvider();
|
// Mode-dependent auth configuration
|
||||||
// Auth configuration
|
let bearerAuth: express.RequestHandler;
|
||||||
const options: AuthRouterOptions = {
|
|
||||||
provider: new EverythingAuthProvider(),
|
if (AUTH_MODE === 'integrated') {
|
||||||
issuerUrl: new URL(BASE_URI),
|
// Integrated mode: MCP server acts as its own OAuth server
|
||||||
tokenOptions: {
|
logger.info('Starting MCP server in INTEGRATED mode', {
|
||||||
rateLimit: {
|
mode: AUTH_MODE,
|
||||||
windowMs: 5 * 1000,
|
baseUri: BASE_URI,
|
||||||
limit: 100,
|
port: PORT
|
||||||
}
|
});
|
||||||
},
|
|
||||||
clientRegistrationOptions: {
|
const authProvider = new EverythingAuthProvider();
|
||||||
rateLimit: {
|
|
||||||
windowMs: 60 * 1000, // 1 minute
|
const authRouterOptions: AuthRouterOptions = {
|
||||||
limit: 10, // Limit to 10 registrations per minute
|
provider: authProvider,
|
||||||
|
issuerUrl: new URL(BASE_URI),
|
||||||
|
tokenOptions: {
|
||||||
|
rateLimit: {
|
||||||
|
windowMs: 5 * 1000,
|
||||||
|
limit: 100,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
clientRegistrationOptions: {
|
||||||
};
|
rateLimit: {
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
const dearerAuthMiddlewareOptions: BearerAuthMiddlewareOptions = {
|
limit: 10, // Limit to 10 registrations per minute
|
||||||
// verifyAccessToken(token: string): Promise<AuthInfo>;
|
},
|
||||||
verifier: {
|
},
|
||||||
verifyAccessToken: authProvider.verifyAccessToken.bind(authProvider),
|
};
|
||||||
},
|
|
||||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL(BASE_URI)),
|
// 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)
|
// MCP routes (legacy SSE transport)
|
||||||
app.get("/sse", cors(corsOptions), bearerAuth, authContext, sseHeaders, handleSSEConnection);
|
app.get("/sse", cors(corsOptions), bearerAuth, authContext, sseHeaders, handleSSEConnection);
|
||||||
app.post("/message", cors(corsOptions), bearerAuth, authContext, sensitiveDataHeaders, handleMessage);
|
app.post("/message", cors(corsOptions), bearerAuth, authContext, sensitiveDataHeaders, handleMessage);
|
||||||
|
|
@ -179,9 +239,11 @@ app.get("/", (req, res) => {
|
||||||
res.sendFile(splashPath);
|
res.sendFile(splashPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upstream auth routes
|
// Upstream auth routes (only in integrated mode)
|
||||||
app.get("/fakeupstreamauth/authorize", cors(corsOptions), handleFakeAuthorize);
|
if (AUTH_MODE === 'integrated') {
|
||||||
app.get("/fakeupstreamauth/callback", cors(corsOptions), handleFakeAuthorizeRedirect);
|
app.get("/fakeupstreamauth/authorize", cors(corsOptions), handleFakeAuthorize);
|
||||||
|
app.get("/fakeupstreamauth/callback", cors(corsOptions), handleFakeAuthorizeRedirect);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await redisClient.connect();
|
await redisClient.connect();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue