LibreChat/packages/api/src/mcp/oauth/handler.ts
Danny Avila a0a1749151
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
🔗 fix: Normalize MCP OAuth resource parameter to match token exchange (#12018)
* 🔗 fix: Normalize MCP OAuth `resource` parameter to match token exchange

The authorization request used the raw resource string from metadata while
the token exchange normalized it through `new URL().href`, causing a
trailing-slash mismatch that Cloudflare's auth server rejected. Canonicalize
the resource URL in both paths so they match.

* 🔧 test: Simplify LeaderElection integration tests for Redis

Refactored the integration tests for LeaderElection with Redis by reducing the number of instances from 100 to 1, streamlining the leadership election process. Updated assertions to verify leadership status and UUID after resignation, improving test clarity and performance. Adjusted timeout to 15 seconds for the single instance scenario.

* 🔧 test: Update LeaderElection test case description for clarity

Modified the description of the test case for leader resignation in the LeaderElection integration tests to better reflect the expected behavior, enhancing clarity and understanding of the test's purpose.

* refactor: `resource` parameter in MCP OAuth authorization URL

Updated the `MCPOAuthHandler` to ensure the `resource` parameter is added to the authorization URL even when an error occurs while retrieving it from metadata. This change improves the handling of invalid resource URLs by using the raw value as a fallback, enhancing the robustness of the authorization process.
2026-03-02 15:52:29 -05:00

1076 lines
39 KiB
TypeScript

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 { TokenExchangeMethodEnum, 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 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
private static getForcedTokenEndpointAuthMethod(
tokenExchangeMethod?: TokenExchangeMethodEnum,
): 'client_secret_basic' | 'client_secret_post' | undefined {
if (tokenExchangeMethod === TokenExchangeMethodEnum.DefaultPost) {
return 'client_secret_post';
}
if (tokenExchangeMethod === TokenExchangeMethodEnum.BasicAuthHeader) {
return 'client_secret_basic';
}
return undefined;
}
private static resolveTokenEndpointAuthMethod(options: {
tokenExchangeMethod?: TokenExchangeMethodEnum;
tokenAuthMethods: string[];
preferredMethod?: string;
}): 'client_secret_basic' | 'client_secret_post' | undefined {
const forcedMethod = this.getForcedTokenEndpointAuthMethod(options.tokenExchangeMethod);
const preferredMethod = forcedMethod ?? options.preferredMethod;
if (preferredMethod === 'client_secret_basic' || preferredMethod === 'client_secret_post') {
return preferredMethod;
}
if (options.tokenAuthMethods.includes('client_secret_basic')) {
return 'client_secret_basic';
}
if (options.tokenAuthMethods.includes('client_secret_post')) {
return 'client_secret_post';
}
return undefined;
}
/**
* Creates a fetch function with custom headers injected
*/
private static createOAuthFetch(
headers: Record<string, string>,
clientInfo?: OAuthClientInformation,
): 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);
}
const method = (init?.method ?? 'GET').toUpperCase();
const initBody = init?.body;
let params: URLSearchParams | undefined;
if (initBody instanceof URLSearchParams) {
params = initBody;
} else if (typeof initBody === 'string') {
const parsed = new URLSearchParams(initBody);
if (parsed.has('grant_type')) {
params = parsed;
}
}
/**
* FastMCP 2.14+/MCP SDK 1.24+ token endpoints can be strict about:
* - Content-Type (must be application/x-www-form-urlencoded)
* - where client_id/client_secret are supplied (default_post vs basic header)
*/
if (method === 'POST' && params?.has('grant_type')) {
newHeaders.set('Content-Type', 'application/x-www-form-urlencoded');
if (clientInfo?.client_id) {
let authMethod = clientInfo.token_endpoint_auth_method;
if (!authMethod) {
if (newHeaders.has('Authorization')) {
authMethod = 'client_secret_basic';
} else if (params.has('client_id') || params.has('client_secret')) {
authMethod = 'client_secret_post';
} else if (clientInfo.client_secret) {
authMethod = 'client_secret_post';
} else {
authMethod = 'none';
}
}
if (!clientInfo.client_secret || authMethod === 'none') {
newHeaders.delete('Authorization');
if (!params.has('client_id')) {
params.set('client_id', clientInfo.client_id);
}
} else if (authMethod === 'client_secret_post') {
newHeaders.delete('Authorization');
if (!params.has('client_id')) {
params.set('client_id', clientInfo.client_id);
}
if (!params.has('client_secret')) {
params.set('client_secret', clientInfo.client_secret);
}
} else if (authMethod === 'client_secret_basic') {
if (!newHeaders.has('Authorization')) {
const clientAuth = Buffer.from(
`${clientInfo.client_id}:${clientInfo.client_secret}`,
).toString('base64');
newHeaders.set('Authorization', `Basic ${clientAuth}`);
}
}
}
return fetch(url, {
...init,
body: params.toString(),
headers: newHeaders,
});
}
return fetch(url, {
...init,
headers: newHeaders,
});
};
}
/**
* Discovers OAuth metadata from the server
*/
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 {
// Try to discover resource metadata first
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,
});
}
// Discover OAuth metadata
logger.debug(
`[MCPOAuth] Discovering OAuth metadata from ${sanitizeUrlForLogging(authServerUrl)}`,
);
let rawMetadata = await discoverAuthorizationServerMetadata(authServerUrl, {
fetchFn,
});
// If discovery failed and we're using a path-based URL, try the base URL
if (!rawMetadata && authServerUrl.pathname !== '/') {
const baseUrl = new URL(authServerUrl.origin);
logger.debug(
`[MCPOAuth] Discovery failed with path, trying base URL: ${sanitizeUrlForLogging(baseUrl)}`,
);
rawMetadata = await discoverAuthorizationServerMetadata(baseUrl, {
fetchFn,
});
}
if (!rawMetadata) {
/**
* No metadata discovered - create fallback metadata using default OAuth endpoint paths.
* This mirrors the MCP SDK's behavior where it falls back to /authorize, /token, /register
* when metadata discovery fails (e.g., servers without .well-known endpoints).
* See: https://github.com/modelcontextprotocol/sdk/blob/main/src/client/auth.ts
*/
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,
};
}
/**
* Registers an OAuth client dynamically
*/
private static async registerOAuthClient(
serverUrl: string,
metadata: OAuthMetadata,
oauthHeaders: Record<string, string>,
resourceMetadata?: OAuthProtectedResourceMetadata,
redirectUri?: string,
tokenExchangeMethod?: TokenExchangeMethodEnum,
): 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,
},
);
/** 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,
logo_uri: undefined as string | undefined,
tos_uri: 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'];
const forcedAuthMethod = this.getForcedTokenEndpointAuthMethod(tokenExchangeMethod);
if (forcedAuthMethod) {
clientMetadata.token_endpoint_auth_method = forcedAuthMethod;
} else 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 ${sanitizeUrlForLogging(serverUrl)} with metadata:`,
clientMetadata,
);
const clientInfo = await registerClient(serverUrl, {
metadata: metadata as unknown as SDKOAuthMetadata,
clientMetadata,
fetchFn: this.createOAuthFetch(oauthHeaders),
});
if (forcedAuthMethod) {
clientInfo.token_endpoint_auth_method = forcedAuthMethod;
} else if (!clientInfo.token_endpoint_auth_method) {
clientInfo.token_endpoint_auth_method = clientMetadata.token_endpoint_auth_method;
}
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;
}
/**
* Initiates the OAuth flow for an MCP server
*/
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 {
// 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}`);
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'];
}
/** Metadata based on pre-configured settings */
let tokenEndpointAuthMethod: string;
if (!config.client_secret) {
tokenEndpointAuthMethod = 'none';
} else {
// When token_exchange_method is undefined or not DefaultPost, default to using
// client_secret_basic (Basic Auth header) for token endpoint authentication.
tokenEndpointAuthMethod =
this.getForcedTokenEndpointAuthMethod(config.token_exchange_method) ??
'client_secret_basic';
}
let defaultTokenAuthMethods: string[];
if (tokenEndpointAuthMethod === 'none') {
defaultTokenAuthMethods = ['none'];
} else if (tokenEndpointAuthMethod === 'client_secret_post') {
defaultTokenAuthMethods = ['client_secret_post', 'client_secret_basic'];
} else {
defaultTokenAuthMethods = ['client_secret_basic', 'client_secret_post'];
}
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 ?? defaultTokenAuthMethods,
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,
token_endpoint_auth_method: tokenEndpointAuthMethod,
};
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: ${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)}`,
);
/** 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,
oauthHeaders,
resourceMetadata,
redirectUri,
config?.token_exchange_method,
);
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: ${sanitizeUrlForLogging(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`);
if (resourceMetadata?.resource != null && resourceMetadata.resource) {
try {
const canonicalResource = new URL(resourceMetadata.resource).href;
authorizationUrl.searchParams.set('resource', canonicalResource);
logger.debug(
`[MCPOAuth] Added resource parameter to authorization URL: ${canonicalResource}`,
);
} catch (error) {
authorizationUrl.searchParams.set('resource', resourceMetadata.resource);
logger.error(
`[MCPOAuth] Invalid resource URL from metadata for ${serverName}: ` +
`'${resourceMetadata.resource}'. Using raw value as fallback.`,
error,
);
}
} 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;
}
}
/**
* Completes the OAuth flow by exchanging the authorization code for tokens
*/
static async completeOAuthFlow(
flowId: string,
authorizationCode: string,
flowManager: FlowStateManager<MCPOAuthTokens>,
oauthHeaders: Record<string, string>,
): 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');
}
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, metadata.clientInfo),
});
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,
};
/** 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`;
}
/**
* Processes and logs a token refresh response from an OAuth server.
* Normalizes the response to MCPOAuthTokens format and logs debug info about refresh token rotation.
*/
private static processRefreshResponse(
tokens: Record<string, unknown>,
serverName: string,
source: string,
): MCPOAuthTokens {
const hasNewRefreshToken = !!tokens.refresh_token;
logger.debug(`[MCPOAuth] Token refresh response (${source})`, {
serverName,
has_new_access_token: !!tokens.access_token,
has_new_refresh_token: hasNewRefreshToken,
refresh_token_rotated: hasNewRefreshToken,
expires_in: tokens.expires_in,
});
if (!hasNewRefreshToken) {
logger.debug(
`[MCPOAuth] OAuth server did not return new refresh_token for ${serverName} - existing refresh token remains valid (normal for non-rotating providers)`,
);
}
return {
...tokens,
obtained_at: Date.now(),
expires_at:
typeof tokens.expires_in === 'number' ? Date.now() + tokens.expires_in * 1000 : undefined,
} as MCPOAuthTokens;
}
/**
* Refreshes OAuth tokens using a refresh token
*/
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 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;
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 {
/** Auto-discover OAuth configuration for refresh */
const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl, {
fetchFn: this.createOAuthFetch(oauthHeaders),
});
if (!oauthMetadata) {
/**
* No metadata discovered - use fallback /token endpoint.
* This mirrors the MCP SDK's behavior for legacy servers without .well-known endpoints.
*/
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,
});
/** Add scope if available */
if (metadata.clientInfo.scope) {
body.append('scope', metadata.clientInfo.scope);
}
const headers: HeadersInit = {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
...oauthHeaders,
};
/** Handle authentication based on server's advertised methods */
if (metadata.clientInfo.client_secret) {
/** Default to client_secret_basic if no methods specified (per RFC 8414) */
const tokenAuthMethods = authMethods ?? ['client_secret_basic'];
const authMethod = this.resolveTokenEndpointAuthMethod({
tokenExchangeMethod: config?.token_exchange_method,
tokenAuthMethods,
preferredMethod: metadata.clientInfo.token_endpoint_auth_method,
});
if (authMethod === 'client_secret_basic') {
/** Use Basic auth */
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 (authMethod === 'client_secret_post') {
/** Use client_secret_post */
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 {
/** No recognized method, default to Basic auth per RFC */
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 {
/** For public clients, client_id must be in the body */
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 this.processRefreshResponse(tokens, metadata.serverName, 'stored client info');
}
// 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 body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
if (config.scope) {
body.append('scope', config.scope);
}
const headers: HeadersInit = {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
...oauthHeaders,
};
/** Handle authentication based on configured methods */
if (config.client_secret) {
/** Default to client_secret_basic if no methods specified (per RFC 8414) */
const tokenAuthMethods = config.token_endpoint_auth_methods_supported ?? [
'client_secret_basic',
];
const authMethod = this.resolveTokenEndpointAuthMethod({
tokenExchangeMethod: config.token_exchange_method,
tokenAuthMethods,
});
if (authMethod === 'client_secret_basic') {
/** Use Basic auth */
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 (authMethod === 'client_secret_post') {
/** Use client_secret_post */
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 {
/** No recognized method, default to Basic auth per RFC */
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 {
/** For public clients, client_id must be in the body */
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 this.processRefreshResponse(tokens, metadata.serverName, 'pre-configured OAuth');
}
/** 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 oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl, {
fetchFn: this.createOAuthFetch(oauthHeaders),
});
let tokenUrl: URL;
if (!oauthMetadata?.token_endpoint) {
/**
* No metadata or token_endpoint discovered - use fallback /token endpoint.
* This mirrors the MCP SDK's behavior for legacy servers without .well-known endpoints.
*/
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 = {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
...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 this.processRefreshResponse(tokens, metadata.serverName, 'auto-discovered OAuth');
} catch (error) {
logger.error(`[MCPOAuth] Failed to refresh tokens for ${metadata.serverName}`, error);
throw error;
}
}
/**
* Revokes OAuth tokens at the authorization server (RFC 7009)
*/
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> {
// build the revoke URL, falling back to the server URL + /revoke if no revocation endpoint is provided
const revokeUrl: URL =
metadata.revocationEndpoint != null
? new URL(metadata.revocationEndpoint)
: new URL('/revoke', metadata.serverUrl);
// detect auth method to use
const authMethods = metadata.revocationEndpointAuthMethodsSupported ?? [
'client_secret_basic', // RFC 8414 (https://datatracker.ietf.org/doc/html/rfc8414)
];
const usesBasicAuth = authMethods.includes('client_secret_basic');
const usesClientSecretPost = authMethods.includes('client_secret_post');
// init the request headers
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
...oauthHeaders,
};
// init the request body
const body = new URLSearchParams({ token });
body.set('token_type_hint', tokenType === 'refresh' ? 'refresh_token' : 'access_token');
// process auth method
if (usesBasicAuth) {
// encode the client id and secret and add to the headers
const credentials = Buffer.from(`${metadata.clientId}:${metadata.clientSecret}`).toString(
'base64',
);
headers['Authorization'] = `Basic ${credentials}`;
} else if (usesClientSecretPost) {
// add the client id and secret to the body
body.set('client_secret', metadata.clientSecret);
body.set('client_id', metadata.clientId);
}
// perform the revoke request
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}`);
}
}
}