From 4771c6eb3434389bf9b4074c25bc615a4f5c67e3 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 4 Sep 2025 19:29:54 -0400 Subject: [PATCH] Add retry logic and token caching - Implemented retry logic (5 attempts, 3 second delay) for auth server connection - Added token validation caching with 60-second default TTL - Cache respects token expiration time if provided - Cache invalidation on 401/403 responses - Automatic cache cleanup every minute - Debug logging for cache hits and misses --- src/auth/external-verifier.ts | 59 +++++++++++++++++++++++++++++++++-- src/index.ts | 50 ++++++++++++++++++++--------- 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/auth/external-verifier.ts b/src/auth/external-verifier.ts index 45f05c9..6842ec1 100644 --- a/src/auth/external-verifier.ts +++ b/src/auth/external-verifier.ts @@ -9,11 +9,32 @@ import { logger } from '../utils/logger.js'; * Used when the MCP server is running in 'separate' mode. */ export class ExternalAuthVerifier implements OAuthTokenVerifier { + // Token validation cache: token -> { authInfo, expiresAt } + private tokenCache = new Map(); + + // Default cache TTL: 60 seconds (conservative for security) + private readonly defaultCacheTTL = 60 * 1000; // milliseconds + /** * Creates a new external auth verifier. * @param authServerUrl Base URL of the external authorization server */ - constructor(private authServerUrl: string) {} + constructor(private authServerUrl: string) { + // Periodically clean up expired cache entries + setInterval(() => this.cleanupCache(), 60 * 1000); // Every minute + } + + /** + * Removes expired entries from the cache. + */ + private cleanupCache(): void { + const now = Date.now(); + for (const [token, entry] of this.tokenCache.entries()) { + if (entry.expiresAt <= now) { + this.tokenCache.delete(token); + } + } + } /** * Verifies an access token by calling the external auth server's introspection endpoint. @@ -22,6 +43,16 @@ export class ExternalAuthVerifier implements OAuthTokenVerifier { * @throws InvalidTokenError if the token is invalid or expired */ async verifyAccessToken(token: string): Promise { + // Check cache first + const cached = this.tokenCache.get(token); + if (cached && cached.expiresAt > Date.now()) { + logger.debug('Token validation cache hit', { + token: token.substring(0, 8) + '...', + expiresIn: Math.round((cached.expiresAt - Date.now()) / 1000) + 's' + }); + return cached.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 @@ -32,6 +63,10 @@ export class ExternalAuthVerifier implements OAuthTokenVerifier { }); if (!response.ok) { + // On 401/403, the token might be invalid - don't cache + if (response.status === 401 || response.status === 403) { + this.tokenCache.delete(token); // Clear any stale cache + } logger.error('Token introspection request failed', undefined, { status: response.status, statusText: response.statusText, @@ -60,7 +95,7 @@ export class ExternalAuthVerifier implements OAuthTokenVerifier { }); } - return { + const authInfo: AuthInfo = { token, clientId: data.client_id || 'unknown', scopes: data.scope?.split(' ') || [], // Empty array if no scopes specified (permissive) @@ -73,6 +108,26 @@ export class ExternalAuthVerifier implements OAuthTokenVerifier { aud: data.aud, }, }; + + // Cache the successful introspection result + // Use token expiration if available, otherwise default TTL + const cacheDuration = data.exp + ? Math.min((data.exp * 1000) - Date.now(), this.defaultCacheTTL) + : this.defaultCacheTTL; + + if (cacheDuration > 0) { + this.tokenCache.set(token, { + authInfo, + expiresAt: Date.now() + cacheDuration + }); + + logger.debug('Token validation cached', { + token: token.substring(0, 8) + '...', + cacheDuration: Math.round(cacheDuration / 1000) + 's' + }); + } + + return authInfo; } catch (error) { if (error instanceof InvalidTokenError) { throw error; diff --git a/src/index.ts b/src/index.ts index fd812b6..9af7a77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -175,23 +175,43 @@ if (AUTH_MODE === 'integrated') { authServerUrl: AUTH_SERVER_URL }); - // Fetch metadata from external auth server + // Fetch metadata from external auth server with retry logic 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}`); + const maxRetries = 5; + const retryDelay = 3000; // 3 seconds + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + logger.info(`Attempting to connect to auth server (attempt ${attempt}/${maxRetries})`, { + authServerUrl: AUTH_SERVER_URL + }); + + 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 + }); + break; // Success, exit retry loop + + } catch (error) { + if (attempt < maxRetries) { + logger.info(`Failed to connect to auth server, retrying in ${retryDelay/1000} seconds...`, { + attempt, + maxRetries, + error: (error as Error).message + }); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } else { + logger.error('Failed to fetch auth server metadata after all retries', error as Error); + logger.error('Make sure the auth server is running at', undefined, { authServerUrl: AUTH_SERVER_URL }); + process.exit(1); + } } - 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)