mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🪪 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:
parent
069d867092
commit
c6a6f2e3ae
4 changed files with 413 additions and 0 deletions
249
packages/api/src/mcp/__tests__/MCPOAuthAudience.test.ts
Normal file
249
packages/api/src/mcp/__tests__/MCPOAuthAudience.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue