diff --git a/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts b/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts index 8d031c33a7..af0141ee8e 100644 --- a/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts +++ b/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts @@ -1314,6 +1314,34 @@ describe('ServerConfigsDB', () => { expect(typeof created.config.updatedAt).toBe('number'); expect(created.config.updatedAt).toBeLessThanOrEqual(Date.now()); }); + + it('should strip admin-only OAuth audience fields from existing DB-backed user configs', async () => { + const config = createSSEConfig('Legacy Audience Test', undefined, { + client_id: 'public-client-id', + }); + const created = await serverConfigsDB.add('temp', config, userId); + + await mongoose.models.MCPServer.updateOne( + { serverName: created.serverName }, + { + $set: { + 'config.oauth.audience': 'https://api.example.com', + 'config.oauth.forward_audience_on_refresh': false, + 'config.oauth.authorization_url': + 'https://auth.example.com/authorize?audience=https%3A%2F%2Fapi.example.com&client=ok', + 'config.oauth.token_url': + 'https://auth.example.com/token?resource=https%3A%2F%2Fapi.example.com&foo=bar', + }, + }, + ); + + const result = await serverConfigsDB.get(created.serverName, userId); + expect(result?.oauth?.client_id).toBe('public-client-id'); + expect(result?.oauth?.authorization_url).toBe('https://auth.example.com/authorize?client=ok'); + expect(result?.oauth?.token_url).toBe('https://auth.example.com/token?foo=bar'); + expect(result?.oauth?.audience).toBeUndefined(); + expect(result?.oauth?.forward_audience_on_refresh).toBeUndefined(); + }); }); describe('edge cases', () => { diff --git a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts index 5eaf92a2c2..bae25a96aa 100644 --- a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts +++ b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts @@ -27,6 +27,8 @@ const DANGEROUS_CREDENTIAL_PATTERNS = [ /\{\{LIBRECHAT_BODY_[^}]+\}\}/g, ]; +const BLOCKED_USER_OAUTH_ENDPOINT_PARAMS = ['audience', 'resource'] as const; + /** * Sanitizes headers by removing dangerous credential placeholders. * This prevents credential exfiltration when MCP servers are shared between users. @@ -52,6 +54,44 @@ function sanitizeCredentialPlaceholders( return sanitized; } +function stripBlockedOAuthEndpointParams(url?: string): string | undefined { + if (!url) { + return url; + } + + try { + const parsed = new URL(url); + BLOCKED_USER_OAUTH_ENDPOINT_PARAMS.forEach((param) => parsed.searchParams.delete(param)); + return parsed.href; + } catch { + return url; + } +} + +function sanitizeUserManagedOAuthConfig(config: ParsedServerConfig): ParsedServerConfig { + if (!config.oauth) { + return config; + } + + const { + audience: _audience, + forward_audience_on_refresh: _forwardAudienceOnRefresh, + ...oauth + } = config.oauth; + return { + ...config, + oauth: { + ...oauth, + ...(config.oauth.authorization_url && { + authorization_url: stripBlockedOAuthEndpointParams(config.oauth.authorization_url), + }), + ...(config.oauth.token_url && { + token_url: stripBlockedOAuthEndpointParams(config.oauth.token_url), + }), + }, + }; +} + /** * DB backed config storage * Handles CRUD Methods of dynamic mcp servers @@ -126,12 +166,12 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { ); } - const sanitizedConfig = { + const sanitizedConfig = sanitizeUserManagedOAuthConfig({ ...config, headers: sanitizeCredentialPlaceholders( (config as ParsedServerConfig & { headers?: Record }).headers, ), - } as ParsedServerConfig; + } as ParsedServerConfig); /** Transformed user-provided API key config (adds customUserVars and headers) */ const transformedConfig = this.transformUserApiKeyConfig(sanitizedConfig); @@ -175,12 +215,12 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { const existingServer = await this._dbMethods.findMCPServerByServerName(serverName); - let configToSave: ParsedServerConfig = { + let configToSave: ParsedServerConfig = sanitizeUserManagedOAuthConfig({ ...config, headers: sanitizeCredentialPlaceholders( (config as ParsedServerConfig & { headers?: Record }).headers, ), - } as ParsedServerConfig; + } as ParsedServerConfig); /** Transformed user-provided API key config (adds customUserVars and headers) */ configToSave = this.transformUserApiKeyConfig(configToSave); @@ -421,7 +461,7 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { source: 'user', updatedAt: serverDBDoc.updatedAt?.getTime(), }; - return await this.decryptConfig(config); + return sanitizeUserManagedOAuthConfig(await this.decryptConfig(config)); } /** diff --git a/packages/data-provider/specs/mcp.spec.ts b/packages/data-provider/specs/mcp.spec.ts index 82ae562580..54c424c74d 100644 --- a/packages/data-provider/specs/mcp.spec.ts +++ b/packages/data-provider/specs/mcp.spec.ts @@ -203,6 +203,81 @@ describe('MCP schemas', () => { }); }); + describe('user-managed OAuth audience restrictions', () => { + it('should reject audience from user-managed OAuth configuration', () => { + const result = MCPServerUserInputSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + oauth: { + audience: 'https://api.example.com', + }, + }); + + expect(result.success).toBe(false); + }); + + it('should reject refresh audience forwarding from user-managed OAuth configuration', () => { + const result = MCPServerUserInputSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + oauth: { + forward_audience_on_refresh: false, + }, + }); + + expect(result.success).toBe(false); + }); + + it('should reject audience query parameters in user-managed OAuth authorization URLs', () => { + const result = MCPServerUserInputSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + oauth: { + authorization_url: 'https://auth.example.com/authorize?audience=https://api.example.com', + token_url: 'https://auth.example.com/token', + client_id: 'public-client-id', + }, + }); + + expect(result.success).toBe(false); + }); + + it('should reject resource query parameters in user-managed OAuth token URLs', () => { + const result = MCPServerUserInputSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + oauth: { + authorization_url: 'https://auth.example.com/authorize', + token_url: 'https://auth.example.com/token?resource=https://api.example.com', + client_id: 'public-client-id', + }, + }); + + expect(result.success).toBe(false); + }); + + it('should continue accepting non-audience OAuth fields from user-managed configuration', () => { + const result = MCPServerUserInputSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + oauth: { + authorization_url: 'https://auth.example.com/authorize', + token_url: 'https://auth.example.com/token', + client_id: 'public-client-id', + scope: 'read execute', + }, + }); + + expect(result.success).toBe(true); + if (result.success && result.data.oauth) { + expect(result.data.oauth.authorization_url).toBe('https://auth.example.com/authorize'); + expect(result.data.oauth.token_url).toBe('https://auth.example.com/token'); + expect(result.data.oauth.client_id).toBe('public-client-id'); + expect(result.data.oauth.scope).toBe('read execute'); + } + }); + }); + describe('OAuth confidential client endpoint pinning', () => { it('should reject client_secret without client_id', () => { const result = MCPOptionsSchema.safeParse({ diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 158a62ce9f..4347168fbb 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -2,90 +2,130 @@ import { z } from 'zod'; import { TokenExchangeMethodEnum } from './types/agents'; import { extractEnvVariable } from './utils'; -const OAuthOptionsSchema = z - .object({ - /** OAuth authorization endpoint (optional - can be auto-discovered) */ - authorization_url: z.string().url().optional(), - /** OAuth token endpoint (optional - can be auto-discovered) */ - token_url: z.string().url().optional(), - /** OAuth client ID (optional - can use dynamic registration) */ - client_id: z.string().optional(), - /** OAuth client secret (requires explicit authorization and token endpoints) */ - client_secret: z.string().optional(), - /** OAuth scopes to request */ - scope: z.string().optional(), - /** OAuth redirect URI (defaults to /api/mcp/{serverName}/oauth/callback) */ - redirect_uri: z.string().url().optional(), - /** Token exchange method */ - token_exchange_method: z.nativeEnum(TokenExchangeMethodEnum).optional(), - /** Supported grant types (defaults to ['authorization_code', 'refresh_token']) */ - grant_types_supported: z.array(z.string()).optional(), - /** Supported token endpoint authentication methods (defaults to ['client_secret_basic', 'client_secret_post']) */ - token_endpoint_auth_methods_supported: z.array(z.string()).optional(), - /** Supported response types (defaults to ['code']) */ - response_types_supported: z.array(z.string()).optional(), - /** Supported code challenge methods (defaults to ['S256', 'plain']) */ - code_challenge_methods_supported: z.array(z.string()).optional(), - /** Skip code challenge validation and force S256 (useful for providers like AWS Cognito that support S256 but don't advertise it) */ - skip_code_challenge_check: z.boolean().optional(), - /** - * Auth0/Cognito-style `audience` parameter. Authorization servers that pre-date - * RFC 8707 — most prominently Auth0 — issue API-scoped access tokens only when - * the `/authorize` request advertises an `audience`. RFC 8707 `resource` (set - * automatically from Protected Resource Metadata) is the standards-conformant - * route; `audience` covers the providers that ignore it. - * - * When set, the value is forwarded as-is on `/authorize` (both pre-configured - * and DCR-discovered paths). Whether it is also forwarded on the - * `refresh_token` grant is controlled by `forward_audience_on_refresh` below. - * - * The `authorization_code` exchange intentionally never receives `audience` — - * Auth0 binds audience from the original `/authorize` request and embeds it - * in the issued access token; sending it again is redundant. - * - * No canonicalization is applied — the audience identifier is provider-defined - * and may differ from the MCP server URL. - */ - audience: z.string().min(1).optional(), - /** - * Whether to also forward `audience` on the `refresh_token` grant body. - * - * Default: `true`. Required for Auth0, which strips the API audience from - * refreshed access tokens unless `audience` is re-supplied on every refresh - * — without it the next MCP call 401s once the initial access token expires. - * - * Set to `false` for providers that document refresh requests as - * `grant_type` + `client_id` + `refresh_token` only (Cognito and other - * strict OAuth 2.0 token endpoints). Those providers maintain the original - * `aud` claim across refreshes when the initial token was resource-bound, - * so the extra parameter is redundant and may be rejected as - * `invalid_request`. - * - * Ignored when `audience` itself is not configured. - */ - forward_audience_on_refresh: z.boolean().optional(), - /** OAuth revocation endpoint (optional - can be auto-discovered) */ - revocation_endpoint: z.string().url().optional(), - /** OAuth revocation endpoint authentication methods supported (optional - can be auto-discovered) */ - revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(), - }) - .superRefine((oauth, ctx) => { - if (oauth.client_secret && !oauth.client_id) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['client_secret'], - message: 'OAuth client_secret requires client_id', - }); - } +const validateOAuthClientCredentials = ( + oauth: { + client_id?: string; + client_secret?: string; + authorization_url?: string; + token_url?: string; + }, + ctx: z.RefinementCtx, +): void => { + if (oauth.client_secret && !oauth.client_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['client_secret'], + message: 'OAuth client_secret requires client_id', + }); + } - if (oauth.client_id && oauth.client_secret && (!oauth.authorization_url || !oauth.token_url)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['client_secret'], - message: 'OAuth client_secret with client_id requires both authorization_url and token_url', - }); - } - }); + if (oauth.client_id && oauth.client_secret && (!oauth.authorization_url || !oauth.token_url)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['client_secret'], + message: 'OAuth client_secret with client_id requires both authorization_url and token_url', + }); + } +}; + +const OAuthOptionsBaseSchema = z.object({ + /** OAuth authorization endpoint (optional - can be auto-discovered) */ + authorization_url: z.string().url().optional(), + /** OAuth token endpoint (optional - can be auto-discovered) */ + token_url: z.string().url().optional(), + /** OAuth client ID (optional - can use dynamic registration) */ + client_id: z.string().optional(), + /** OAuth client secret (requires explicit authorization and token endpoints) */ + client_secret: z.string().optional(), + /** OAuth scopes to request */ + scope: z.string().optional(), + /** OAuth redirect URI (defaults to /api/mcp/{serverName}/oauth/callback) */ + redirect_uri: z.string().url().optional(), + /** Token exchange method */ + token_exchange_method: z.nativeEnum(TokenExchangeMethodEnum).optional(), + /** Supported grant types (defaults to ['authorization_code', 'refresh_token']) */ + grant_types_supported: z.array(z.string()).optional(), + /** Supported token endpoint authentication methods (defaults to ['client_secret_basic', 'client_secret_post']) */ + token_endpoint_auth_methods_supported: z.array(z.string()).optional(), + /** Supported response types (defaults to ['code']) */ + response_types_supported: z.array(z.string()).optional(), + /** Supported code challenge methods (defaults to ['S256', 'plain']) */ + code_challenge_methods_supported: z.array(z.string()).optional(), + /** Skip code challenge validation and force S256 (useful for providers like AWS Cognito that support S256 but don't advertise it) */ + skip_code_challenge_check: z.boolean().optional(), + /** + * Auth0/Cognito-style `audience` parameter. Authorization servers that pre-date + * RFC 8707 — most prominently Auth0 — issue API-scoped access tokens only when + * the `/authorize` request advertises an `audience`. RFC 8707 `resource` (set + * automatically from Protected Resource Metadata) is the standards-conformant + * route; `audience` covers the providers that ignore it. + * + * When set, the value is forwarded as-is on `/authorize` (both pre-configured + * and DCR-discovered paths). Whether it is also forwarded on the + * `refresh_token` grant is controlled by `forward_audience_on_refresh` below. + * + * The `authorization_code` exchange intentionally never receives `audience` — + * Auth0 binds audience from the original `/authorize` request and embeds it + * in the issued access token; sending it again is redundant. + * + * No canonicalization is applied — the audience identifier is provider-defined + * and may differ from the MCP server URL. This field is only accepted from + * trusted/admin MCP configuration and is rejected from user-managed servers. + */ + audience: z.string().min(1).optional(), + /** + * Whether to also forward `audience` on the `refresh_token` grant body. + * + * Default: `true`. Required for Auth0, which strips the API audience from + * refreshed access tokens unless `audience` is re-supplied on every refresh + * — without it the next MCP call 401s once the initial access token expires. + * + * Set to `false` for providers that document refresh requests as + * `grant_type` + `client_id` + `refresh_token` only (Cognito and other + * strict OAuth 2.0 token endpoints). Those providers maintain the original + * `aud` claim across refreshes when the initial token was resource-bound, + * so the extra parameter is redundant and may be rejected as + * `invalid_request`. + * + * Ignored when `audience` itself is not configured. + */ + forward_audience_on_refresh: z.boolean().optional(), + /** OAuth revocation endpoint (optional - can be auto-discovered) */ + revocation_endpoint: z.string().url().optional(), + /** OAuth revocation endpoint authentication methods supported (optional - can be auto-discovered) */ + revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(), +}); + +const OAuthOptionsSchema = OAuthOptionsBaseSchema.superRefine(validateOAuthClientCredentials); + +const BLOCKED_USER_OAUTH_ENDPOINT_PARAMS = ['audience', 'resource'] as const; + +const userOAuthEndpointUrlSchema = z + .string() + .url() + .refine( + (value) => { + try { + const { searchParams } = new URL(value); + return BLOCKED_USER_OAUTH_ENDPOINT_PARAMS.every((param) => !searchParams.has(param)); + } catch { + return true; + } + }, + { message: 'OAuth endpoint URLs cannot include audience or resource query parameters' }, + ); + +const UserOAuthOptionsSchema = OAuthOptionsBaseSchema.omit({ + audience: true, + forward_audience_on_refresh: true, +}) + .extend({ + authorization_url: userOAuthEndpointUrlSchema.optional(), + token_url: userOAuthEndpointUrlSchema.optional(), + audience: z.never().optional(), + forward_audience_on_refresh: z.never().optional(), + }) + .superRefine(validateOAuthClientCredentials); const BaseOptionsSchema = z.object({ /** Display name for the MCP server - only letters, numbers, and spaces allowed */ @@ -300,6 +340,11 @@ const omitServerManagedFields = >(schema: T oauth_headers: true, }); +const userManagedServerFields = >(schema: T) => + omitServerManagedFields(schema).extend({ + oauth: UserOAuthOptionsSchema.optional(), + }); + const envVarPattern = /\$\{[^}]+\}/; const isWsProtocol = (val: string): boolean => /^wss?:/i.test(val); const isHttpProtocol = (val: string): boolean => /^https?:/i.test(val); @@ -320,7 +365,8 @@ const userUrlSchema = (protocolCheck: (val: string) => boolean, message: string) /** * MCP Server configuration that comes from UI/API input only. * Omits server-managed fields like startup, timeout, customUserVars, etc. - * Allows: title, description, url, iconPath, oauth (user credentials) + * Allows: title, description, url, iconPath, oauth (user credentials). + * Admin-only OAuth audience fields are rejected for user-managed servers. * * SECURITY: Stdio transport is intentionally excluded from user input. * Stdio allows arbitrary command execution and should only be configured @@ -334,14 +380,14 @@ const userUrlSchema = (protocolCheck: (val: string) => boolean, message: string) * file://, ftp://, javascript:, and other non-network schemes. */ export const MCPServerUserInputSchema = z.union([ - omitServerManagedFields(WebSocketOptionsSchema).extend({ + userManagedServerFields(WebSocketOptionsSchema).extend({ url: userUrlSchema(isWsProtocol, 'WebSocket URL must use ws:// or wss://'), }), - omitServerManagedFields(SSEOptionsSchema).extend({ + userManagedServerFields(SSEOptionsSchema).extend({ proxy: z.never().optional(), url: userUrlSchema(isHttpProtocol, 'SSE URL must use http:// or https://'), }), - omitServerManagedFields(StreamableHTTPOptionsSchema).extend({ + userManagedServerFields(StreamableHTTPOptionsSchema).extend({ proxy: z.never().optional(), url: userUrlSchema(isHttpProtocol, 'Streamable HTTP URL must use http:// or https://'), }),