mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🔐 fix: Respect Server's Token Endpoint Auth Methods for MCP OAuth Refresh (#9717)
* fix: respect server's token endpoint auth methods for MCP OAuth refresh Previously, LibreChat always used Basic Auth when refreshing OAuth tokens if a client_secret was present. This caused issues with servers (like FastMCP) that only support client_secret_post. Now properly checks and respects the server's advertised token_endpoint_auth_methods_supported. Fixes token refresh failures with error: "refresh_token.client_id: Field required" * chore: remove MCP OAuth URL Logging
This commit is contained in:
parent
e5d2a932bc
commit
344e7c44b5
3 changed files with 503 additions and 13 deletions
|
@ -44,7 +44,7 @@ async function reinitMCPServer({
|
||||||
const oauthStart =
|
const oauthStart =
|
||||||
_oauthStart ??
|
_oauthStart ??
|
||||||
(async (authURL) => {
|
(async (authURL) => {
|
||||||
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
|
logger.info(`[MCP Reinitialize] OAuth URL received for ${serverName}`);
|
||||||
oauthUrl = authURL;
|
oauthUrl = authURL;
|
||||||
oauthRequired = true;
|
oauthRequired = true;
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { MCPOptions } from 'librechat-data-provider';
|
import type { MCPOptions } from 'librechat-data-provider';
|
||||||
|
import type { AuthorizationServerMetadata } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||||
import { MCPOAuthHandler } from '~/mcp/oauth';
|
import { MCPOAuthHandler } from '~/mcp/oauth';
|
||||||
|
|
||||||
jest.mock('@librechat/data-schemas', () => ({
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
@ -12,11 +13,19 @@ jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
|
||||||
jest.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
|
jest.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
|
||||||
startAuthorization: jest.fn(),
|
startAuthorization: jest.fn(),
|
||||||
|
discoverAuthorizationServerMetadata: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { startAuthorization } from '@modelcontextprotocol/sdk/client/auth.js';
|
import {
|
||||||
|
startAuthorization,
|
||||||
|
discoverAuthorizationServerMetadata,
|
||||||
|
} from '@modelcontextprotocol/sdk/client/auth.js';
|
||||||
|
|
||||||
const mockStartAuthorization = startAuthorization as jest.MockedFunction<typeof startAuthorization>;
|
const mockStartAuthorization = startAuthorization as jest.MockedFunction<typeof startAuthorization>;
|
||||||
|
const mockDiscoverAuthorizationServerMetadata =
|
||||||
|
discoverAuthorizationServerMetadata as jest.MockedFunction<
|
||||||
|
typeof discoverAuthorizationServerMetadata
|
||||||
|
>;
|
||||||
|
|
||||||
describe('MCPOAuthHandler - Configurable OAuth Metadata', () => {
|
describe('MCPOAuthHandler - Configurable OAuth Metadata', () => {
|
||||||
const mockServerName = 'test-server';
|
const mockServerName = 'test-server';
|
||||||
|
@ -188,6 +197,432 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('refreshOAuthTokens', () => {
|
||||||
|
const mockRefreshToken = 'refresh-token-12345';
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const mockFetch = jest.fn() as unknown as jest.MockedFunction<typeof fetch>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFetch.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with stored metadata', () => {
|
||||||
|
it('should use client_secret_post when server only supports that method', async () => {
|
||||||
|
const metadata = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
userId: 'user-123',
|
||||||
|
serverUrl: 'https://auth.example.com',
|
||||||
|
state: 'state-123',
|
||||||
|
clientInfo: {
|
||||||
|
client_id: 'test-client-id',
|
||||||
|
client_secret: 'test-client-secret',
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
scope: 'read write',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock OAuth metadata discovery
|
||||||
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce({
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
|
||||||
|
token_endpoint: 'https://auth.example.com/oauth/token',
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_post'],
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'],
|
||||||
|
} as AuthorizationServerMetadata);
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata);
|
||||||
|
|
||||||
|
// Verify the call was made without Authorization header
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://auth.example.com/oauth/token',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: expect.not.objectContaining({
|
||||||
|
Authorization: expect.any(String),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the body contains client_id and client_secret
|
||||||
|
const callArgs = mockFetch.mock.calls[0];
|
||||||
|
const body = callArgs[1]?.body as URLSearchParams;
|
||||||
|
expect(body.toString()).toContain('client_id=test-client-id');
|
||||||
|
expect(body.toString()).toContain('client_secret=test-client-secret');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
obtained_at: expect.any(Number),
|
||||||
|
expires_at: expect.any(Number),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use client_secret_basic when server only supports that method', async () => {
|
||||||
|
const metadata = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
userId: 'user-123',
|
||||||
|
serverUrl: 'https://auth.example.com',
|
||||||
|
state: 'state-123',
|
||||||
|
clientInfo: {
|
||||||
|
client_id: 'test-client-id',
|
||||||
|
client_secret: 'test-client-secret',
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
scope: 'read write',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock OAuth metadata discovery
|
||||||
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce({
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
|
||||||
|
token_endpoint: 'https://auth.example.com/oauth/token',
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'],
|
||||||
|
} as AuthorizationServerMetadata);
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata);
|
||||||
|
|
||||||
|
const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`;
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://auth.example.com/oauth/token',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expectedAuth,
|
||||||
|
}),
|
||||||
|
body: expect.not.stringContaining('client_id='),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer client_secret_basic when both methods are supported', async () => {
|
||||||
|
const metadata = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
userId: 'user-123',
|
||||||
|
serverUrl: 'https://auth.example.com',
|
||||||
|
state: 'state-123',
|
||||||
|
clientInfo: {
|
||||||
|
client_id: 'test-client-id',
|
||||||
|
client_secret: 'test-client-secret',
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock OAuth metadata discovery
|
||||||
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce({
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
|
||||||
|
token_endpoint: 'https://auth.example.com/oauth/token',
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'],
|
||||||
|
} as AuthorizationServerMetadata);
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata);
|
||||||
|
|
||||||
|
const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`;
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://auth.example.com/oauth/token',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expectedAuth,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to client_secret_basic when no methods are advertised', async () => {
|
||||||
|
const metadata = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
userId: 'user-123',
|
||||||
|
serverUrl: 'https://auth.example.com',
|
||||||
|
state: 'state-123',
|
||||||
|
clientInfo: {
|
||||||
|
client_id: 'test-client-id',
|
||||||
|
client_secret: 'test-client-secret',
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock OAuth metadata discovery with no auth methods specified
|
||||||
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce({
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
|
||||||
|
token_endpoint: 'https://auth.example.com/oauth/token',
|
||||||
|
// No token_endpoint_auth_methods_supported field
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'],
|
||||||
|
} as AuthorizationServerMetadata);
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata);
|
||||||
|
|
||||||
|
const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`;
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://auth.example.com/oauth/token',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expectedAuth,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include client_id in body for public clients (no secret)', async () => {
|
||||||
|
const metadata = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
userId: 'user-123',
|
||||||
|
serverUrl: 'https://auth.example.com',
|
||||||
|
state: 'state-123',
|
||||||
|
clientInfo: {
|
||||||
|
client_id: 'test-client-id',
|
||||||
|
// No client_secret - public client
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock OAuth metadata discovery
|
||||||
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce({
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
|
||||||
|
token_endpoint: 'https://auth.example.com/oauth/token',
|
||||||
|
token_endpoint_auth_methods_supported: ['none'],
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'],
|
||||||
|
} as AuthorizationServerMetadata);
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata);
|
||||||
|
|
||||||
|
// Verify the call was made without Authorization header
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://auth.example.com/oauth/token',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: expect.not.objectContaining({
|
||||||
|
Authorization: expect.any(String),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the body contains client_id (public client)
|
||||||
|
const callArgs = mockFetch.mock.calls[0];
|
||||||
|
const body = callArgs[1]?.body as URLSearchParams;
|
||||||
|
expect(body.toString()).toContain('client_id=test-client-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with pre-configured OAuth settings', () => {
|
||||||
|
it('should use client_secret_post when configured to only support that method', async () => {
|
||||||
|
const config = {
|
||||||
|
token_url: 'https://auth.example.com/oauth/token',
|
||||||
|
client_id: 'test-client-id',
|
||||||
|
client_secret: 'test-client-secret',
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_post'],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await MCPOAuthHandler.refreshOAuthTokens(
|
||||||
|
mockRefreshToken,
|
||||||
|
{ serverName: 'test-server' },
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the call was made without Authorization header
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
new URL('https://auth.example.com/oauth/token'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: expect.not.objectContaining({
|
||||||
|
Authorization: expect.any(String),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the body contains client_id and client_secret
|
||||||
|
const callArgs = mockFetch.mock.calls[0];
|
||||||
|
const body = callArgs[1]?.body as URLSearchParams;
|
||||||
|
expect(body.toString()).toContain('client_id=test-client-id');
|
||||||
|
expect(body.toString()).toContain('client_secret=test-client-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use client_secret_basic when configured to support that method', async () => {
|
||||||
|
const config = {
|
||||||
|
token_url: 'https://auth.example.com/oauth/token',
|
||||||
|
client_id: 'test-client-id',
|
||||||
|
client_secret: 'test-client-secret',
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await MCPOAuthHandler.refreshOAuthTokens(
|
||||||
|
mockRefreshToken,
|
||||||
|
{ serverName: 'test-server' },
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`;
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
new URL('https://auth.example.com/oauth/token'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expectedAuth,
|
||||||
|
}),
|
||||||
|
body: expect.not.stringContaining('client_id='),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to client_secret_basic when no auth methods configured', async () => {
|
||||||
|
const config = {
|
||||||
|
token_url: 'https://auth.example.com/oauth/token',
|
||||||
|
client_id: 'test-client-id',
|
||||||
|
client_secret: 'test-client-secret',
|
||||||
|
// No token_endpoint_auth_methods_supported field
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await MCPOAuthHandler.refreshOAuthTokens(
|
||||||
|
mockRefreshToken,
|
||||||
|
{ serverName: 'test-server' },
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`;
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
new URL('https://auth.example.com/oauth/token'),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expectedAuth,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when refresh fails', async () => {
|
||||||
|
const metadata = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
userId: 'user-123',
|
||||||
|
serverUrl: 'https://auth.example.com',
|
||||||
|
state: 'state-123',
|
||||||
|
clientInfo: {
|
||||||
|
client_id: 'test-client-id',
|
||||||
|
client_secret: 'test-client-secret',
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock OAuth metadata discovery
|
||||||
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce({
|
||||||
|
token_endpoint: 'https://auth.example.com/oauth/token',
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_post'],
|
||||||
|
} as AuthorizationServerMetadata);
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
statusText: 'Bad Request',
|
||||||
|
text: async () =>
|
||||||
|
'{"error":"invalid_request","error_description":"refresh_token.client_id: Field required"}',
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata)).rejects.toThrow(
|
||||||
|
'Token refresh failed: 400 Bad Request - {"error":"invalid_request","error_description":"refresh_token.client_id: Field required"}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('revokeOAuthToken', () => {
|
describe('revokeOAuthToken', () => {
|
||||||
const mockServerName = 'test-server';
|
const mockServerName = 'test-server';
|
||||||
const mockToken = 'test-token-12345';
|
const mockToken = 'test-token-12345';
|
||||||
|
|
|
@ -501,6 +501,7 @@ export class MCPOAuthHandler {
|
||||||
|
|
||||||
/** Use the stored client information and metadata to determine the token URL */
|
/** Use the stored client information and metadata to determine the token URL */
|
||||||
let tokenUrl: string;
|
let tokenUrl: string;
|
||||||
|
let authMethods: string[] | undefined;
|
||||||
if (config?.token_url) {
|
if (config?.token_url) {
|
||||||
tokenUrl = config.token_url;
|
tokenUrl = config.token_url;
|
||||||
} else if (!metadata.serverUrl) {
|
} else if (!metadata.serverUrl) {
|
||||||
|
@ -515,6 +516,7 @@ export class MCPOAuthHandler {
|
||||||
throw new Error('No token endpoint found in OAuth metadata');
|
throw new Error('No token endpoint found in OAuth metadata');
|
||||||
}
|
}
|
||||||
tokenUrl = oauthMetadata.token_endpoint;
|
tokenUrl = oauthMetadata.token_endpoint;
|
||||||
|
authMethods = oauthMetadata.token_endpoint_auth_methods_supported;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
|
@ -532,14 +534,36 @@ export class MCPOAuthHandler {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Use client_secret for authentication if available */
|
/** Handle authentication based on server's advertised methods */
|
||||||
if (metadata.clientInfo.client_secret) {
|
if (metadata.clientInfo.client_secret) {
|
||||||
const clientAuth = Buffer.from(
|
/** Default to client_secret_basic if no methods specified (per RFC 8414) */
|
||||||
`${metadata.clientInfo.client_id}:${metadata.clientInfo.client_secret}`,
|
const tokenAuthMethods = authMethods ?? ['client_secret_basic'];
|
||||||
).toString('base64');
|
const usesBasicAuth = tokenAuthMethods.includes('client_secret_basic');
|
||||||
headers['Authorization'] = `Basic ${clientAuth}`;
|
const usesClientSecretPost = tokenAuthMethods.includes('client_secret_post');
|
||||||
|
|
||||||
|
if (usesBasicAuth) {
|
||||||
|
/** 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 (usesClientSecretPost) {
|
||||||
|
/** 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}`,
|
||||||
|
).toString('base64');
|
||||||
|
headers['Authorization'] = `Basic ${clientAuth}`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
/** For public clients, client_id must be in the body */
|
/** For public clients, client_id must be in the body */
|
||||||
|
logger.debug('[MCPOAuth] Using public client authentication (no secret)');
|
||||||
body.append('client_id', metadata.clientInfo.client_id);
|
body.append('client_id', metadata.clientInfo.client_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -575,9 +599,6 @@ export class MCPOAuthHandler {
|
||||||
logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for token refresh`);
|
logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for token refresh`);
|
||||||
|
|
||||||
const tokenUrl = new URL(config.token_url);
|
const tokenUrl = new URL(config.token_url);
|
||||||
const clientAuth = config.client_secret
|
|
||||||
? Buffer.from(`${config.client_id}:${config.client_secret}`).toString('base64')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
|
@ -593,10 +614,44 @@ export class MCPOAuthHandler {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (clientAuth) {
|
/** Handle authentication based on configured methods */
|
||||||
headers['Authorization'] = `Basic ${clientAuth}`;
|
if (config.client_secret) {
|
||||||
|
/** Default to client_secret_basic if no methods specified (per RFC 8414) */
|
||||||
|
const tokenAuthMethods = config.token_endpoint_auth_methods_supported ?? [
|
||||||
|
'client_secret_basic',
|
||||||
|
];
|
||||||
|
const usesBasicAuth = tokenAuthMethods.includes('client_secret_basic');
|
||||||
|
const usesClientSecretPost = tokenAuthMethods.includes('client_secret_post');
|
||||||
|
|
||||||
|
if (usesBasicAuth) {
|
||||||
|
/** Use Basic auth */
|
||||||
|
logger.debug(
|
||||||
|
'[MCPOAuth] Using client_secret_basic authentication method (pre-configured)',
|
||||||
|
);
|
||||||
|
const clientAuth = Buffer.from(`${config.client_id}:${config.client_secret}`).toString(
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
headers['Authorization'] = `Basic ${clientAuth}`;
|
||||||
|
} else if (usesClientSecretPost) {
|
||||||
|
/** 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)',
|
||||||
|
);
|
||||||
|
const clientAuth = Buffer.from(`${config.client_id}:${config.client_secret}`).toString(
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
headers['Authorization'] = `Basic ${clientAuth}`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use client_id in body for public clients
|
/** For public clients, client_id must be in the body */
|
||||||
|
logger.debug('[MCPOAuth] Using public client authentication (no secret, pre-configured)');
|
||||||
body.append('client_id', config.client_id);
|
body.append('client_id', config.client_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue