🛜 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:
Danny Avila 2025-12-11 12:31:24 -05:00 committed by GitHub
parent 4a2de417b6
commit 24c76c6cb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 556 additions and 35 deletions

View file

@ -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',

View file

@ -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,
}),
}),
);
});
});
});