| | 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}`); |
| | } |
| | } |
| | } |
| |
|