mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
🪐 feat: MCP OAuth 2.0 Discovery Support (#7924)
* chore: Update @modelcontextprotocol/sdk to version 1.12.3 in package.json and package-lock.json - Bump version of @modelcontextprotocol/sdk to 1.12.3 to incorporate recent updates. - Update dependencies for ajv and cross-spawn to their latest versions. - Add ajv as a new dependency in the sdk module. - Include json-schema-traverse as a new dependency in the sdk module. * feat: @librechat/auth * feat: Add crypto module exports to auth package - Introduced a new crypto module by creating index.ts in the crypto directory. - Updated the main index.ts of the auth package to export from the new crypto module. * feat: Update package dependencies and build scripts for auth package - Added @librechat/auth as a dependency in package.json and package-lock.json. - Updated build scripts to include the auth package in both frontend and bun build processes. - Removed unused mongoose and openid-client dependencies from package-lock.json for cleaner dependency management. * refactor: Migrate crypto utility functions to @librechat/auth - Replaced local crypto utility imports with the new @librechat/auth package across multiple files. - Removed the obsolete crypto.js file and its exports. - Updated relevant services and models to utilize the new encryption and decryption methods from @librechat/auth. * feat: Enhance OAuth token handling and update dependencies in auth package * chore: Remove Token model and TokenService due to restructuring of OAuth handling - Deleted the Token.js model and TokenService.js, which were responsible for managing OAuth tokens. - This change is part of a broader refactor to streamline OAuth token management and improve code organization. * refactor: imports from '@librechat/auth' to '@librechat/api' and add OAuth token handling functionality * refactor: Simplify logger usage in MCP and FlowStateManager classes * chore: fix imports * feat: Add OAuth configuration schema to MCP with token exchange method support * feat: FIRST PASS Implement MCP OAuth flow with token management and error handling - Added a new route for handling OAuth callbacks and token retrieval. - Integrated OAuth token storage and retrieval mechanisms. - Enhanced MCP connection to support automatic OAuth flow initiation on 401 errors. - Implemented dynamic client registration and metadata discovery for OAuth. - Updated MCPManager to manage OAuth tokens and handle authentication requirements. - Introduced comprehensive logging for OAuth processes and error handling. * refactor: Update MCPConnection and MCPManager to utilize new URL handling - Added a `url` property to MCPConnection for better URL management. - Refactored MCPManager to use the new `url` property instead of a deprecated method for OAuth handling. - Changed logging from info to debug level for flow manager and token methods initialization. - Improved comments for clarity on existing tokens and OAuth event listener setup. * refactor: Improve connection timeout error messages in MCPConnection and MCPManager and use initTimeout for connection - Updated the connection timeout error messages to include the duration of the timeout. - Introduced a configurable `connectTimeout` variable in both MCPConnection and MCPManager for better flexibility. * chore: cleanup MCP OAuth Token exchange handling; fix: erroneous use of flowsCache and remove verbose logs * refactor: Update MCPManager and MCPTokenStorage to use TokenMethods for token management - Removed direct token storage handling in MCPManager and replaced it with TokenMethods for better abstraction. - Refactored MCPTokenStorage methods to accept parameters for token operations, enhancing flexibility and readability. - Improved logging messages related to token persistence and retrieval processes. * refactor: Update MCP OAuth handling to use static methods and improve flow management - Refactored MCPOAuthHandler to utilize static methods for initiating and completing OAuth flows, enhancing clarity and reducing instance dependencies. - Updated MCPManager to pass flowManager explicitly to OAuth handling methods, improving flexibility in flow state management. - Enhanced comments and logging for better understanding of OAuth processes and flow state retrieval. * refactor: Integrate token methods into createMCPTool for enhanced token management * refactor: Change logging from info to debug level in MCPOAuthHandler for improved log management * chore: clean up logging * feat: first pass, auth URL from MCP OAuth flow * chore: Improve logging format for OAuth authentication URL display * chore: cleanup mcp manager comments * feat: add connection reconnection logic in MCPManager * refactor: reorganize token storage handling in MCP - Moved token storage logic from MCPManager to a new MCPTokenStorage class for better separation of concerns. - Updated imports to reflect the new token storage structure. - Enhanced methods for storing, retrieving, updating, and deleting OAuth tokens, improving overall token management. * chore: update comment for SYSTEM_USER_ID in MCPManager for clarity * feat: implement refresh token functionality in MCP - Added refresh token handling in MCPManager to support token renewal for both app-level and user-specific connections. - Introduced a refreshTokens function to facilitate token refresh logic. - Enhanced MCPTokenStorage to manage client information and refresh token processes. - Updated logging for better traceability during token operations. * chore: cleanup @librechat/auth * feat: implement MCP server initialization in a separate service - Added a new service to handle the initialization of MCP servers, improving code organization and readability. - Refactored the server startup logic to utilize the new initializeMCP function. - Removed redundant MCP initialization code from the main server file. * fix: don't log auth url for user connections * feat: enhance OAuth flow with success and error handling components - Updated OAuth callback routes to redirect to new success and error pages instead of sending status messages. - Introduced `OAuthSuccess` and `OAuthError` components to provide user feedback during authentication. - Added localization support for success and error messages in the translation files. - Implemented countdown functionality in the success component for a better user experience. * fix: refresh token handling for user connections, add missing URL and methods - add standard enum for system user id and helper for determining app-lvel vs. user-level connections * refactor: update token handling in MCPManager and MCPTokenStorage * fix: improve error logging in OAuth authentication handler * fix: concurrency issues for both login url emission and concurrency of oauth flows for shared flows (same user, same server, multiple calls for same server) * fix: properly fail shared flows for concurrent server calls and prevent duplication of tokens * chore: remove unused auth package directory from update configuration * ci: fix mocks in samlStrategy tests * ci: add mcpConfig to AppService test setup * chore: remove obsolete MCP OAuth implementation documentation * fix: update build script for API to use correct command * chore: bump version of @librechat/api to 1.2.4 * fix: update abort signal handling in createMCPTool function * fix: add optional clientInfo parameter to refreshTokensFunction metadata * refactor: replace app.locals.availableTools with getCachedTools in multiple services and controllers for improved tool management * fix: concurrent refresh token handling issue * refactor: add signal parameter to getUserConnection method for improved abort handling * chore: JSDoc typing for `loadEphemeralAgent` * refactor: update isConnectionActive method to use destructured parameters for improved readability * feat: implement caching for MCP tools to handle app-level disconnects for loading list of tools * ci: fix agent test
This commit is contained in:
parent
b412455e9d
commit
ec7370dfe9
60 changed files with 3399 additions and 764 deletions
603
packages/api/src/mcp/oauth/handler.ts
Normal file
603
packages/api/src/mcp/oauth/handler.ts
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
import { randomBytes } from 'crypto';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import {
|
||||
discoverOAuthMetadata,
|
||||
registerClient,
|
||||
startAuthorization,
|
||||
exchangeAuthorization,
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
} from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import { OAuthMetadataSchema } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { MCPOptions } from 'librechat-data-provider';
|
||||
import type { FlowStateManager } from '~/flow/manager';
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthProtectedResourceMetadata,
|
||||
MCPOAuthFlowMetadata,
|
||||
MCPOAuthTokens,
|
||||
OAuthMetadata,
|
||||
} from './types';
|
||||
|
||||
/** Type for the OAuth metadata from the SDK */
|
||||
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; // 10 minutes
|
||||
|
||||
/**
|
||||
* Discovers OAuth metadata from the server
|
||||
*/
|
||||
private static async discoverMetadata(serverUrl: string): Promise<{
|
||||
metadata: OAuthMetadata;
|
||||
resourceMetadata?: OAuthProtectedResourceMetadata;
|
||||
authServerUrl: URL;
|
||||
}> {
|
||||
logger.debug(`[MCPOAuth] discoverMetadata called with serverUrl: ${serverUrl}`);
|
||||
|
||||
let authServerUrl = new URL(serverUrl);
|
||||
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
|
||||
|
||||
try {
|
||||
// Try to discover resource metadata first
|
||||
logger.debug(
|
||||
`[MCPOAuth] Attempting to discover protected resource metadata from ${serverUrl}`,
|
||||
);
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Discover OAuth metadata
|
||||
logger.debug(`[MCPOAuth] Discovering OAuth metadata from ${authServerUrl}`);
|
||||
const rawMetadata = await discoverOAuthMetadata(authServerUrl);
|
||||
|
||||
if (!rawMetadata) {
|
||||
logger.error(`[MCPOAuth] Failed to discover OAuth metadata from ${authServerUrl}`);
|
||||
throw new Error('Failed to discover OAuth metadata');
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an OAuth client dynamically
|
||||
*/
|
||||
private static async registerOAuthClient(
|
||||
serverUrl: string,
|
||||
metadata: OAuthMetadata,
|
||||
resourceMetadata?: OAuthProtectedResourceMetadata,
|
||||
redirectUri?: string,
|
||||
): Promise<OAuthClientInformation> {
|
||||
logger.debug(`[MCPOAuth] Starting client registration for ${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,
|
||||
});
|
||||
|
||||
/** Client metadata based on what the server supports */
|
||||
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 ${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) {
|
||||
// Prefer client_secret_basic if supported, otherwise use the first supported method
|
||||
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 ${serverUrl} with metadata:`, clientMetadata);
|
||||
|
||||
const clientInfo = await registerClient(serverUrl, {
|
||||
metadata: metadata as unknown as SDKOAuthMetadata,
|
||||
clientMetadata,
|
||||
});
|
||||
|
||||
logger.debug(`[MCPOAuth] Client registered successfully for ${serverUrl}:`, {
|
||||
client_id: clientInfo.client_id,
|
||||
has_client_secret: !!clientInfo.client_secret,
|
||||
grant_types: clientInfo.grant_types,
|
||||
scope: clientInfo.scope,
|
||||
});
|
||||
|
||||
return clientInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the OAuth flow for an MCP server
|
||||
*/
|
||||
static async initiateOAuthFlow(
|
||||
serverName: string,
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
config: MCPOptions['oauth'] | undefined,
|
||||
): Promise<{ authorizationUrl: string; flowId: string; flowMetadata: MCPOAuthFlowMetadata }> {
|
||||
logger.debug(`[MCPOAuth] initiateOAuthFlow called for ${serverName} with URL: ${serverUrl}`);
|
||||
|
||||
const flowId = this.generateFlowId(userId, serverName);
|
||||
const state = this.generateState();
|
||||
|
||||
logger.debug(`[MCPOAuth] Generated flowId: ${flowId}, state: ${state}`);
|
||||
|
||||
try {
|
||||
// Check if we have pre-configured OAuth settings
|
||||
if (config?.authorization_url && config?.token_url && config?.client_id) {
|
||||
logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for ${serverName}`);
|
||||
/** Metadata based on pre-configured settings */
|
||||
const metadata: OAuthMetadata = {
|
||||
authorization_endpoint: config.authorization_url,
|
||||
token_endpoint: config.token_url,
|
||||
issuer: serverUrl,
|
||||
scopes_supported: config.scope?.split(' '),
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
/** Add state parameter with flowId to the authorization URL */
|
||||
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: ${authorizationUrl.toString()}`);
|
||||
return {
|
||||
authorizationUrl: authorizationUrl.toString(),
|
||||
flowId,
|
||||
flowMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(`[MCPOAuth] Starting auto-discovery of OAuth metadata from ${serverUrl}`);
|
||||
const { metadata, resourceMetadata, authServerUrl } = await this.discoverMetadata(serverUrl);
|
||||
|
||||
logger.debug(`[MCPOAuth] OAuth metadata discovered, auth server URL: ${authServerUrl}`);
|
||||
|
||||
/** Dynamic client registration based on the discovered metadata */
|
||||
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,
|
||||
resourceMetadata,
|
||||
redirectUri,
|
||||
);
|
||||
|
||||
logger.debug(`[MCPOAuth] Client registered with ID: ${clientInfo.client_id}`);
|
||||
|
||||
/** Authorization Scope */
|
||||
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: ${authorizationUrl.toString()}`);
|
||||
|
||||
/** Add state parameter with flowId to the authorization URL */
|
||||
authorizationUrl.searchParams.set('state', flowId);
|
||||
logger.debug(`[MCPOAuth] Added state parameter to authorization URL`);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the OAuth flow by exchanging the authorization code for tokens
|
||||
*/
|
||||
static async completeOAuthFlow(
|
||||
flowId: string,
|
||||
authorizationCode: string,
|
||||
flowManager: FlowStateManager<MCPOAuthTokens>,
|
||||
): Promise<MCPOAuthTokens> {
|
||||
try {
|
||||
/** Flow state which contains our metadata */
|
||||
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');
|
||||
}
|
||||
|
||||
const tokens = await exchangeAuthorization(metadata.serverUrl, {
|
||||
metadata: metadata.metadata as unknown as SDKOAuthMetadata,
|
||||
clientInformation: metadata.clientInfo,
|
||||
authorizationCode,
|
||||
codeVerifier: metadata.codeVerifier,
|
||||
redirectUri: metadata.clientInfo.redirect_uris?.[0] || this.getDefaultRedirectUri(),
|
||||
});
|
||||
|
||||
logger.debug('[MCPOAuth] Raw tokens from exchange:', {
|
||||
access_token: tokens.access_token ? '[REDACTED]' : undefined,
|
||||
refresh_token: tokens.refresh_token ? '[REDACTED]' : undefined,
|
||||
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,
|
||||
};
|
||||
|
||||
/** Now complete the flow with the tokens */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the OAuth flow metadata
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a flow ID for the OAuth flow
|
||||
* @returns Consistent ID so concurrent requests share the same flow
|
||||
*/
|
||||
public static generateFlowId(userId: string, serverName: string): string {
|
||||
return `${userId}:${serverName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a secure state parameter
|
||||
*/
|
||||
private static generateState(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default redirect URI for a server
|
||||
*/
|
||||
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes OAuth tokens using a refresh token
|
||||
*/
|
||||
static async refreshOAuthTokens(
|
||||
refreshToken: string,
|
||||
metadata: { serverName: string; serverUrl?: string; clientInfo?: OAuthClientInformation },
|
||||
config?: MCPOptions['oauth'],
|
||||
): Promise<MCPOAuthTokens> {
|
||||
logger.debug(`[MCPOAuth] Refreshing tokens for ${metadata.serverName}`);
|
||||
|
||||
try {
|
||||
/** If we have stored client information from the original flow, use that first */
|
||||
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,
|
||||
});
|
||||
|
||||
/** Use the stored client information and metadata to determine the token URL */
|
||||
let tokenUrl: string;
|
||||
if (config?.token_url) {
|
||||
tokenUrl = config.token_url;
|
||||
} else if (!metadata.serverUrl) {
|
||||
throw new Error('No token URL available for refresh');
|
||||
} else {
|
||||
/** Auto-discover OAuth configuration for refresh */
|
||||
const { metadata: oauthMetadata } = await this.discoverMetadata(metadata.serverUrl);
|
||||
if (!oauthMetadata.token_endpoint) {
|
||||
throw new Error('No token endpoint found in OAuth metadata');
|
||||
}
|
||||
tokenUrl = oauthMetadata.token_endpoint;
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
/** Add scope if available */
|
||||
if (metadata.clientInfo.scope) {
|
||||
body.append('scope', metadata.clientInfo.scope);
|
||||
}
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
/** Use client_secret for authentication if available */
|
||||
if (metadata.clientInfo.client_secret) {
|
||||
const clientAuth = Buffer.from(
|
||||
`${metadata.clientInfo.client_id}:${metadata.clientInfo.client_secret}`,
|
||||
).toString('base64');
|
||||
headers['Authorization'] = `Basic ${clientAuth}`;
|
||||
} else {
|
||||
/** For public clients, client_id must be in the body */
|
||||
body.append('client_id', metadata.clientInfo.client_id);
|
||||
}
|
||||
|
||||
logger.debug(`[MCPOAuth] Refresh request to: ${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,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: If we have pre-configured OAuth settings, use them
|
||||
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 clientAuth = config.client_secret
|
||||
? Buffer.from(`${config.client_id}:${config.client_secret}`).toString('base64')
|
||||
: null;
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
if (clientAuth) {
|
||||
headers['Authorization'] = `Basic ${clientAuth}`;
|
||||
} else {
|
||||
// Use client_id in body for public clients
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** For auto-discovered OAuth, we need the server URL */
|
||||
if (!metadata.serverUrl) {
|
||||
throw new Error('Server URL required for auto-discovered OAuth token refresh');
|
||||
}
|
||||
|
||||
/** Auto-discover OAuth configuration for refresh */
|
||||
const { metadata: oauthMetadata } = await this.discoverMetadata(metadata.serverUrl);
|
||||
|
||||
if (!oauthMetadata.token_endpoint) {
|
||||
throw new Error('No token endpoint found in OAuth metadata');
|
||||
}
|
||||
|
||||
const 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',
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue