🪪 feat: MCP OAuth - Support audience parameter for Auth0/Cognito-style providers (#13402)

* 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 7b3dfa6cd7.

* 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 <tim@allesknut.de>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Freudator86 2026-05-30 15:59:39 +02:00 committed by GitHub
parent 069d867092
commit c6a6f2e3ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 413 additions and 0 deletions

View file

@ -0,0 +1,249 @@
/**
* Tests that the optional `audience` field on `mcpServers.<name>.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<number> {
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<void> {
const sockets = new Set<Socket>();
httpServer.on('connection', (socket: Socket) => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
return () =>
new Promise<void>((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<void>;
}> {
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<void>((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<ReturnType<typeof startRecordingTokenServer>>;
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);
});
});
});

View file

@ -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',

View file

@ -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();
}
});
});
});

View file

@ -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) */