mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 08:20:14 +01:00
* feat: Add OpenID Connect federated provider token support
Implements support for passing federated provider tokens (Cognito, Azure AD, Auth0)
as variables in LibreChat's librechat.yaml configuration for both custom endpoints
and MCP servers.
Features:
- New LIBRECHAT_OPENID_* template variables for federated provider tokens
- JWT claims parsing from ID tokens without verification (for claim extraction)
- Token validation with expiration checking
- Support for multiple token storage locations (federatedTokens, openidTokens)
- Integration with existing template variable system
- Comprehensive test suite with Cognito-specific scenarios
- Provider-agnostic design supporting Cognito, Azure AD, Auth0, etc.
Security:
- Server-side only token processing
- Automatic token expiration validation
- Graceful fallbacks for missing/invalid tokens
- No client-side token exposure
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: Add federated token propagation to OIDC authentication strategies
Adds federatedTokens object to user during authentication to enable
federated provider token template variables in LibreChat configuration.
Changes:
- OpenID JWT Strategy: Extract raw JWT from Authorization header and
attach as federatedTokens.access_token to enable {{LIBRECHAT_OPENID_TOKEN}}
placeholder resolution
- OpenID Strategy: Attach tokenset tokens as federatedTokens object to
standardize token access across both authentication strategies
This enables proper token propagation for custom endpoints and MCP
servers that require federated provider tokens for authorization.
Resolves missing token issue reported by @ramden in PR #9931
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Denis Ramic <denis.ramic@nfon.com>
Co-Authored-By: Claude <noreply@anthropic.com>
* test: Add federatedTokens validation tests for OIDC strategies
Adds comprehensive test coverage for the federated token propagation
feature implemented in the authentication strategies.
Tests added:
- Verify federatedTokens object is attached to user with correct structure
(access_token, refresh_token, expires_at)
- Verify both tokenset and federatedTokens are present in user object
- Ensure tokens from OIDC provider are correctly propagated
Also fixes existing test suite by adding missing mocks:
- isEmailDomainAllowed function mock
- findOpenIDUser function mock
These tests validate the fix from commit 5874ba29f that enables
{{LIBRECHAT_OPENID_TOKEN}} template variable functionality.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: Remove implementation documentation file
The PR description already contains all necessary implementation details.
This documentation file is redundant and was requested to be removed.
* fix: skip s256 check
* fix(openid): handle missing refresh token in Cognito token refresh response
When OPENID_REUSE_TOKENS=true, the token refresh flow was failing because
Cognito (and most OAuth providers) don't return a new refresh token in the
refresh grant response - they only return new access and ID tokens.
Changes:
- Modified setOpenIDAuthTokens() to accept optional existingRefreshToken parameter
- Updated validation to only require access_token (refresh_token now optional)
- Added logic to reuse existing refresh token when not provided in tokenset
- Updated refreshController to pass original refresh token as fallback
- Added comments explaining standard OAuth 2.0 refresh token behavior
This fixes the "Token is not present. User is not authenticated." error that
occurred during silent token refresh with Cognito as the OpenID provider.
Fixes: Authentication loop with OPENID_REUSE_TOKENS=true and AWS Cognito
* fix(openid): extract refresh token from cookies for template variable replacement
When OPENID_REUSE_TOKENS=true, the openIdJwtStrategy populates user.federatedTokens
to enable template variable replacement (e.g., {{LIBRECHAT_OPENID_ACCESS_TOKEN}}).
However, the refresh_token field was incorrectly sourced from payload.refresh_token,
which is always undefined because:
1. JWTs don't contain refresh tokens in their payload
2. The JWT itself IS the access token
3. Refresh tokens are separate opaque tokens stored in HTTP-only cookies
This caused extractOpenIDTokenInfo() to receive incomplete federatedTokens,
resulting in template variables remaining unreplaced in headers.
**Root Cause:**
- Line 90: `refresh_token: payload.refresh_token` (always undefined)
- JWTs only contain access token data in their claims
- Refresh tokens are separate, stored securely in cookies
**Solution:**
- Import `cookie` module to parse cookies from request
- Extract refresh token from `refreshToken` cookie
- Populate federatedTokens with both access token (JWT) and refresh token (from cookie)
**Impact:**
- Template variables like {{LIBRECHAT_OPENID_ACCESS_TOKEN}} now work correctly
- Headers in librechat.yaml are properly replaced with actual tokens
- MCP server authentication with federated tokens now functional
**Technical Details:**
- passReqToCallback=true in JWT strategy provides req object access
- Refresh token extracted via cookies.parse(req.headers.cookie).refreshToken
- Falls back gracefully if cookie header or refreshToken is missing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: re-resolve headers on each request to pick up fresh federatedTokens
- OpenAIClient now re-resolves headers in chatCompletion() before each API call
- This ensures template variables like {{LIBRECHAT_OPENID_TOKEN}} are replaced
with actual token values from req.user.federatedTokens
- initialize.js now stores original template headers instead of pre-resolved ones
- Fixes template variable replacement when OPENID_REUSE_TOKENS=true
The issue was that headers were only resolved once during client initialization,
before openIdJwtStrategy had populated user.federatedTokens. Now headers are
re-resolved on every request with the current user's fresh tokens.
* debug: add logging to track header resolution in OpenAIClient
* debug: log tokenset structure after refresh to diagnose missing access_token
* fix: set federatedTokens on user object after OAuth refresh
- After successful OAuth token refresh, the user object was not being
updated with federatedTokens
- This caused template variable resolution to fail on subsequent requests
- Now sets user.federatedTokens with access_token, id_token, refresh_token
and expires_at from the refreshed tokenset
- Fixes template variables like {{LIBRECHAT_OPENID_TOKEN}} not being
replaced after token refresh
- Related to PR #9931 (OpenID federated token support)
* fix(openid): pass user object through agent chain for template variable resolution
Root cause: buildAgentContext in agents/run.ts called resolveHeaders without
the user parameter, preventing OpenID federated token template variables from
being resolved in agent runtime parameters.
Changes:
- packages/api/src/agents/run.ts: Add user parameter to createRun signature
- packages/api/src/agents/run.ts: Pass user to resolveHeaders in buildAgentContext
- api/server/controllers/agents/client.js: Pass user when calling createRun
- api/server/services/Endpoints/bedrock/options.js: Add resolveHeaders call with debug logging
- api/server/services/Endpoints/custom/initialize.js: Add debug logging
- packages/api/src/utils/env.ts: Add comprehensive debug logging and stack traces
- packages/api/src/utils/oidc.ts: Fix eslint errors (unused type, explicit any)
This ensures template variables like {{LIBRECHAT_OPENID_TOKEN}} and
{{LIBRECHAT_USER_OPENIDID}} are properly resolved in both custom endpoint
headers and Bedrock AgentCore runtime parameters.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: remove debug logging from OpenID token template feature
Removed excessive debug logging that was added during development to make
the PR more suitable for upstream review:
- Removed 7 debug statements from OpenAIClient.js
- Removed all console.log statements from packages/api/src/utils/env.ts
- Removed debug logging from bedrock/options.js
- Removed debug logging from custom/initialize.js
- Removed debug statement from AuthController.js
This reduces the changeset by ~50 lines while maintaining full functionality
of the OpenID federated token template variable feature.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* test(openid): add comprehensive unit tests for template variable substitution
- Add 34 unit tests for OIDC token utilities (oidc.spec.ts)
- Test coverage for token extraction, validation, and placeholder processing
- Integration tests for full OpenID token flow
- All tests pass with comprehensive edge case coverage
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* test: fix OpenID federated tokens test failures
- Add serverMetadata() mock to openid-client mock configuration
* Fixes TypeError in openIdJwtStrategy.js where serverMetadata() was being called
* Mock now returns jwks_uri and end_session_endpoint as expected by the code
- Update outdated initialize.spec.js test
* Remove test expecting resolveHeaders call during initialization
* Header resolution was refactored to be deferred until LLM request time
* Update test to verify options are returned correctly with useLegacyContent flag
Fixes #9931 CI failures for backend unit tests
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: fix package-lock.json conflict
* chore: sync package-log with upstream
* chore: cleanup
* fix: use createSafeUser
* fix: fix createSafeUser signature
* chore: remove comments
* chore: purge comments
* fix: update Jest testPathPattern to testPathPatterns for Jest 30+ compatibility
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Denis Ramic <denis.ramic@nfon.com>
Co-authored-by: kristjanaapro <kristjana@apro.is>
chore: import order and add back JSDoc for OpenID JWT callback
854 lines
30 KiB
TypeScript
854 lines
30 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 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
|
|
|
|
/**
|
|
* Creates a fetch function with custom headers injected
|
|
*/
|
|
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,
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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)}`,
|
|
);
|
|
const rawMetadata = await discoverAuthorizationServerMetadata(authServerUrl, {
|
|
fetchFn,
|
|
});
|
|
|
|
if (!rawMetadata) {
|
|
logger.error(
|
|
`[MCPOAuth] Failed to discover OAuth metadata from ${sanitizeUrlForLogging(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,
|
|
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,
|
|
},
|
|
);
|
|
|
|
/** 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 ${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) {
|
|
// 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),
|
|
});
|
|
|
|
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 */
|
|
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,
|
|
});
|
|
|
|
/** 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,
|
|
);
|
|
|
|
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) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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),
|
|
});
|
|
|
|
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`;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
throw new Error('Failed to discover OAuth metadata for token refresh');
|
|
}
|
|
if (!oauthMetadata.token_endpoint) {
|
|
throw new Error('No token endpoint found in OAuth metadata');
|
|
}
|
|
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 = {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Accept: 'application/json',
|
|
...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 usesBasicAuth = tokenAuthMethods.includes('client_secret_basic');
|
|
const usesClientSecretPost = tokenAuthMethods.includes('client_secret_post');
|
|
|
|
if (usesBasicAuth) {
|
|
/** 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 (usesClientSecretPost) {
|
|
/** 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 {
|
|
...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 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,
|
|
};
|
|
|
|
/** 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 usesBasicAuth = tokenAuthMethods.includes('client_secret_basic');
|
|
const usesClientSecretPost = tokenAuthMethods.includes('client_secret_post');
|
|
|
|
if (usesBasicAuth) {
|
|
/** 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 (usesClientSecretPost) {
|
|
/** 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 {
|
|
...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 oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl, {
|
|
fetchFn: this.createOAuthFetch(oauthHeaders),
|
|
});
|
|
|
|
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',
|
|
...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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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}`);
|
|
}
|
|
}
|
|
}
|