mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-27 04:36:12 +01:00
🛜 feat: Support Legacy OAuth Servers without .well-known Metadata (#10917)
Adds support for MCP servers like StackOverflow that use OAuth but don't provide standard discovery metadata at .well-known endpoints. Changes: - Add fallback OAuth endpoints (/authorize, /token, /register) when discoverAuthorizationServerMetadata returns undefined - Add POST fallback in OAuth detection when HEAD returns non-401 (StackOverflow returns 405 for HEAD, 401 for POST) - Detect OAuth requirement from WWW-Authenticate: Bearer header even without resource_metadata URL - Add fallback /token endpoint for token refresh when metadata discovery fails - Add registration_endpoint to OAuthMetadata type This mirrors the MCP SDK's behavior where it gracefully falls back to default OAuth endpoint paths when .well-known metadata isn't available. Tests: - Add unit tests for detectOAuth.ts (POST fallback, Bearer detection) - Add unit tests for handler.ts (fallback metadata, fallback refresh) - Add StackOverflow to integration test servers Fixes OAuth flow for servers that: - Return 405 for HEAD requests (only support POST) - Return 401 with simple "Bearer" in WWW-Authenticate - Don't have .well-known/oauth-authorization-server endpoint - Use standard /authorize, /token, /register paths
This commit is contained in:
parent
4a2de417b6
commit
24c76c6cb9
8 changed files with 556 additions and 35 deletions
|
|
@ -25,7 +25,7 @@ describe('OAuth Detection Integration Tests', () => {
|
|||
name: 'GitHub Copilot MCP Server',
|
||||
url: 'https://api.githubcopilot.com/mcp',
|
||||
expectedOAuth: true,
|
||||
expectedMethod: '401-challenge-metadata',
|
||||
expectedMethod: 'protected-resource-metadata',
|
||||
withMeta: true,
|
||||
},
|
||||
{
|
||||
|
|
@ -42,6 +42,13 @@ describe('OAuth Detection Integration Tests', () => {
|
|||
expectedMethod: 'protected-resource-metadata',
|
||||
withMeta: true,
|
||||
},
|
||||
{
|
||||
name: 'StackOverflow MCP (HEAD=405, POST=401+Bearer)',
|
||||
url: 'https://mcp.stackoverflow.com',
|
||||
expectedOAuth: true,
|
||||
expectedMethod: '401-challenge-metadata',
|
||||
withMeta: false,
|
||||
},
|
||||
{
|
||||
name: 'HTTPBin (Non-OAuth)',
|
||||
url: 'https://httpbin.org',
|
||||
|
|
|
|||
|
|
@ -992,4 +992,147 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => {
|
|||
expect(headers.get('foo')).toBe('bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback OAuth Metadata (Legacy Server Support)', () => {
|
||||
const originalFetch = global.fetch;
|
||||
const mockFetch = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should use fallback metadata when discoverAuthorizationServerMetadata returns undefined', async () => {
|
||||
// Mock resource metadata discovery to fail
|
||||
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValueOnce(
|
||||
new Error('No resource metadata'),
|
||||
);
|
||||
|
||||
// Mock authorization server metadata discovery to return undefined (no .well-known)
|
||||
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce(undefined);
|
||||
|
||||
// Mock client registration to succeed
|
||||
mockRegisterClient.mockResolvedValueOnce({
|
||||
client_id: 'dynamic-client-id',
|
||||
client_secret: 'dynamic-client-secret',
|
||||
redirect_uris: ['http://localhost:3080/api/mcp/test-server/oauth/callback'],
|
||||
});
|
||||
|
||||
// Mock startAuthorization to return a successful response
|
||||
mockStartAuthorization.mockResolvedValueOnce({
|
||||
authorizationUrl: new URL('https://mcp.example.com/authorize?client_id=dynamic-client-id'),
|
||||
codeVerifier: 'test-code-verifier',
|
||||
});
|
||||
|
||||
await MCPOAuthHandler.initiateOAuthFlow(
|
||||
'test-server',
|
||||
'https://mcp.example.com',
|
||||
'user-123',
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Verify registerClient was called with fallback metadata
|
||||
expect(mockRegisterClient).toHaveBeenCalledWith(
|
||||
'https://mcp.example.com/',
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
issuer: 'https://mcp.example.com/',
|
||||
authorization_endpoint: 'https://mcp.example.com/authorize',
|
||||
token_endpoint: 'https://mcp.example.com/token',
|
||||
registration_endpoint: 'https://mcp.example.com/register',
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256', 'plain'],
|
||||
token_endpoint_auth_methods_supported: [
|
||||
'client_secret_basic',
|
||||
'client_secret_post',
|
||||
'none',
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use fallback /token endpoint for refresh when metadata discovery fails', async () => {
|
||||
const metadata = {
|
||||
serverName: 'test-server',
|
||||
serverUrl: 'https://mcp.example.com',
|
||||
clientInfo: {
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock metadata discovery to return undefined (no .well-known)
|
||||
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce(undefined);
|
||||
|
||||
// Mock successful token refresh
|
||||
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(
|
||||
'test-refresh-token',
|
||||
metadata,
|
||||
{},
|
||||
{},
|
||||
);
|
||||
|
||||
// Verify fetch was called with fallback /token endpoint
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mcp.example.com/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.access_token).toBe('new-access-token');
|
||||
});
|
||||
|
||||
it('should use fallback auth methods when metadata discovery fails during refresh', async () => {
|
||||
const metadata = {
|
||||
serverName: 'test-server',
|
||||
serverUrl: 'https://mcp.example.com',
|
||||
clientInfo: {
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock metadata discovery to return undefined
|
||||
mockDiscoverAuthorizationServerMetadata.mockResolvedValueOnce(undefined);
|
||||
|
||||
// Mock successful token refresh
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new-access-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await MCPOAuthHandler.refreshOAuthTokens('test-refresh-token', metadata, {}, {});
|
||||
|
||||
// Verify it uses client_secret_basic (first in fallback auth methods)
|
||||
const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`;
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: expectedAuth,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue