| import { randomBytes } from 'crypto'; |
| import { logger } from '@librechat/data-schemas'; |
| import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport'; |
| import { OAuthMetadataSchema } from '@modelcontextprotocol/sdk/shared/auth.js'; |
| import { |
| registerClient, |
| startAuthorization, |
| exchangeAuthorization, |
| discoverAuthorizationServerMetadata, |
| discoverOAuthProtectedResourceMetadata, |
| } from '@modelcontextprotocol/sdk/client/auth.js'; |
| import type { MCPOptions } from 'librechat-data-provider'; |
| import type { FlowStateManager } from '~/flow/manager'; |
| import type { |
| OAuthClientInformation, |
| OAuthProtectedResourceMetadata, |
| MCPOAuthFlowMetadata, |
| MCPOAuthTokens, |
| OAuthMetadata, |
| } from './types'; |
| import { sanitizeUrlForLogging } from '~/mcp/utils'; |
|
|
| |
| type SDKOAuthMetadata = Parameters<typeof registerClient>[1]['metadata']; |
|
|
| export class MCPOAuthHandler { |
| private static readonly FLOW_TYPE = 'mcp_oauth'; |
| private static readonly FLOW_TTL = 10 * 60 * 1000; |
|
|
| |
| |
| |
| private static createOAuthFetch(headers: Record<string, string>): FetchLike { |
| return async (url: string | URL, init?: RequestInit): Promise<Response> => { |
| const newHeaders = new Headers(init?.headers ?? {}); |
| for (const [key, value] of Object.entries(headers)) { |
| newHeaders.set(key, value); |
| } |
| return fetch(url, { |
| ...init, |
| headers: newHeaders, |
| }); |
| }; |
| } |
|
|
| |
| |
| |
| private static async discoverMetadata( |
| serverUrl: string, |
| oauthHeaders: Record<string, string>, |
| ): Promise<{ |
| metadata: OAuthMetadata; |
| resourceMetadata?: OAuthProtectedResourceMetadata; |
| authServerUrl: URL; |
| }> { |
| logger.debug( |
| `[MCPOAuth] discoverMetadata called with serverUrl: ${sanitizeUrlForLogging(serverUrl)}`, |
| ); |
|
|
| let authServerUrl = new URL(serverUrl); |
| let resourceMetadata: OAuthProtectedResourceMetadata | undefined; |
|
|
| const fetchFn = this.createOAuthFetch(oauthHeaders); |
|
|
| try { |
| |
| logger.debug( |
| `[MCPOAuth] Attempting to discover protected resource metadata from ${serverUrl}`, |
| ); |
| resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {}, fetchFn); |
|
|
| if (resourceMetadata?.authorization_servers?.length) { |
| authServerUrl = new URL(resourceMetadata.authorization_servers[0]); |
| logger.debug( |
| `[MCPOAuth] Found authorization server from resource metadata: ${authServerUrl}`, |
| ); |
| } else { |
| logger.debug(`[MCPOAuth] No authorization servers found in resource metadata`); |
| } |
| } catch (error) { |
| logger.debug('[MCPOAuth] Resource metadata discovery failed, continuing with server URL', { |
| error, |
| }); |
| } |
|
|
| |
| logger.debug( |
| `[MCPOAuth] Discovering OAuth metadata from ${sanitizeUrlForLogging(authServerUrl)}`, |
| ); |
| const rawMetadata = await discoverAuthorizationServerMetadata(authServerUrl, { |
| fetchFn, |
| }); |
|
|
| if (!rawMetadata) { |
| |
| |
| |
| |
| |
| |
| logger.warn( |
| `[MCPOAuth] No OAuth metadata discovered from ${sanitizeUrlForLogging(authServerUrl)}, using legacy fallback endpoints`, |
| ); |
|
|
| const fallbackMetadata: OAuthMetadata = { |
| issuer: authServerUrl.toString(), |
| authorization_endpoint: new URL('/authorize', authServerUrl).toString(), |
| token_endpoint: new URL('/token', authServerUrl).toString(), |
| registration_endpoint: new URL('/register', authServerUrl).toString(), |
| response_types_supported: ['code'], |
| grant_types_supported: ['authorization_code', 'refresh_token'], |
| code_challenge_methods_supported: ['S256', 'plain'], |
| token_endpoint_auth_methods_supported: [ |
| 'client_secret_basic', |
| 'client_secret_post', |
| 'none', |
| ], |
| }; |
|
|
| logger.debug(`[MCPOAuth] Using fallback metadata:`, fallbackMetadata); |
| return { |
| metadata: fallbackMetadata, |
| resourceMetadata, |
| authServerUrl, |
| }; |
| } |
|
|
| logger.debug(`[MCPOAuth] OAuth metadata discovered successfully`); |
| const metadata = await OAuthMetadataSchema.parseAsync(rawMetadata); |
|
|
| logger.debug(`[MCPOAuth] OAuth metadata parsed successfully`); |
| return { |
| metadata: metadata as unknown as OAuthMetadata, |
| resourceMetadata, |
| authServerUrl, |
| }; |
| } |
|
|
| |
| |
| |
| private static async registerOAuthClient( |
| serverUrl: string, |
| metadata: OAuthMetadata, |
| oauthHeaders: Record<string, string>, |
| resourceMetadata?: OAuthProtectedResourceMetadata, |
| redirectUri?: string, |
| ): Promise<OAuthClientInformation> { |
| logger.debug( |
| `[MCPOAuth] Starting client registration for ${sanitizeUrlForLogging(serverUrl)}, server metadata:`, |
| { |
| grant_types_supported: metadata.grant_types_supported, |
| response_types_supported: metadata.response_types_supported, |
| token_endpoint_auth_methods_supported: metadata.token_endpoint_auth_methods_supported, |
| scopes_supported: metadata.scopes_supported, |
| }, |
| ); |
|
|
| |
| const clientMetadata = { |
| client_name: 'LibreChat MCP Client', |
| redirect_uris: [redirectUri || this.getDefaultRedirectUri()], |
| grant_types: ['authorization_code'] as string[], |
| response_types: ['code'] as string[], |
| token_endpoint_auth_method: 'client_secret_basic', |
| scope: undefined as string | undefined, |
| }; |
|
|
| const supportedGrantTypes = metadata.grant_types_supported || ['authorization_code']; |
| const requestedGrantTypes = ['authorization_code']; |
|
|
| if (supportedGrantTypes.includes('refresh_token')) { |
| requestedGrantTypes.push('refresh_token'); |
| logger.debug( |
| `[MCPOAuth] Server ${serverUrl} supports \`refresh_token\` grant type, adding to request`, |
| ); |
| } else { |
| logger.debug( |
| `[MCPOAuth] Server ${sanitizeUrlForLogging(serverUrl)} does not support \`refresh_token\` grant type`, |
| ); |
| } |
| clientMetadata.grant_types = requestedGrantTypes; |
|
|
| clientMetadata.response_types = metadata.response_types_supported || ['code']; |
|
|
| if (metadata.token_endpoint_auth_methods_supported) { |
| |
| if (metadata.token_endpoint_auth_methods_supported.includes('client_secret_basic')) { |
| clientMetadata.token_endpoint_auth_method = 'client_secret_basic'; |
| } else if (metadata.token_endpoint_auth_methods_supported.includes('client_secret_post')) { |
| clientMetadata.token_endpoint_auth_method = 'client_secret_post'; |
| } else if (metadata.token_endpoint_auth_methods_supported.includes('none')) { |
| clientMetadata.token_endpoint_auth_method = 'none'; |
| } else { |
| clientMetadata.token_endpoint_auth_method = |
| metadata.token_endpoint_auth_methods_supported[0]; |
| } |
| } |
|
|
| const availableScopes = resourceMetadata?.scopes_supported || metadata.scopes_supported; |
| if (availableScopes) { |
| clientMetadata.scope = availableScopes.join(' '); |
| } |
|
|
| logger.debug( |
| `[MCPOAuth] Registering client for ${sanitizeUrlForLogging(serverUrl)} with metadata:`, |
| clientMetadata, |
| ); |
|
|
| const clientInfo = await registerClient(serverUrl, { |
| metadata: metadata as unknown as SDKOAuthMetadata, |
| clientMetadata, |
| fetchFn: this.createOAuthFetch(oauthHeaders), |
| }); |
|
|
| logger.debug( |
| `[MCPOAuth] Client registered successfully for ${sanitizeUrlForLogging(serverUrl)}:`, |
| { |
| client_id: clientInfo.client_id, |
| has_client_secret: !!clientInfo.client_secret, |
| grant_types: clientInfo.grant_types, |
| scope: clientInfo.scope, |
| }, |
| ); |
|
|
| return clientInfo; |
| } |
|
|
| |
| |
| |
| static async initiateOAuthFlow( |
| serverName: string, |
| serverUrl: string, |
| userId: string, |
| oauthHeaders: Record<string, string>, |
| config?: MCPOptions['oauth'], |
| ): Promise<{ authorizationUrl: string; flowId: string; flowMetadata: MCPOAuthFlowMetadata }> { |
| logger.debug( |
| `[MCPOAuth] initiateOAuthFlow called for ${serverName} with URL: ${sanitizeUrlForLogging(serverUrl)}`, |
| ); |
|
|
| const flowId = this.generateFlowId(userId, serverName); |
| const state = this.generateState(); |
|
|
| logger.debug(`[MCPOAuth] Generated flowId: ${flowId}, state: ${state}`); |
|
|
| try { |
| |
| if (config?.authorization_url && config?.token_url && config?.client_id) { |
| logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for ${serverName}`); |
|
|
| const skipCodeChallengeCheck = |
| config?.skip_code_challenge_check === true || |
| process.env.MCP_SKIP_CODE_CHALLENGE_CHECK === 'true'; |
| let codeChallengeMethodsSupported: string[]; |
|
|
| if (config?.code_challenge_methods_supported !== undefined) { |
| codeChallengeMethodsSupported = config.code_challenge_methods_supported; |
| } else if (skipCodeChallengeCheck) { |
| codeChallengeMethodsSupported = ['S256', 'plain']; |
| logger.debug( |
| `[MCPOAuth] Code challenge check skip enabled, forcing S256 support for ${serverName}`, |
| ); |
| } else { |
| codeChallengeMethodsSupported = ['S256', 'plain']; |
| } |
|
|
| |
| const metadata: OAuthMetadata = { |
| authorization_endpoint: config.authorization_url, |
| token_endpoint: config.token_url, |
| issuer: serverUrl, |
| scopes_supported: config.scope?.split(' ') ?? [], |
| grant_types_supported: config?.grant_types_supported ?? [ |
| 'authorization_code', |
| 'refresh_token', |
| ], |
| token_endpoint_auth_methods_supported: config?.token_endpoint_auth_methods_supported ?? [ |
| 'client_secret_basic', |
| 'client_secret_post', |
| ], |
| response_types_supported: config?.response_types_supported ?? ['code'], |
| code_challenge_methods_supported: codeChallengeMethodsSupported, |
| }; |
| logger.debug(`[MCPOAuth] metadata for "${serverName}": ${JSON.stringify(metadata)}`); |
| const clientInfo: OAuthClientInformation = { |
| client_id: config.client_id, |
| client_secret: config.client_secret, |
| redirect_uris: [config.redirect_uri || this.getDefaultRedirectUri(serverName)], |
| scope: config.scope, |
| }; |
|
|
| logger.debug(`[MCPOAuth] Starting authorization with pre-configured settings`); |
| const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, { |
| metadata: metadata as unknown as SDKOAuthMetadata, |
| clientInformation: clientInfo, |
| redirectUrl: clientInfo.redirect_uris?.[0] || this.getDefaultRedirectUri(serverName), |
| scope: config.scope, |
| }); |
|
|
| |
| authorizationUrl.searchParams.set('state', flowId); |
| logger.debug(`[MCPOAuth] Added state parameter to authorization URL`); |
|
|
| const flowMetadata: MCPOAuthFlowMetadata = { |
| serverName, |
| userId, |
| serverUrl, |
| state, |
| codeVerifier, |
| clientInfo, |
| metadata, |
| }; |
|
|
| logger.debug( |
| `[MCPOAuth] Authorization URL generated: ${sanitizeUrlForLogging(authorizationUrl.toString())}`, |
| ); |
| return { |
| authorizationUrl: authorizationUrl.toString(), |
| flowId, |
| flowMetadata, |
| }; |
| } |
|
|
| logger.debug( |
| `[MCPOAuth] Starting auto-discovery of OAuth metadata from ${sanitizeUrlForLogging(serverUrl)}`, |
| ); |
| const { metadata, resourceMetadata, authServerUrl } = await this.discoverMetadata( |
| serverUrl, |
| oauthHeaders, |
| ); |
|
|
| logger.debug( |
| `[MCPOAuth] OAuth metadata discovered, auth server URL: ${sanitizeUrlForLogging(authServerUrl)}`, |
| ); |
|
|
| |
| const redirectUri = config?.redirect_uri || this.getDefaultRedirectUri(serverName); |
| logger.debug(`[MCPOAuth] Registering OAuth client with redirect URI: ${redirectUri}`); |
|
|
| const clientInfo = await this.registerOAuthClient( |
| authServerUrl.toString(), |
| metadata, |
| oauthHeaders, |
| resourceMetadata, |
| redirectUri, |
| ); |
|
|
| logger.debug(`[MCPOAuth] Client registered with ID: ${clientInfo.client_id}`); |
|
|
| |
| const scope = |
| config?.scope || |
| resourceMetadata?.scopes_supported?.join(' ') || |
| metadata.scopes_supported?.join(' '); |
|
|
| logger.debug(`[MCPOAuth] Starting authorization with scope: ${scope}`); |
|
|
| let authorizationUrl: URL; |
| let codeVerifier: string; |
|
|
| try { |
| logger.debug(`[MCPOAuth] Calling startAuthorization...`); |
| const authResult = await startAuthorization(serverUrl, { |
| metadata: metadata as unknown as SDKOAuthMetadata, |
| clientInformation: clientInfo, |
| redirectUrl: redirectUri, |
| scope, |
| }); |
|
|
| authorizationUrl = authResult.authorizationUrl; |
| codeVerifier = authResult.codeVerifier; |
|
|
| logger.debug(`[MCPOAuth] startAuthorization completed successfully`); |
| logger.debug( |
| `[MCPOAuth] Authorization URL: ${sanitizeUrlForLogging(authorizationUrl.toString())}`, |
| ); |
|
|
| |
| authorizationUrl.searchParams.set('state', flowId); |
| logger.debug(`[MCPOAuth] Added state parameter to authorization URL`); |
|
|
| if (resourceMetadata?.resource != null && resourceMetadata.resource) { |
| authorizationUrl.searchParams.set('resource', resourceMetadata.resource); |
| logger.debug( |
| `[MCPOAuth] Added resource parameter to authorization URL: ${resourceMetadata.resource}`, |
| ); |
| } else { |
| logger.warn( |
| `[MCPOAuth] Resource metadata missing 'resource' property for ${serverName}. ` + |
| 'This can cause issues with some Authorization Servers who expect a "resource" parameter.', |
| ); |
| } |
| } catch (error) { |
| logger.error(`[MCPOAuth] startAuthorization failed:`, error); |
| throw error; |
| } |
|
|
| const flowMetadata: MCPOAuthFlowMetadata = { |
| serverName, |
| userId, |
| serverUrl, |
| state, |
| codeVerifier, |
| clientInfo, |
| metadata, |
| resourceMetadata, |
| }; |
|
|
| logger.debug( |
| `[MCPOAuth] Authorization URL generated for ${serverName}: ${authorizationUrl.toString()}`, |
| ); |
|
|
| const result = { |
| authorizationUrl: authorizationUrl.toString(), |
| flowId, |
| flowMetadata, |
| }; |
|
|
| logger.debug( |
| `[MCPOAuth] Returning from initiateOAuthFlow with result ${flowId} for ${serverName}`, |
| result, |
| ); |
| return result; |
| } catch (error) { |
| logger.error('[MCPOAuth] Failed to initiate OAuth flow', { error, serverName, userId }); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| static async completeOAuthFlow( |
| flowId: string, |
| authorizationCode: string, |
| flowManager: FlowStateManager<MCPOAuthTokens>, |
| oauthHeaders: Record<string, string>, |
| ): Promise<MCPOAuthTokens> { |
| try { |
| |
| const flowState = await flowManager.getFlowState(flowId, this.FLOW_TYPE); |
| if (!flowState) { |
| throw new Error('OAuth flow not found'); |
| } |
|
|
| const flowMetadata = flowState.metadata as MCPOAuthFlowMetadata; |
| if (!flowMetadata) { |
| throw new Error('OAuth flow metadata not found'); |
| } |
|
|
| const metadata = flowMetadata; |
| if (!metadata.metadata || !metadata.clientInfo || !metadata.codeVerifier) { |
| throw new Error('Invalid flow metadata'); |
| } |
|
|
| let resource: URL | undefined; |
| try { |
| if (metadata.resourceMetadata?.resource != null && metadata.resourceMetadata.resource) { |
| resource = new URL(metadata.resourceMetadata.resource); |
| logger.debug(`[MCPOAuth] Resource URL for flow ${flowId}: ${resource.toString()}`); |
| } |
| } catch (error) { |
| logger.warn( |
| `[MCPOAuth] Invalid resource URL format for flow ${flowId}: '${metadata.resourceMetadata!.resource}'. ` + |
| `Error: ${error instanceof Error ? error.message : 'Unknown error'}. Proceeding without resource parameter.`, |
| ); |
| resource = undefined; |
| } |
|
|
| const tokens = await exchangeAuthorization(metadata.serverUrl, { |
| redirectUri: metadata.clientInfo.redirect_uris?.[0] || this.getDefaultRedirectUri(), |
| metadata: metadata.metadata as unknown as SDKOAuthMetadata, |
| clientInformation: metadata.clientInfo, |
| codeVerifier: metadata.codeVerifier, |
| authorizationCode, |
| resource, |
| fetchFn: this.createOAuthFetch(oauthHeaders), |
| }); |
|
|
| logger.debug('[MCPOAuth] Token exchange successful', { |
| flowId, |
| has_access_token: !!tokens.access_token, |
| has_refresh_token: !!tokens.refresh_token, |
| expires_in: tokens.expires_in, |
| token_type: tokens.token_type, |
| scope: tokens.scope, |
| }); |
|
|
| const mcpTokens: MCPOAuthTokens = { |
| ...tokens, |
| obtained_at: Date.now(), |
| expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined, |
| }; |
|
|
| |
| await flowManager.completeFlow(flowId, this.FLOW_TYPE, mcpTokens); |
|
|
| return mcpTokens; |
| } catch (error) { |
| logger.error('[MCPOAuth] Failed to complete OAuth flow', { error, flowId }); |
| await flowManager.failFlow(flowId, this.FLOW_TYPE, error as Error); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| static async getFlowState( |
| flowId: string, |
| flowManager: FlowStateManager<MCPOAuthTokens>, |
| ): Promise<MCPOAuthFlowMetadata | null> { |
| const flowState = await flowManager.getFlowState(flowId, this.FLOW_TYPE); |
| if (!flowState) { |
| return null; |
| } |
| return flowState.metadata as MCPOAuthFlowMetadata; |
| } |
|
|
| |
| |
| |
| |
| public static generateFlowId(userId: string, serverName: string): string { |
| return `${userId}:${serverName}`; |
| } |
|
|
| |
| |
| |
| private static generateState(): string { |
| return randomBytes(32).toString('base64url'); |
| } |
|
|
| |
| |
| |
| private static getDefaultRedirectUri(serverName?: string): string { |
| const baseUrl = process.env.DOMAIN_SERVER || 'http://localhost:3080'; |
| return serverName |
| ? `${baseUrl}/api/mcp/${serverName}/oauth/callback` |
| : `${baseUrl}/api/mcp/oauth/callback`; |
| } |
|
|
| |
| |
| |
| static async refreshOAuthTokens( |
| refreshToken: string, |
| metadata: { serverName: string; serverUrl?: string; clientInfo?: OAuthClientInformation }, |
| oauthHeaders: Record<string, string>, |
| config?: MCPOptions['oauth'], |
| ): Promise<MCPOAuthTokens> { |
| logger.debug(`[MCPOAuth] Refreshing tokens for ${metadata.serverName}`); |
|
|
| try { |
| |
| if (metadata.clientInfo?.client_id) { |
| logger.debug( |
| `[MCPOAuth] Using stored client information for token refresh for ${metadata.serverName}`, |
| ); |
| logger.debug( |
| `[MCPOAuth] Client ID: ${metadata.clientInfo.client_id} for ${metadata.serverName}`, |
| ); |
| logger.debug( |
| `[MCPOAuth] Has client secret: ${!!metadata.clientInfo.client_secret} for ${metadata.serverName}`, |
| ); |
| logger.debug(`[MCPOAuth] Stored client info for ${metadata.serverName}:`, { |
| client_id: metadata.clientInfo.client_id, |
| has_client_secret: !!metadata.clientInfo.client_secret, |
| grant_types: metadata.clientInfo.grant_types, |
| scope: metadata.clientInfo.scope, |
| }); |
|
|
| |
| let tokenUrl: string; |
| let authMethods: string[] | undefined; |
| if (config?.token_url) { |
| tokenUrl = config.token_url; |
| authMethods = config.token_endpoint_auth_methods_supported; |
| } else if (!metadata.serverUrl) { |
| throw new Error('No token URL available for refresh'); |
| } else { |
| |
| const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl, { |
| fetchFn: this.createOAuthFetch(oauthHeaders), |
| }); |
| if (!oauthMetadata) { |
| |
| |
| |
| |
| logger.warn( |
| `[MCPOAuth] No OAuth metadata discovered for token refresh, using fallback /token endpoint`, |
| ); |
| tokenUrl = new URL('/token', metadata.serverUrl).toString(); |
| authMethods = ['client_secret_basic', 'client_secret_post', 'none']; |
| } else if (!oauthMetadata.token_endpoint) { |
| throw new Error('No token endpoint found in OAuth metadata'); |
| } else { |
| tokenUrl = oauthMetadata.token_endpoint; |
| authMethods = oauthMetadata.token_endpoint_auth_methods_supported; |
| } |
| } |
|
|
| const body = new URLSearchParams({ |
| grant_type: 'refresh_token', |
| refresh_token: refreshToken, |
| }); |
|
|
| |
| if (metadata.clientInfo.scope) { |
| body.append('scope', metadata.clientInfo.scope); |
| } |
|
|
| const headers: HeadersInit = { |
| 'Content-Type': 'application/x-www-form-urlencoded', |
| Accept: 'application/json', |
| ...oauthHeaders, |
| }; |
|
|
| |
| if (metadata.clientInfo.client_secret) { |
| |
| const tokenAuthMethods = authMethods ?? ['client_secret_basic']; |
| const usesBasicAuth = tokenAuthMethods.includes('client_secret_basic'); |
| const usesClientSecretPost = tokenAuthMethods.includes('client_secret_post'); |
|
|
| if (usesBasicAuth) { |
| |
| logger.debug('[MCPOAuth] Using client_secret_basic authentication method'); |
| const clientAuth = Buffer.from( |
| `${metadata.clientInfo.client_id}:${metadata.clientInfo.client_secret}`, |
| ).toString('base64'); |
| headers['Authorization'] = `Basic ${clientAuth}`; |
| } else if (usesClientSecretPost) { |
| |
| logger.debug('[MCPOAuth] Using client_secret_post authentication method'); |
| body.append('client_id', metadata.clientInfo.client_id); |
| body.append('client_secret', metadata.clientInfo.client_secret); |
| } else { |
| |
| logger.debug('[MCPOAuth] No recognized auth method, defaulting to client_secret_basic'); |
| const clientAuth = Buffer.from( |
| `${metadata.clientInfo.client_id}:${metadata.clientInfo.client_secret}`, |
| ).toString('base64'); |
| headers['Authorization'] = `Basic ${clientAuth}`; |
| } |
| } else { |
| |
| logger.debug('[MCPOAuth] Using public client authentication (no secret)'); |
| body.append('client_id', metadata.clientInfo.client_id); |
| } |
|
|
| logger.debug(`[MCPOAuth] Refresh request to: ${sanitizeUrlForLogging(tokenUrl)}`, { |
| body: body.toString(), |
| headers, |
| }); |
|
|
| const response = await fetch(tokenUrl, { |
| method: 'POST', |
| headers, |
| body, |
| }); |
|
|
| if (!response.ok) { |
| const errorText = await response.text(); |
| throw new Error( |
| `Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`, |
| ); |
| } |
|
|
| const tokens = await response.json(); |
|
|
| return { |
| ...tokens, |
| obtained_at: Date.now(), |
| expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined, |
| }; |
| } |
|
|
| |
| if (config?.token_url && config?.client_id) { |
| logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for token refresh`); |
|
|
| const tokenUrl = new URL(config.token_url); |
|
|
| const body = new URLSearchParams({ |
| grant_type: 'refresh_token', |
| refresh_token: refreshToken, |
| }); |
|
|
| if (config.scope) { |
| body.append('scope', config.scope); |
| } |
|
|
| const headers: HeadersInit = { |
| 'Content-Type': 'application/x-www-form-urlencoded', |
| Accept: 'application/json', |
| ...oauthHeaders, |
| }; |
|
|
| |
| if (config.client_secret) { |
| |
| const tokenAuthMethods = config.token_endpoint_auth_methods_supported ?? [ |
| 'client_secret_basic', |
| ]; |
| const usesBasicAuth = tokenAuthMethods.includes('client_secret_basic'); |
| const usesClientSecretPost = tokenAuthMethods.includes('client_secret_post'); |
|
|
| if (usesBasicAuth) { |
| |
| logger.debug( |
| '[MCPOAuth] Using client_secret_basic authentication method (pre-configured)', |
| ); |
| const clientAuth = Buffer.from(`${config.client_id}:${config.client_secret}`).toString( |
| 'base64', |
| ); |
| headers['Authorization'] = `Basic ${clientAuth}`; |
| } else if (usesClientSecretPost) { |
| |
| logger.debug( |
| '[MCPOAuth] Using client_secret_post authentication method (pre-configured)', |
| ); |
| body.append('client_id', config.client_id); |
| body.append('client_secret', config.client_secret); |
| } else { |
| |
| logger.debug( |
| '[MCPOAuth] No recognized auth method, defaulting to client_secret_basic (pre-configured)', |
| ); |
| const clientAuth = Buffer.from(`${config.client_id}:${config.client_secret}`).toString( |
| 'base64', |
| ); |
| headers['Authorization'] = `Basic ${clientAuth}`; |
| } |
| } else { |
| |
| logger.debug('[MCPOAuth] Using public client authentication (no secret, pre-configured)'); |
| body.append('client_id', config.client_id); |
| } |
|
|
| const response = await fetch(tokenUrl, { |
| method: 'POST', |
| headers, |
| body, |
| }); |
|
|
| if (!response.ok) { |
| const errorText = await response.text(); |
| throw new Error( |
| `Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`, |
| ); |
| } |
|
|
| const tokens = await response.json(); |
|
|
| return { |
| ...tokens, |
| obtained_at: Date.now(), |
| expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined, |
| }; |
| } |
|
|
| |
| if (!metadata.serverUrl) { |
| throw new Error('Server URL required for auto-discovered OAuth token refresh'); |
| } |
|
|
| |
| const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl, { |
| fetchFn: this.createOAuthFetch(oauthHeaders), |
| }); |
|
|
| let tokenUrl: URL; |
| if (!oauthMetadata?.token_endpoint) { |
| |
| |
| |
| |
| logger.warn( |
| `[MCPOAuth] No OAuth metadata or token endpoint found, using fallback /token endpoint`, |
| ); |
| tokenUrl = new URL('/token', metadata.serverUrl); |
| } else { |
| tokenUrl = new URL(oauthMetadata.token_endpoint); |
| } |
|
|
| const body = new URLSearchParams({ |
| grant_type: 'refresh_token', |
| refresh_token: refreshToken, |
| }); |
|
|
| const headers: HeadersInit = { |
| 'Content-Type': 'application/x-www-form-urlencoded', |
| Accept: 'application/json', |
| ...oauthHeaders, |
| }; |
|
|
| const response = await fetch(tokenUrl, { |
| method: 'POST', |
| headers, |
| body, |
| }); |
|
|
| if (!response.ok) { |
| const errorText = await response.text(); |
| throw new Error( |
| `Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`, |
| ); |
| } |
|
|
| const tokens = await response.json(); |
|
|
| return { |
| ...tokens, |
| obtained_at: Date.now(), |
| expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined, |
| }; |
| } catch (error) { |
| logger.error(`[MCPOAuth] Failed to refresh tokens for ${metadata.serverName}`, error); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| public static async revokeOAuthToken( |
| serverName: string, |
| token: string, |
| tokenType: 'refresh' | 'access', |
| metadata: { |
| serverUrl: string; |
| clientId: string; |
| clientSecret: string; |
| revocationEndpoint?: string; |
| revocationEndpointAuthMethodsSupported?: string[]; |
| }, |
| oauthHeaders: Record<string, string> = {}, |
| ): Promise<void> { |
| |
| const revokeUrl: URL = |
| metadata.revocationEndpoint != null |
| ? new URL(metadata.revocationEndpoint) |
| : new URL('/revoke', metadata.serverUrl); |
|
|
| |
| const authMethods = metadata.revocationEndpointAuthMethodsSupported ?? [ |
| 'client_secret_basic', |
| ]; |
| const usesBasicAuth = authMethods.includes('client_secret_basic'); |
| const usesClientSecretPost = authMethods.includes('client_secret_post'); |
|
|
| |
| const headers: Record<string, string> = { |
| 'Content-Type': 'application/x-www-form-urlencoded', |
| ...oauthHeaders, |
| }; |
|
|
| |
| const body = new URLSearchParams({ token }); |
| body.set('token_type_hint', tokenType === 'refresh' ? 'refresh_token' : 'access_token'); |
|
|
| |
| if (usesBasicAuth) { |
| |
| const credentials = Buffer.from(`${metadata.clientId}:${metadata.clientSecret}`).toString( |
| 'base64', |
| ); |
| headers['Authorization'] = `Basic ${credentials}`; |
| } else if (usesClientSecretPost) { |
| |
| body.set('client_secret', metadata.clientSecret); |
| body.set('client_id', metadata.clientId); |
| } |
|
|
| |
| logger.info( |
| `[MCPOAuth] Revoking tokens for ${serverName} via ${sanitizeUrlForLogging(revokeUrl.toString())}`, |
| ); |
| const response = await fetch(revokeUrl, { |
| method: 'POST', |
| body: body.toString(), |
| headers, |
| }); |
|
|
| if (!response.ok) { |
| logger.error(`[MCPOAuth] Token revocation failed for ${serverName}: HTTP ${response.status}`); |
| throw new Error(`Token revocation failed: HTTP ${response.status}`); |
| } |
| } |
| } |
|
|