🤝 fix: Respect Server Token Endpoint Auth Method Preference in MCP OAuth (#12052)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* fix(mcp): respect server's token endpoint auth method preference order

* fix(mcp): update token endpoint auth method to client_secret_basic

* fix(mcp): correct auth method to client_secret_basic in OAuth handler

* test(mcp): add tests for OAuth client registration method selection based on server preferences

* refactor(mcp): extract and implement token endpoint auth methods into separate utility functions

- Moved token endpoint authentication method logic from the MCPOAuthHandler to new utility functions in methods.ts for better organization and reusability.
- Added tests for the new methods to ensure correct behavior in selecting and resolving authentication methods based on server preferences and token exchange methods.
- Updated MCPOAuthHandler to utilize the new utility functions, improving code clarity and maintainability.

* chore(mcp): remove redundant comments in OAuth handler

- Cleaned up the MCPOAuthHandler by removing unnecessary comments related to authentication methods, improving code readability and maintainability.

* refactor(mcp): update supported auth methods to use ReadonlySet for better performance

- Changed the SUPPORTED_AUTH_METHODS from an array to a ReadonlySet for improved lookup efficiency.
- Enhanced the logic in selectRegistrationAuthMethod to prioritize credential-based methods and handle cases where the server advertises 'none' correctly, ensuring compliance with RFC 7591.

* test(mcp): add tests for selectRegistrationAuthMethod to handle 'none' and empty array cases

- Introduced new test cases to ensure selectRegistrationAuthMethod correctly prioritizes credential-based methods over 'none' when listed first or before other methods.
- Added a test to verify that an empty token_endpoint_auth_methods_supported returns undefined, adhering to RFC 8414.

* refactor(mcp): streamline authentication method handling in OAuth handler

- Simplified the logic for determining the authentication method by consolidating checks into a single function call.
- Removed redundant checks for supported auth methods, enhancing code clarity and maintainability.
- Updated the request header and body handling based on the resolved authentication method.

* fix(mcp): ensure compliance with RFC 6749 by removing credentials from body when using client_secret_basic

- Updated the MCPOAuthHandler to delete client_id and client_secret from body parameters when using the client_secret_basic authentication method, ensuring adherence to RFC 6749 §2.3.1.

* test(mcp): add tests for OAuth flow handling of client_secret_basic and client_secret_post methods

- Introduced new test cases to verify that the MCPOAuthHandler correctly removes client_id and client_secret from the request body when using client_secret_basic.
- Added tests to ensure proper handling of client_secret_post and none authentication methods, confirming that the correct parameters are included or excluded based on the specified method.
- Enhanced the test suite for completeOAuthFlow to cover various scenarios, ensuring compliance with OAuth 2.0 specifications.

* test(mcp): enhance tests for selectRegistrationAuthMethod and resolveTokenEndpointAuthMethod

- Added new test cases to verify the selection of the first supported credential method from a mixed list in selectRegistrationAuthMethod.
- Included tests to ensure resolveTokenEndpointAuthMethod correctly ignores unsupported preferred methods and handles empty tokenAuthMethods, returning undefined as expected.
- Improved test coverage for various scenarios in the OAuth flow, ensuring compliance with relevant specifications.

---------

Co-authored-by: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
This commit is contained in:
Danny Avila 2026-03-03 22:44:13 -05:00 committed by GitHub
parent 4af23474e2
commit 6ebee069c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 644 additions and 86 deletions

View file

@ -18,6 +18,12 @@ import type {
MCPOAuthTokens,
OAuthMetadata,
} from './types';
import {
resolveTokenEndpointAuthMethod,
getForcedTokenEndpointAuthMethod,
selectRegistrationAuthMethod,
inferClientAuthMethod,
} from './methods';
import { sanitizeUrlForLogging } from '~/mcp/utils';
/** Type for the OAuth metadata from the SDK */
@ -27,39 +33,6 @@ export class MCPOAuthHandler {
private static readonly FLOW_TYPE = 'mcp_oauth';
private static readonly FLOW_TTL = 10 * 60 * 1000; // 10 minutes
private static getForcedTokenEndpointAuthMethod(
tokenExchangeMethod?: TokenExchangeMethodEnum,
): 'client_secret_basic' | 'client_secret_post' | undefined {
if (tokenExchangeMethod === TokenExchangeMethodEnum.DefaultPost) {
return 'client_secret_post';
}
if (tokenExchangeMethod === TokenExchangeMethodEnum.BasicAuthHeader) {
return 'client_secret_basic';
}
return undefined;
}
private static resolveTokenEndpointAuthMethod(options: {
tokenExchangeMethod?: TokenExchangeMethodEnum;
tokenAuthMethods: string[];
preferredMethod?: string;
}): 'client_secret_basic' | 'client_secret_post' | undefined {
const forcedMethod = this.getForcedTokenEndpointAuthMethod(options.tokenExchangeMethod);
const preferredMethod = forcedMethod ?? options.preferredMethod;
if (preferredMethod === 'client_secret_basic' || preferredMethod === 'client_secret_post') {
return preferredMethod;
}
if (options.tokenAuthMethods.includes('client_secret_basic')) {
return 'client_secret_basic';
}
if (options.tokenAuthMethods.includes('client_secret_post')) {
return 'client_secret_post';
}
return undefined;
}
/**
* Creates a fetch function with custom headers injected
*/
@ -95,19 +68,14 @@ export class MCPOAuthHandler {
newHeaders.set('Content-Type', 'application/x-www-form-urlencoded');
if (clientInfo?.client_id) {
let authMethod = clientInfo.token_endpoint_auth_method;
if (!authMethod) {
if (newHeaders.has('Authorization')) {
authMethod = 'client_secret_basic';
} else if (params.has('client_id') || params.has('client_secret')) {
authMethod = 'client_secret_post';
} else if (clientInfo.client_secret) {
authMethod = 'client_secret_post';
} else {
authMethod = 'none';
}
}
const authMethod =
clientInfo.token_endpoint_auth_method ??
inferClientAuthMethod(
newHeaders.has('Authorization'),
params.has('client_id'),
params.has('client_secret'),
!!clientInfo.client_secret,
);
if (!clientInfo.client_secret || authMethod === 'none') {
newHeaders.delete('Authorization');
@ -123,6 +91,9 @@ export class MCPOAuthHandler {
params.set('client_secret', clientInfo.client_secret);
}
} else if (authMethod === 'client_secret_basic') {
/** RFC 6749 §2.3.1: credentials MUST NOT appear in both the header and the body. The SDK defaults to body params, so remove them before setting the Basic header. */
params.delete('client_id');
params.delete('client_secret');
if (!newHeaders.has('Authorization')) {
const clientAuth = Buffer.from(
`${clientInfo.client_id}:${clientInfo.client_secret}`,
@ -300,22 +271,12 @@ export class MCPOAuthHandler {
clientMetadata.response_types = metadata.response_types_supported || ['code'];
const forcedAuthMethod = this.getForcedTokenEndpointAuthMethod(tokenExchangeMethod);
if (forcedAuthMethod) {
clientMetadata.token_endpoint_auth_method = forcedAuthMethod;
} else if (metadata.token_endpoint_auth_methods_supported) {
// Prefer client_secret_basic if supported, otherwise use the first supported method
if (metadata.token_endpoint_auth_methods_supported.includes('client_secret_basic')) {
clientMetadata.token_endpoint_auth_method = 'client_secret_basic';
} else if (metadata.token_endpoint_auth_methods_supported.includes('client_secret_post')) {
clientMetadata.token_endpoint_auth_method = 'client_secret_post';
} else if (metadata.token_endpoint_auth_methods_supported.includes('none')) {
clientMetadata.token_endpoint_auth_method = 'none';
} else {
clientMetadata.token_endpoint_auth_method =
metadata.token_endpoint_auth_methods_supported[0];
}
const selectedAuthMethod = selectRegistrationAuthMethod(
metadata.token_endpoint_auth_methods_supported,
tokenExchangeMethod,
);
if (selectedAuthMethod) {
clientMetadata.token_endpoint_auth_method = selectedAuthMethod;
}
const availableScopes = resourceMetadata?.scopes_supported || metadata.scopes_supported;
@ -334,6 +295,7 @@ export class MCPOAuthHandler {
fetchFn: this.createOAuthFetch(oauthHeaders),
});
const forcedAuthMethod = getForcedTokenEndpointAuthMethod(tokenExchangeMethod);
if (forcedAuthMethod) {
clientInfo.token_endpoint_auth_method = forcedAuthMethod;
} else if (!clientInfo.token_endpoint_auth_method) {
@ -401,8 +363,7 @@ export class MCPOAuthHandler {
// When token_exchange_method is undefined or not DefaultPost, default to using
// client_secret_basic (Basic Auth header) for token endpoint authentication.
tokenEndpointAuthMethod =
this.getForcedTokenEndpointAuthMethod(config.token_exchange_method) ??
'client_secret_basic';
getForcedTokenEndpointAuthMethod(config.token_exchange_method) ?? 'client_secret_basic';
}
let defaultTokenAuthMethods: string[];
@ -815,26 +776,23 @@ export class MCPOAuthHandler {
if (metadata.clientInfo.client_secret) {
/** Default to client_secret_basic if no methods specified (per RFC 8414) */
const tokenAuthMethods = authMethods ?? ['client_secret_basic'];
const authMethod = this.resolveTokenEndpointAuthMethod({
const authMethod = resolveTokenEndpointAuthMethod({
tokenExchangeMethod: config?.token_exchange_method,
tokenAuthMethods,
preferredMethod: metadata.clientInfo.token_endpoint_auth_method,
});
if (authMethod === 'client_secret_basic') {
/** Use Basic auth */
logger.debug('[MCPOAuth] Using client_secret_basic authentication method');
const clientAuth = Buffer.from(
`${metadata.clientInfo.client_id}:${metadata.clientInfo.client_secret}`,
).toString('base64');
headers['Authorization'] = `Basic ${clientAuth}`;
} else if (authMethod === 'client_secret_post') {
/** Use client_secret_post */
logger.debug('[MCPOAuth] Using client_secret_post authentication method');
body.append('client_id', metadata.clientInfo.client_id);
body.append('client_secret', metadata.clientInfo.client_secret);
} else {
/** No recognized method, default to Basic auth per RFC */
logger.debug('[MCPOAuth] No recognized auth method, defaulting to client_secret_basic');
const clientAuth = Buffer.from(
`${metadata.clientInfo.client_id}:${metadata.clientInfo.client_secret}`,
@ -896,13 +854,12 @@ export class MCPOAuthHandler {
const tokenAuthMethods = config.token_endpoint_auth_methods_supported ?? [
'client_secret_basic',
];
const authMethod = this.resolveTokenEndpointAuthMethod({
const authMethod = resolveTokenEndpointAuthMethod({
tokenExchangeMethod: config.token_exchange_method,
tokenAuthMethods,
});
if (authMethod === 'client_secret_basic') {
/** Use Basic auth */
logger.debug(
'[MCPOAuth] Using client_secret_basic authentication method (pre-configured)',
);
@ -911,14 +868,12 @@ export class MCPOAuthHandler {
);
headers['Authorization'] = `Basic ${clientAuth}`;
} else if (authMethod === 'client_secret_post') {
/** Use client_secret_post */
logger.debug(
'[MCPOAuth] Using client_secret_post authentication method (pre-configured)',
);
body.append('client_id', config.client_id);
body.append('client_secret', config.client_secret);
} else {
/** No recognized method, default to Basic auth per RFC */
logger.debug(
'[MCPOAuth] No recognized auth method, defaulting to client_secret_basic (pre-configured)',
);
@ -1028,32 +983,23 @@ export class MCPOAuthHandler {
? new URL(metadata.revocationEndpoint)
: new URL('/revoke', metadata.serverUrl);
// detect auth method to use
const authMethods = metadata.revocationEndpointAuthMethodsSupported ?? [
'client_secret_basic', // RFC 8414 (https://datatracker.ietf.org/doc/html/rfc8414)
];
const usesBasicAuth = authMethods.includes('client_secret_basic');
const usesClientSecretPost = authMethods.includes('client_secret_post');
const authMethods = metadata.revocationEndpointAuthMethodsSupported ?? ['client_secret_basic'];
const authMethod = resolveTokenEndpointAuthMethod({ tokenAuthMethods: authMethods });
// init the request headers
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
...oauthHeaders,
};
// init the request body
const body = new URLSearchParams({ token });
body.set('token_type_hint', tokenType === 'refresh' ? 'refresh_token' : 'access_token');
// process auth method
if (usesBasicAuth) {
// encode the client id and secret and add to the headers
if (authMethod === 'client_secret_basic') {
const credentials = Buffer.from(`${metadata.clientId}:${metadata.clientSecret}`).toString(
'base64',
);
headers['Authorization'] = `Basic ${credentials}`;
} else if (usesClientSecretPost) {
// add the client id and secret to the body
} else if (authMethod === 'client_secret_post') {
body.set('client_secret', metadata.clientSecret);
body.set('client_id', metadata.clientId);
}

View file

@ -2,3 +2,4 @@ export * from './types';
export * from './handler';
export * from './tokens';
export * from './detectOAuth';
export * from './methods';

View file

@ -0,0 +1,111 @@
import { TokenExchangeMethodEnum } from 'librechat-data-provider';
type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none';
/** Unordered roster of auth methods we can handle — order is irrelevant; server's list controls priority */
const SUPPORTED_AUTH_METHODS: ReadonlySet<string> = new Set<ClientAuthMethod>([
'client_secret_basic',
'client_secret_post',
'none',
]);
/** Maps a user-facing `TokenExchangeMethodEnum` to an OAuth auth method string. */
export function getForcedTokenEndpointAuthMethod(
tokenExchangeMethod?: TokenExchangeMethodEnum,
): 'client_secret_basic' | 'client_secret_post' | undefined {
if (tokenExchangeMethod === TokenExchangeMethodEnum.DefaultPost) {
return 'client_secret_post';
}
if (tokenExchangeMethod === TokenExchangeMethodEnum.BasicAuthHeader) {
return 'client_secret_basic';
}
return undefined;
}
/**
* Selects the auth method to request during dynamic client registration.
*
* Priority:
* 1. Forced override from `tokenExchangeMethod` config
* 2. First credential-based method from server's advertised list (skips `none` per RFC 7591
* `none` declares a public client, which is incorrect for DCR with a generated secret)
* 3. `none` if the server only advertises `none`
* 4. Server's first listed method (unsupported exotic method best-effort)
* 5. Falls through to `undefined` (caller keeps its default)
*/
export function selectRegistrationAuthMethod(
serverAdvertised: string[] | undefined,
tokenExchangeMethod?: TokenExchangeMethodEnum,
): string | undefined {
const forced = getForcedTokenEndpointAuthMethod(tokenExchangeMethod);
if (forced) {
return forced;
}
if (!serverAdvertised?.length) {
return undefined;
}
const credentialPreferred = serverAdvertised.find(
(m) => SUPPORTED_AUTH_METHODS.has(m) && m !== 'none',
);
if (credentialPreferred) {
return credentialPreferred;
}
const serverPreferred = serverAdvertised.find((m) => SUPPORTED_AUTH_METHODS.has(m));
return serverPreferred ?? serverAdvertised[0];
}
/**
* Resolves the auth method for token endpoint requests (refresh, pre-configured flows).
*
* Priority:
* 1. Forced override from `tokenExchangeMethod` config
* 2. Preferred method from client registration response (`clientInfo.token_endpoint_auth_method`)
* 3. First match from server's advertised methods
*/
export function resolveTokenEndpointAuthMethod(options: {
tokenExchangeMethod?: TokenExchangeMethodEnum;
tokenAuthMethods: string[];
preferredMethod?: string;
}): 'client_secret_basic' | 'client_secret_post' | undefined {
const forced = getForcedTokenEndpointAuthMethod(options.tokenExchangeMethod);
const preferredMethod = forced ?? options.preferredMethod;
if (preferredMethod === 'client_secret_basic' || preferredMethod === 'client_secret_post') {
return preferredMethod;
}
if (options.tokenAuthMethods.includes('client_secret_basic')) {
return 'client_secret_basic';
}
if (options.tokenAuthMethods.includes('client_secret_post')) {
return 'client_secret_post';
}
return undefined;
}
/**
* Infers the client auth method from request state when `clientInfo.token_endpoint_auth_method`
* is not set. Used inside the fetch wrapper to determine how credentials were applied by the SDK.
*
* Per RFC 8414 Section 2, defaults to `client_secret_basic` for confidential clients.
*/
export function inferClientAuthMethod(
hasAuthorizationHeader: boolean,
hasBodyClientId: boolean,
hasBodyClientSecret: boolean,
hasClientSecret: boolean,
): ClientAuthMethod {
if (hasAuthorizationHeader) {
return 'client_secret_basic';
}
if (hasBodyClientId || hasBodyClientSecret) {
return 'client_secret_post';
}
if (hasClientSecret) {
return 'client_secret_basic';
}
return 'none';
}