From c6a6f2e3aed7841959f27a370cd5cab2d0535041 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Sat, 30 May 2026 15:59:39 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=AA=20feat:=20MCP=20OAuth=20-=20Suppor?= =?UTF-8?q?t=20`audience`=20parameter=20for=20Auth0/Cognito-style=20provid?= =?UTF-8?q?ers=20(#13402)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp/oauth): support audience parameter for Auth0/Cognito-style providers LibreChat already follows RFC 9728 (Protected Resource Metadata discovery) and RFC 8707 (resource indicators on /authorize). However, authorization servers that pre-date RFC 8707 — most prominently Auth0 — issue API-scoped access tokens only when an Auth0-specific 'audience' parameter is supplied on /authorize and /token. Without it, refresh_token responses strip the API audience and the next MCP call 401s. This change adds an optional 'audience' field to OAuthOptionsSchema and forwards it on: * pre-configured authorize URL build * discovered (DCR + RFC 9728) authorize URL build * refresh_token grant body 'resource' (RFC 8707) is left untouched and remains the standards-conformant route; 'audience' covers providers that ignore 'resource'. The two are independent — providers may accept either, both, or neither, so we forward whichever the operator configures. Schema tests added; no behavioral change for existing configs (field is optional with no default). Refs: MCP Authorization Spec 2025-06-18, RFC 9728, RFC 8707. * ci: build audience-fix branch image to ghcr.io/freudator86/librechat:audience-fix * Revert "ci: build audience-fix branch image to ghcr.io/freudator86/librechat:audience-fix" This reverts commit 7b3dfa6cd7c67354137284cb600d0fd00f4735b9. * tests: assert audience param in authorize URL + refresh body; tighten schema (.min(1)); refine comment to reflect actual code paths Adresses PR review: - audience: z.string().min(1).optional() rejects empty strings - schema comment now precisely lists the two code paths (authorize + refresh_token grant); explicitly notes the authorization_code exchange intentionally does not receive audience because Auth0 binds it from the initial /authorize request - new MCPOAuthAudience.test.ts: 4 cases — authorize URL with/without audience, refresh body with/without audience — using a local recording HTTP server (no shared helper changes) - new schema test: empty-string audience is rejected * style: inline two logger.debug calls (prettier) * style: inline third audience-debug log (prettier) * feat(mcp/oauth): add forward_audience_on_refresh opt-out for strict token endpoints (Cognito) Addresses Codex review P2 'Avoid sending audience on refresh grants': the previous behavior forwarded audience on every refresh_token grant, which is correct for Auth0 (strips the audience claim otherwise) but is non-standard for Cognito and other strict OAuth 2.0 token endpoints that document refresh as grant_type + client_id + refresh_token only. New optional boolean 'forward_audience_on_refresh' (default: true) preserves the existing Auth0-friendly default while letting operators of strict tenants opt out cleanly. Schema + handler tests cover both cases. No behavioral change for existing configs. * style: format MCP OAuth refresh audience log --------- Co-authored-by: Tim Freudenthal Co-authored-by: Danny Avila --- .../mcp/__tests__/MCPOAuthAudience.test.ts | 249 ++++++++++++++++++ packages/api/src/mcp/oauth/handler.ts | 40 +++ packages/data-provider/specs/mcp.spec.ts | 88 +++++++ packages/data-provider/src/mcp.ts | 36 +++ 4 files changed, 413 insertions(+) create mode 100644 packages/api/src/mcp/__tests__/MCPOAuthAudience.test.ts diff --git a/packages/api/src/mcp/__tests__/MCPOAuthAudience.test.ts b/packages/api/src/mcp/__tests__/MCPOAuthAudience.test.ts new file mode 100644 index 0000000000..80790da57d --- /dev/null +++ b/packages/api/src/mcp/__tests__/MCPOAuthAudience.test.ts @@ -0,0 +1,249 @@ +/** + * Tests that the optional `audience` field on `mcpServers..oauth` is + * forwarded into: + * - the pre-configured authorize URL build + * - the `refresh_token` grant body + * + * The authorize URL is verified by parsing the URL produced by + * `initiateOAuthFlow`. The refresh case is verified by intercepting the + * outbound /token POST body via a local HTTP server that records every + * request body it receives. + */ + +import * as http from 'http'; +import * as net from 'net'; +import type { Socket } from 'net'; +import { TokenExchangeMethodEnum } from 'librechat-data-provider'; +import { MCPOAuthHandler } from '~/mcp/oauth'; + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + getTenantId: jest.fn(), + SYSTEM_TENANT_ID: '__SYSTEM__', + encryptV2: jest.fn(async (val: string) => `enc:${val}`), + decryptV2: jest.fn(async (val: string) => val.replace(/^enc:/, '')), +})); + +/** Bypass SSRF for local test endpoints. */ +jest.mock('~/auth', () => ({ + ...jest.requireActual('~/auth'), + createSSRFSafeUndiciConnect: jest.fn(() => undefined), + isSSRFTarget: jest.fn(() => false), + resolveHostnameSSRF: jest.fn(async () => false), + isOAuthUrlAllowed: jest.fn(() => true), +})); + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() as net.AddressInfo; + srv.close((err) => (err ? reject(err) : resolve(addr.port))); + }); + }); +} + +function trackSockets(httpServer: http.Server): () => Promise { + const sockets = new Set(); + httpServer.on('connection', (socket: Socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + return () => + new Promise((resolve) => { + for (const socket of sockets) { + socket.destroy(); + } + sockets.clear(); + httpServer.close(() => resolve()); + }); +} + +/** + * Tiny /token endpoint that records every request body and responds with a + * valid OAuth token payload. Sufficient to assert what LibreChat actually + * sends to the authorization server during a refresh exchange. + */ +async function startRecordingTokenServer(): Promise<{ + url: string; + bodies: URLSearchParams[]; + close: () => Promise; +}> { + const bodies: URLSearchParams[] = []; + const port = await getFreePort(); + const server = http.createServer((req, res) => { + if (req.method !== 'POST') { + res.writeHead(405); + res.end(); + return; + } + let raw = ''; + req.on('data', (chunk) => (raw += chunk)); + req.on('end', () => { + bodies.push(new URLSearchParams(raw)); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + }), + ); + }); + }); + const close = trackSockets(server); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + return { url: `http://127.0.0.1:${port}/`, bodies, close }; +} + +describe('MCP OAuth audience parameter', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('pre-configured authorize URL', () => { + it('appends audience= query parameter when configured', async () => { + const { authorizationUrl } = await MCPOAuthHandler.initiateOAuthFlow( + 'test-server', + 'https://example.test/mcp', + 'user-1', + {}, + { + authorization_url: 'https://auth.example.test/authorize', + token_url: 'https://auth.example.test/token', + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uri: 'https://example.test/api/mcp/test-server/oauth/callback', + scope: 'read execute', + audience: 'https://example.test/mcp', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + ); + + const url = new URL(authorizationUrl); + expect(url.searchParams.get('audience')).toBe('https://example.test/mcp'); + }); + + it('omits audience when not configured', async () => { + const { authorizationUrl } = await MCPOAuthHandler.initiateOAuthFlow( + 'test-server', + 'https://example.test/mcp', + 'user-1', + {}, + { + authorization_url: 'https://auth.example.test/authorize', + token_url: 'https://auth.example.test/token', + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uri: 'https://example.test/api/mcp/test-server/oauth/callback', + scope: 'read', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + ); + + const url = new URL(authorizationUrl); + expect(url.searchParams.has('audience')).toBe(false); + }); + }); + + describe('refresh_token grant body', () => { + let recorder: Awaited>; + + beforeEach(async () => { + recorder = await startRecordingTokenServer(); + }); + + afterEach(async () => { + await recorder.close(); + }); + + it('appends audience to refresh body when configured', async () => { + await MCPOAuthHandler.refreshOAuthTokens( + 'refresh-token-value', + { + serverName: 'test-server', + serverUrl: recorder.url, + clientInfo: { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['http://localhost/callback'], + }, + }, + {}, + { + token_url: `${recorder.url}token`, + client_id: 'test-client', + client_secret: 'test-secret', + audience: 'https://example.test/mcp', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + ); + + expect(recorder.bodies.length).toBeGreaterThan(0); + const body = recorder.bodies[recorder.bodies.length - 1]; + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('audience')).toBe('https://example.test/mcp'); + }); + + it('omits audience from refresh body when forward_audience_on_refresh=false (Cognito opt-out)', async () => { + await MCPOAuthHandler.refreshOAuthTokens( + 'refresh-token-value', + { + serverName: 'test-server', + serverUrl: recorder.url, + clientInfo: { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['http://localhost/callback'], + }, + }, + {}, + { + token_url: `${recorder.url}token`, + client_id: 'test-client', + client_secret: 'test-secret', + audience: 'https://example.test/mcp', + forward_audience_on_refresh: false, + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + ); + + expect(recorder.bodies.length).toBeGreaterThan(0); + const body = recorder.bodies[recorder.bodies.length - 1]; + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.has('audience')).toBe(false); + }); + + it('omits audience from refresh body when not configured', async () => { + await MCPOAuthHandler.refreshOAuthTokens( + 'refresh-token-value', + { + serverName: 'test-server', + serverUrl: recorder.url, + clientInfo: { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['http://localhost/callback'], + }, + }, + {}, + { + token_url: `${recorder.url}token`, + client_id: 'test-client', + client_secret: 'test-secret', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + ); + + expect(recorder.bodies.length).toBeGreaterThan(0); + const body = recorder.bodies[recorder.bodies.length - 1]; + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.has('audience')).toBe(false); + }); + }); +}); diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts index 31649c783a..be503ae97e 100644 --- a/packages/api/src/mcp/oauth/handler.ts +++ b/packages/api/src/mcp/oauth/handler.ts @@ -577,6 +577,16 @@ export class MCPOAuthHandler { authorizationUrl.searchParams.set('state', state); logger.debug(`[MCPOAuth] Added state parameter to authorization URL`); + /** + * Auth0/Cognito-style `audience` parameter. Forwarded as-is; the provider + * decides whether it accepts RFC 8707 `resource`, the legacy `audience`, + * or both. See `OAuthOptionsSchema.audience`. + */ + if (config?.audience) { + authorizationUrl.searchParams.set('audience', config.audience); + logger.debug(`[MCPOAuth] Added audience parameter (pre-configured): ${config.audience}`); + } + const flowMetadata: MCPOAuthFlowMetadata = { serverName, userId, @@ -728,6 +738,7 @@ export class MCPOAuthHandler { `[MCPOAuth] Added resource parameter to authorization URL: ${canonicalResource}`, ); } else { + // resource omitted on purpose — see comment below in the `else` branch. /** * Reachable only when `discoverOAuthProtectedResourceMetadata` did not return a * document (404 / network error / server does not implement RFC 9728). If a PRM @@ -740,6 +751,16 @@ export class MCPOAuthHandler { 'This can cause issues with some Authorization Servers that expect a "resource" parameter.', ); } + + /** + * Auth0/Cognito-style `audience` parameter. Independent of `resource` (RFC 8707): + * some authorization servers ignore `resource` and only mint API-scoped tokens + * when `audience` is supplied. See `OAuthOptionsSchema.audience`. + */ + if (config?.audience) { + authorizationUrl.searchParams.set('audience', config.audience); + logger.debug(`[MCPOAuth] Added audience parameter (discovered flow): ${config.audience}`); + } } catch (error) { logger.error(`[MCPOAuth] startAuthorization failed:`, error); throw error; @@ -1231,6 +1252,25 @@ export class MCPOAuthHandler { body.append('scope', metadata.clientInfo.scope); } + /** + * Forward Auth0-style `audience` on refresh by default — Auth0 strips the + * API audience from refreshed access tokens unless it is re-supplied on + * every refresh, otherwise the next MCP call 401s once the initial token + * expires. + * + * Operators with strict OAuth 2.0 token endpoints (Cognito and similar) + * that documents refresh requests as `grant_type` + `client_id` + + * `refresh_token` only, and that maintain the original `aud` claim on + * refresh, can opt out by setting `forward_audience_on_refresh: false`. + * See `OAuthOptionsSchema.forward_audience_on_refresh`. + */ + if (config?.audience && config?.forward_audience_on_refresh !== false) { + body.append('audience', config.audience); + logger.debug( + `[MCPOAuth] Added audience parameter to refresh request: ${config.audience}`, + ); + } + const headers: HeadersInit = { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', diff --git a/packages/data-provider/specs/mcp.spec.ts b/packages/data-provider/specs/mcp.spec.ts index aba23db52a..82ae562580 100644 --- a/packages/data-provider/specs/mcp.spec.ts +++ b/packages/data-provider/specs/mcp.spec.ts @@ -272,5 +272,93 @@ describe('MCP schemas', () => { expect(result.success).toBe(true); }); + + it('should accept audience parameter (Auth0/Cognito-style)', () => { + const result = MCPOptionsSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + oauth: { + audience: 'https://api.example.com', + }, + }); + + expect(result.success).toBe(true); + if (result.success && result.data.oauth) { + expect(result.data.oauth.audience).toBe('https://api.example.com'); + } + }); + + it('should accept audience alongside scope and other OAuth fields', () => { + const result = MCPOptionsSchema.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', + scope: 'read execute', + audience: 'https://api.example.com', + }, + }); + + expect(result.success).toBe(true); + }); + + it('should treat audience as optional (omitting it is fine)', () => { + const result = MCPOptionsSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + oauth: { + scope: 'read', + }, + }); + + expect(result.success).toBe(true); + if (result.success && result.data.oauth) { + expect(result.data.oauth.audience).toBeUndefined(); + } + }); + + it('should reject empty-string audience', () => { + const result = MCPOptionsSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + oauth: { + audience: '', + }, + }); + + expect(result.success).toBe(false); + }); + + it('should accept forward_audience_on_refresh = false (Cognito opt-out)', () => { + const result = MCPOptionsSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + oauth: { + audience: 'https://api.example.com', + forward_audience_on_refresh: false, + }, + }); + + expect(result.success).toBe(true); + if (result.success && result.data.oauth) { + expect(result.data.oauth.forward_audience_on_refresh).toBe(false); + } + }); + + it('should treat forward_audience_on_refresh as optional', () => { + const result = MCPOptionsSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + oauth: { + audience: 'https://api.example.com', + }, + }); + + expect(result.success).toBe(true); + if (result.success && result.data.oauth) { + expect(result.data.oauth.forward_audience_on_refresh).toBeUndefined(); + } + }); }); }); diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 69efb4693f..158a62ce9f 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -28,6 +28,42 @@ const OAuthOptionsSchema = z 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) */