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.
This commit is contained in:
Tim Freudenthal 2026-05-29 18:30:21 +00:00
parent 6d9c01927d
commit 1bf8b2a902
3 changed files with 95 additions and 0 deletions

View file

@ -577,6 +577,18 @@ 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 +740,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 +753,18 @@ 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;
@ -1170,6 +1195,19 @@ export class MCPOAuthHandler {
body.append('scope', metadata.clientInfo.scope);
}
/**
* Forward Auth0/Cognito-style `audience` on refresh as well providers that
* required it on `/authorize` typically require it on `/token` too, otherwise
* the refresh exchange returns a token without the API audience and the next
* MCP call 401s. See `OAuthOptionsSchema.audience`.
*/
if (config?.audience) {
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,50 @@ 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();
}
});
});
});

View file

@ -28,6 +28,18 @@ 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 authorization (and refresh) 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` and `/token`
* (including refresh) no canonicalization, since the audience identifier is
* provider-defined and may differ from the MCP server URL.
*/
audience: z.string().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) */