import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; import { AuthorizationServerMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'; import type { OAuthClientInformation, OAuthClientInformationFull, OAuthClientMetadata, OAuthConfig, OAuthTokens, } from './config.js'; import { DEFAULT_CLIENT_METADATA, generateSessionId, generateState, } from './config.js'; import { areTokensExpired, refreshTokensWithRetry } from './oauth-flow.js'; import { MemoryStorage, OAuthStorage } from './storage.js'; /** * Options for token refresh */ interface TokenRefreshConfig { maxRetries: number; retryDelay: number; } /** * MCP OAuth Client Provider implementation with automatic token refresh */ export class MCPOAuthClientProvider implements OAuthClientProvider { private readonly oauthStorage: OAuthStorage; private readonly sessionId: string; private readonly redirectUri: string | undefined; private readonly tokenRefreshConfig: TokenRefreshConfig; private readonly _clientMetadata: OAuthClientMetadata; private cachedClientInformation?: OAuthClientInformationFull; public tokenEndpoint?: string; public authorizationServerMetadata?: AuthorizationServerMetadata; constructor(config: OAuthConfig) { this.sessionId = config.sessionId ?? generateSessionId(); const storage = config.storage ?? new MemoryStorage(); // Set token endpoint if provided if (config.tokenEndpoint) { this.tokenEndpoint = config.tokenEndpoint; } // Store redirect URI this.redirectUri = config.redirectUri; // Store token refresh configuration this.tokenRefreshConfig = { maxRetries: 3, retryDelay: 1000, ...(config.tokenRefresh ?? {}), }; // Store client metadata this._clientMetadata = { ...DEFAULT_CLIENT_METADATA, ...(config.clientMetadata ?? {}), redirect_uris: config.redirectUri ? [config.redirectUri] : [], // Always required scope: config.scope ?? DEFAULT_CLIENT_METADATA.scope, }; // client information this.cachedClientInformation = { ...this._clientMetadata, client_id: config.clientId ?? '', client_secret: config.clientSecret ?? '', }; this.oauthStorage = new OAuthStorage(storage, this.sessionId, { staticClientInfo: config.clientId ? { client_id: config.clientId, client_secret: config.clientSecret, } : undefined, initialTokens: config.tokens, }); } /** * The URL to redirect the user agent to after authorization */ get redirectUrl(): string | URL { if (!this.redirectUri) { throw new Error('No redirect URI configured for this OAuth client'); } return this.redirectUri; } /** * Metadata about this OAuth client */ get clientMetadata(): OAuthClientMetadata { return this._clientMetadata; } /** * Returns a OAuth2 state parameter for CSRF protection */ async state(): Promise { return generateState(); } /** * Loads information about this OAuth client * Config credentials are initialized into storage, so all reads go through storage */ async clientInformation(): Promise { return await this.oauthStorage.getClientInfo(); } /** * Saves client information after dynamic registration */ async saveClientInformation( clientInformation: OAuthClientInformationFull ): Promise { this.cachedClientInformation = clientInformation; await this.oauthStorage.saveClientInfo(clientInformation); } /** * Loads any existing OAuth tokens for the current session * Automatically refreshes tokens if they're expired or about to expire (within 5 minutes) * Requires authorizationServerMetadata to be set (from auth flow) for automatic refresh */ async tokens(): Promise { const currentTokens = await this.oauthStorage.getTokens(); if (!currentTokens) { return undefined; } // Check if tokens are expired or about to expire (within 5 minutes) const needsRefresh = areTokensExpired(currentTokens, undefined, 300); // If tokens need refresh and we have the server metadata and refresh token, refresh automatically if (needsRefresh && currentTokens.refresh_token && this.tokenEndpoint) { try { // Use the token endpoint from authorization server metadata const newTokens = await this.refreshTokens(); return newTokens; } catch (error) { // If refresh fails, return the current tokens and let the caller handle it // This prevents breaking existing flows that might handle refresh differently return currentTokens; } } return currentTokens; } /** * Refresh tokens using helper function * Uses the token endpoint from authorizationServerMetadata * * @returns New OAuth tokens * @throws Error if no authorization server metadata is available */ async refreshTokens(): Promise { if (!this.tokenEndpoint) { throw new Error( 'No token endpoint available. Cannot refresh tokens without token_endpoint.' ); } const { maxRetries, retryDelay } = this.tokenRefreshConfig; const currentTokens = await this.oauthStorage.getTokens(); if (!currentTokens?.refresh_token) { throw new Error('No refresh token available'); } const clientInfo = await this.clientInformation(); if (!clientInfo) { throw new Error('No client information available'); } try { // Use helper function for retry logic with token endpoint const newTokens = await refreshTokensWithRetry( this.tokenEndpoint, clientInfo, currentTokens.refresh_token, this.addClientAuthentication, maxRetries, retryDelay ); // Save the new tokens await this.oauthStorage.saveTokens(newTokens); return newTokens; } catch (error) { // All retries failed, invalidate tokens await this.invalidateCredentials('tokens'); throw error; } } /** * Loads any existing OAuth tokens for the current session (without auto-refresh) * Use this if you want to check tokens without triggering a refresh */ async getStoredTokens(): Promise { return this.oauthStorage.getTokens(); } /** * Stores new OAuth tokens for the current session */ async saveTokens(tokens: OAuthTokens): Promise { await this.oauthStorage.saveTokens(tokens); } /** * Invoked to redirect the user agent to the given URL to begin the authorization flow */ async redirectToAuthorization(authorizationUrl: URL): Promise { // In a real implementation, this would open a browser or redirect the user // For now, we'll let the caller handle the redirect by throwing an error with the URL // In a Bun environment, we could automatically open the browser: if (typeof Bun !== 'undefined') { try { await Bun.$`open ${authorizationUrl.toString()}`; return; } catch { // Fallback if 'open' command is not available } } // If we can't automatically open, throw an error with the URL for the caller to handle throw new Error(`Please navigate to: ${authorizationUrl.toString()}`); } /** * Saves a PKCE code verifier for the current session */ async saveCodeVerifier(codeVerifier: string): Promise { await this.oauthStorage.saveCodeVerifier(codeVerifier); } /** * Loads the PKCE code verifier for the current session */ async codeVerifier(): Promise { const verifier = await this.oauthStorage.getCodeVerifier(); if (!verifier) { throw new Error('No code verifier found for current session'); } return verifier; } /** * Adds custom client authentication to OAuth token requests */ addClientAuthentication = ( headers: Headers, params: URLSearchParams, url: string | URL, metadata?: AuthorizationServerMetadata ): void => { const clientInfo = this.cachedClientInformation; this.authorizationServerMetadata = metadata; this.tokenEndpoint = metadata?.token_endpoint; if (!clientInfo) { throw new Error('No client information available for authentication'); } // Use client_secret_post method by default params.set('client_id', clientInfo.client_id); if (clientInfo.client_secret) { params.set('client_secret', clientInfo.client_secret); } }; /** * Validates the resource URL for OAuth requests */ async validateResourceURL( serverUrl: string | URL, resource?: string ): Promise { // Simple validation - in a real implementation you might want more sophisticated logic if (resource) { try { return new URL(resource); } catch { throw new Error(`Invalid resource URL: ${resource}`); } } // Default to server URL if no specific resource is provided return new URL(serverUrl); } /** * Invalidates stored credentials based on the specified scope */ async invalidateCredentials( scope: 'all' | 'client' | 'tokens' | 'verifier' ): Promise { switch (scope) { case 'all': await this.oauthStorage.clearAll(); break; case 'client': await this.oauthStorage.clearClientInfo(); break; case 'tokens': await this.oauthStorage.clearTokens(); break; case 'verifier': await this.oauthStorage.clearCodeVerifier(); break; } } /** * Get the current session ID */ getSessionId(): string { return this.sessionId; } /** * Get the OAuth storage helper */ getOAuthStorage(): OAuthStorage { return this.oauthStorage; } /** * Clear the current session */ async clearSession(): Promise { await this.oauthStorage.clearSession(); } }