fixes for oauth spec - adds github oauth support. Resource paramater. (#6281)

This commit is contained in:
Brian Ray 2025-08-15 15:14:48 -04:00 committed by GitHub
parent 01b8a7565c
commit 2c07dc0757
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 557 additions and 173 deletions

View File

@ -29,6 +29,55 @@ import { MCPOAuthTokenStorage, MCPOAuthToken } from './oauth-token-storage.js';
const mockFetch = vi.fn(); const mockFetch = vi.fn();
global.fetch = mockFetch; global.fetch = mockFetch;
// Helper function to create mock fetch responses with proper headers
const createMockResponse = (options: {
ok: boolean;
status?: number;
contentType?: string;
text?: string | (() => Promise<string>);
json?: unknown | (() => Promise<unknown>);
}) => {
const response: {
ok: boolean;
status?: number;
headers: {
get: (name: string) => string | null;
};
text?: () => Promise<string>;
json?: () => Promise<unknown>;
} = {
ok: options.ok,
headers: {
get: (name: string) => {
if (name.toLowerCase() === 'content-type') {
return options.contentType || null;
}
return null;
},
},
};
if (options.status !== undefined) {
response.status = options.status;
}
if (options.text !== undefined) {
response.text =
typeof options.text === 'string'
? () => Promise.resolve(options.text as string)
: (options.text as () => Promise<string>);
}
if (options.json !== undefined) {
response.json =
typeof options.json === 'function'
? (options.json as () => Promise<unknown>)
: () => Promise.resolve(options.json);
}
return response;
};
// Define a reusable mock server with .listen, .close, and .on methods // Define a reusable mock server with .listen, .close, and .on methods
const mockHttpServer = { const mockHttpServer = {
listen: vi.fn(), listen: vi.fn(),
@ -133,10 +182,14 @@ describe('MCPOAuthProvider', () => {
}); });
// Mock token exchange // Mock token exchange
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(mockTokenResponse), ok: true,
}); contentType: 'application/json',
text: JSON.stringify(mockTokenResponse),
json: mockTokenResponse,
}),
);
const result = await MCPOAuthProvider.authenticate( const result = await MCPOAuthProvider.authenticate(
'test-server', 'test-server',
@ -165,7 +218,11 @@ describe('MCPOAuthProvider', () => {
it('should handle OAuth discovery when no authorization URL provided', async () => { it('should handle OAuth discovery when no authorization URL provided', async () => {
// Use a mutable config object // Use a mutable config object
const configWithoutAuth: MCPOAuthConfig = { ...mockConfig }; const configWithoutAuth: MCPOAuthConfig = {
...mockConfig,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
};
delete configWithoutAuth.authorizationUrl; delete configWithoutAuth.authorizationUrl;
delete configWithoutAuth.tokenUrl; delete configWithoutAuth.tokenUrl;
@ -179,21 +236,30 @@ describe('MCPOAuthProvider', () => {
scopes_supported: ['read', 'write'], scopes_supported: ['read', 'write'],
}; };
// Mock HEAD request for WWW-Authenticate check
mockFetch mockFetch
.mockResolvedValueOnce({ .mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(mockResourceMetadata), ok: true,
}) status: 200,
.mockResolvedValueOnce({ }),
ok: true, )
json: () => Promise.resolve(mockAuthServerMetadata), .mockResolvedValueOnce(
}); createMockResponse({
ok: true,
// Patch config after discovery contentType: 'application/json',
configWithoutAuth.authorizationUrl = text: JSON.stringify(mockResourceMetadata),
mockAuthServerMetadata.authorization_endpoint; json: mockResourceMetadata,
configWithoutAuth.tokenUrl = mockAuthServerMetadata.token_endpoint; }),
configWithoutAuth.scopes = mockAuthServerMetadata.scopes_supported; )
.mockResolvedValueOnce(
createMockResponse({
ok: true,
contentType: 'application/json',
text: JSON.stringify(mockAuthServerMetadata),
json: mockAuthServerMetadata,
}),
);
// Setup callback handler // Setup callback handler
let callbackHandler: unknown; let callbackHandler: unknown;
@ -220,10 +286,14 @@ describe('MCPOAuthProvider', () => {
}); });
// Mock token exchange with discovered endpoint // Mock token exchange with discovered endpoint
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(mockTokenResponse), ok: true,
}); contentType: 'application/json',
text: JSON.stringify(mockTokenResponse),
json: mockTokenResponse,
}),
);
const result = await MCPOAuthProvider.authenticate( const result = await MCPOAuthProvider.authenticate(
'test-server', 'test-server',
@ -236,7 +306,9 @@ describe('MCPOAuthProvider', () => {
'https://discovered.auth.com/token', 'https://discovered.auth.com/token',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: expect.objectContaining({
'Content-Type': 'application/x-www-form-urlencoded',
}),
}), }),
); );
}); });
@ -261,14 +333,22 @@ describe('MCPOAuthProvider', () => {
}; };
mockFetch mockFetch
.mockResolvedValueOnce({ .mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(mockAuthServerMetadata), ok: true,
}) contentType: 'application/json',
.mockResolvedValueOnce({ text: JSON.stringify(mockAuthServerMetadata),
ok: true, json: mockAuthServerMetadata,
json: () => Promise.resolve(mockRegistrationResponse), }),
}); )
.mockResolvedValueOnce(
createMockResponse({
ok: true,
contentType: 'application/json',
text: JSON.stringify(mockRegistrationResponse),
json: mockRegistrationResponse,
}),
);
// Setup callback handler // Setup callback handler
let callbackHandler: unknown; let callbackHandler: unknown;
@ -295,10 +375,14 @@ describe('MCPOAuthProvider', () => {
}); });
// Mock token exchange // Mock token exchange
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(mockTokenResponse), ok: true,
}); contentType: 'application/json',
text: JSON.stringify(mockTokenResponse),
json: mockTokenResponse,
}),
);
const result = await MCPOAuthProvider.authenticate( const result = await MCPOAuthProvider.authenticate(
'test-server', 'test-server',
@ -397,15 +481,18 @@ describe('MCPOAuthProvider', () => {
}, 10); }, 10);
}); });
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: false, createMockResponse({
status: 400, ok: false,
text: () => Promise.resolve('Invalid grant'), status: 400,
}); contentType: 'application/x-www-form-urlencoded',
text: 'error=invalid_grant&error_description=Invalid grant',
}),
);
await expect( await expect(
MCPOAuthProvider.authenticate('test-server', mockConfig), MCPOAuthProvider.authenticate('test-server', mockConfig),
).rejects.toThrow('Token exchange failed: 400 - Invalid grant'); ).rejects.toThrow('Token exchange failed: invalid_grant - Invalid grant');
}); });
it('should handle callback timeout', async () => { it('should handle callback timeout', async () => {
@ -445,10 +532,14 @@ describe('MCPOAuthProvider', () => {
refresh_token: 'new_refresh_token', refresh_token: 'new_refresh_token',
}; };
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(refreshResponse), ok: true,
}); contentType: 'application/json',
text: JSON.stringify(refreshResponse),
json: refreshResponse,
}),
);
const result = await MCPOAuthProvider.refreshAccessToken( const result = await MCPOAuthProvider.refreshAccessToken(
mockConfig, mockConfig,
@ -461,17 +552,24 @@ describe('MCPOAuthProvider', () => {
'https://auth.example.com/token', 'https://auth.example.com/token',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, application/x-www-form-urlencoded',
},
body: expect.stringContaining('grant_type=refresh_token'), body: expect.stringContaining('grant_type=refresh_token'),
}), }),
); );
}); });
it('should include client secret in refresh request when available', async () => { it('should include client secret in refresh request when available', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(mockTokenResponse), ok: true,
}); contentType: 'application/json',
text: JSON.stringify(mockTokenResponse),
json: mockTokenResponse,
}),
);
await MCPOAuthProvider.refreshAccessToken( await MCPOAuthProvider.refreshAccessToken(
mockConfig, mockConfig,
@ -484,11 +582,14 @@ describe('MCPOAuthProvider', () => {
}); });
it('should handle refresh token failure', async () => { it('should handle refresh token failure', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: false, createMockResponse({
status: 400, ok: false,
text: () => Promise.resolve('Invalid refresh token'), status: 400,
}); contentType: 'application/x-www-form-urlencoded',
text: 'error=invalid_request&error_description=Invalid refresh token',
}),
);
await expect( await expect(
MCPOAuthProvider.refreshAccessToken( MCPOAuthProvider.refreshAccessToken(
@ -496,7 +597,9 @@ describe('MCPOAuthProvider', () => {
'invalid_refresh_token', 'invalid_refresh_token',
'https://auth.example.com/token', 'https://auth.example.com/token',
), ),
).rejects.toThrow('Token refresh failed: 400 - Invalid refresh token'); ).rejects.toThrow(
'Token refresh failed: invalid_request - Invalid refresh token',
);
}); });
}); });
@ -544,10 +647,14 @@ describe('MCPOAuthProvider', () => {
refresh_token: 'new_refresh_token', refresh_token: 'new_refresh_token',
}; };
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(refreshResponse), ok: true,
}); contentType: 'application/json',
text: JSON.stringify(refreshResponse),
json: refreshResponse,
}),
);
const result = await MCPOAuthProvider.getValidToken( const result = await MCPOAuthProvider.getValidToken(
'test-server', 'test-server',
@ -590,11 +697,14 @@ describe('MCPOAuthProvider', () => {
vi.mocked(MCPOAuthTokenStorage.isTokenExpired).mockReturnValue(true); vi.mocked(MCPOAuthTokenStorage.isTokenExpired).mockReturnValue(true);
vi.mocked(MCPOAuthTokenStorage.removeToken).mockResolvedValue(undefined); vi.mocked(MCPOAuthTokenStorage.removeToken).mockResolvedValue(undefined);
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: false, createMockResponse({
status: 400, ok: false,
text: () => Promise.resolve('Invalid refresh token'), status: 400,
}); contentType: 'application/x-www-form-urlencoded',
text: 'error=invalid_request&error_description=Invalid refresh token',
}),
);
const result = await MCPOAuthProvider.getValidToken( const result = await MCPOAuthProvider.getValidToken(
'test-server', 'test-server',
@ -664,10 +774,14 @@ describe('MCPOAuthProvider', () => {
}, 10); }, 10);
}); });
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(mockTokenResponse), ok: true,
}); contentType: 'application/json',
text: JSON.stringify(mockTokenResponse),
json: mockTokenResponse,
}),
);
await MCPOAuthProvider.authenticate('test-server', mockConfig); await MCPOAuthProvider.authenticate('test-server', mockConfig);
@ -709,12 +823,20 @@ describe('MCPOAuthProvider', () => {
}, 10); }, 10);
}); });
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(mockTokenResponse), ok: true,
}); contentType: 'application/json',
text: JSON.stringify(mockTokenResponse),
json: mockTokenResponse,
}),
);
await MCPOAuthProvider.authenticate('test-server', mockConfig); await MCPOAuthProvider.authenticate(
'test-server',
mockConfig,
'https://auth.example.com',
);
expect(capturedUrl).toBeDefined(); expect(capturedUrl).toBeDefined();
expect(capturedUrl!).toContain('response_type=code'); expect(capturedUrl!).toContain('response_type=code');
@ -757,10 +879,14 @@ describe('MCPOAuthProvider', () => {
}, 10); }, 10);
}); });
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(mockTokenResponse), ok: true,
}); contentType: 'application/json',
text: JSON.stringify(mockTokenResponse),
json: mockTokenResponse,
}),
);
const configWithParamsInUrl = { const configWithParamsInUrl = {
...mockConfig, ...mockConfig,
@ -806,10 +932,14 @@ describe('MCPOAuthProvider', () => {
}, 10); }, 10);
}); });
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce(
ok: true, createMockResponse({
json: () => Promise.resolve(mockTokenResponse), ok: true,
}); contentType: 'application/json',
text: JSON.stringify(mockTokenResponse),
json: mockTokenResponse,
}),
);
const configWithFragment = { const configWithFragment = {
...mockConfig, ...mockConfig,

View File

@ -144,8 +144,8 @@ export class MCPOAuthProvider {
private static async discoverOAuthFromMCPServer( private static async discoverOAuthFromMCPServer(
mcpServerUrl: string, mcpServerUrl: string,
): Promise<MCPOAuthConfig | null> { ): Promise<MCPOAuthConfig | null> {
const baseUrl = OAuthUtils.extractBaseUrl(mcpServerUrl); // Use the full URL with path preserved for OAuth discovery
return OAuthUtils.discoverOAuthConfig(baseUrl); return OAuthUtils.discoverOAuthConfig(mcpServerUrl);
} }
/** /**
@ -302,14 +302,18 @@ export class MCPOAuthProvider {
} }
// Add resource parameter for MCP OAuth spec compliance // Add resource parameter for MCP OAuth spec compliance
// Use the MCP server URL if provided, otherwise fall back to authorization URL // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth)
const resourceUrl = mcpServerUrl || config.authorizationUrl!; if (mcpServerUrl) {
try { try {
params.append('resource', OAuthUtils.buildResourceParameter(resourceUrl)); params.append(
} catch (error) { 'resource',
throw new Error( OAuthUtils.buildResourceParameter(mcpServerUrl),
`Invalid resource URL: "${resourceUrl}". ${getErrorMessage(error)}`, );
); } catch (error) {
console.warn(
`Could not add resource parameter: ${getErrorMessage(error)}`,
);
}
} }
const url = new URL(config.authorizationUrl!); const url = new URL(config.authorizationUrl!);
@ -355,32 +359,93 @@ export class MCPOAuthProvider {
} }
// Add resource parameter for MCP OAuth spec compliance // Add resource parameter for MCP OAuth spec compliance
// Use the MCP server URL if provided, otherwise fall back to token URL // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth)
const resourceUrl = mcpServerUrl || config.tokenUrl!; if (mcpServerUrl) {
try { const resourceUrl = mcpServerUrl;
params.append('resource', OAuthUtils.buildResourceParameter(resourceUrl)); try {
} catch (error) { params.append(
throw new Error( 'resource',
`Invalid resource URL: "${resourceUrl}". ${getErrorMessage(error)}`, OAuthUtils.buildResourceParameter(resourceUrl),
); );
} catch (error) {
console.warn(
`Could not add resource parameter: ${getErrorMessage(error)}`,
);
}
} }
const response = await fetch(config.tokenUrl!, { const response = await fetch(config.tokenUrl!, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, application/x-www-form-urlencoded',
}, },
body: params.toString(), body: params.toString(),
}); });
const responseText = await response.text();
const contentType = response.headers.get('content-type') || '';
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); // Try to parse error from form-urlencoded response
let errorMessage: string | null = null;
try {
const errorParams = new URLSearchParams(responseText);
const error = errorParams.get('error');
const errorDescription = errorParams.get('error_description');
if (error) {
errorMessage = `Token exchange failed: ${error} - ${errorDescription || 'No description'}`;
}
} catch {
// Fall back to raw error
}
throw new Error( throw new Error(
`Token exchange failed: ${response.status} - ${errorText}`, errorMessage ||
`Token exchange failed: ${response.status} - ${responseText}`,
); );
} }
return (await response.json()) as OAuthTokenResponse; // Log unexpected content types for debugging
if (
!contentType.includes('application/json') &&
!contentType.includes('application/x-www-form-urlencoded')
) {
console.warn(
`Token endpoint returned unexpected content-type: ${contentType}. ` +
`Expected application/json or application/x-www-form-urlencoded. ` +
`Will attempt to parse response.`,
);
}
// Try to parse as JSON first, fall back to form-urlencoded
try {
return JSON.parse(responseText) as OAuthTokenResponse;
} catch {
// Parse form-urlencoded response
const tokenParams = new URLSearchParams(responseText);
const accessToken = tokenParams.get('access_token');
const tokenType = tokenParams.get('token_type') || 'Bearer';
const expiresIn = tokenParams.get('expires_in');
const refreshToken = tokenParams.get('refresh_token');
const scope = tokenParams.get('scope');
if (!accessToken) {
// Check for error in response
const error = tokenParams.get('error');
const errorDescription = tokenParams.get('error_description');
throw new Error(
`Token exchange failed: ${error || 'no_access_token'} - ${errorDescription || responseText}`,
);
}
return {
access_token: accessToken,
token_type: tokenType,
expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined,
refresh_token: refreshToken || undefined,
scope: scope || undefined,
} as OAuthTokenResponse;
}
} }
/** /**
@ -417,32 +482,92 @@ export class MCPOAuthProvider {
} }
// Add resource parameter for MCP OAuth spec compliance // Add resource parameter for MCP OAuth spec compliance
// Use the MCP server URL if provided, otherwise fall back to token URL // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth)
const resourceUrl = mcpServerUrl || tokenUrl; if (mcpServerUrl) {
try { try {
params.append('resource', OAuthUtils.buildResourceParameter(resourceUrl)); params.append(
} catch (error) { 'resource',
throw new Error( OAuthUtils.buildResourceParameter(mcpServerUrl),
`Invalid resource URL: "${resourceUrl}". ${getErrorMessage(error)}`, );
); } catch (error) {
console.warn(
`Could not add resource parameter: ${getErrorMessage(error)}`,
);
}
} }
const response = await fetch(tokenUrl, { const response = await fetch(tokenUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, application/x-www-form-urlencoded',
}, },
body: params.toString(), body: params.toString(),
}); });
const responseText = await response.text();
const contentType = response.headers.get('content-type') || '';
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); // Try to parse error from form-urlencoded response
let errorMessage: string | null = null;
try {
const errorParams = new URLSearchParams(responseText);
const error = errorParams.get('error');
const errorDescription = errorParams.get('error_description');
if (error) {
errorMessage = `Token refresh failed: ${error} - ${errorDescription || 'No description'}`;
}
} catch {
// Fall back to raw error
}
throw new Error( throw new Error(
`Token refresh failed: ${response.status} - ${errorText}`, errorMessage ||
`Token refresh failed: ${response.status} - ${responseText}`,
); );
} }
return (await response.json()) as OAuthTokenResponse; // Log unexpected content types for debugging
if (
!contentType.includes('application/json') &&
!contentType.includes('application/x-www-form-urlencoded')
) {
console.warn(
`Token refresh endpoint returned unexpected content-type: ${contentType}. ` +
`Expected application/json or application/x-www-form-urlencoded. ` +
`Will attempt to parse response.`,
);
}
// Try to parse as JSON first, fall back to form-urlencoded
try {
return JSON.parse(responseText) as OAuthTokenResponse;
} catch {
// Parse form-urlencoded response
const tokenParams = new URLSearchParams(responseText);
const accessToken = tokenParams.get('access_token');
const tokenType = tokenParams.get('token_type') || 'Bearer';
const expiresIn = tokenParams.get('expires_in');
const refreshToken = tokenParams.get('refresh_token');
const scope = tokenParams.get('scope');
if (!accessToken) {
// Check for error in response
const error = tokenParams.get('error');
const errorDescription = tokenParams.get('error_description');
throw new Error(
`Token refresh failed: ${error || 'unknown_error'} - ${errorDescription || responseText}`,
);
}
return {
access_token: accessToken,
token_type: tokenType,
expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined,
refresh_token: refreshToken || undefined,
scope: scope || undefined,
} as OAuthTokenResponse;
}
} }
/** /**
@ -464,37 +589,43 @@ export class MCPOAuthProvider {
'No authorization URL provided, attempting OAuth discovery...', 'No authorization URL provided, attempting OAuth discovery...',
); );
// For SSE URLs, first check if authentication is required // First check if the server requires authentication via WWW-Authenticate header
if (OAuthUtils.isSSEEndpoint(mcpServerUrl)) { try {
try { const headers: HeadersInit = OAuthUtils.isSSEEndpoint(mcpServerUrl)
const response = await fetch(mcpServerUrl, { ? { Accept: 'text/event-stream' }
method: 'HEAD', : { Accept: 'application/json' };
headers: {
Accept: 'text/event-stream',
},
});
if (response.status === 401 || response.status === 307) { const response = await fetch(mcpServerUrl, {
const wwwAuthenticate = response.headers.get('www-authenticate'); method: 'HEAD',
if (wwwAuthenticate) { headers,
const discoveredConfig = });
await OAuthUtils.discoverOAuthFromWWWAuthenticate(
wwwAuthenticate, if (response.status === 401 || response.status === 307) {
); const wwwAuthenticate = response.headers.get('www-authenticate');
if (discoveredConfig) {
config = { if (wwwAuthenticate) {
...config, const discoveredConfig =
...discoveredConfig, await OAuthUtils.discoverOAuthFromWWWAuthenticate(
scopes: discoveredConfig.scopes || config.scopes || [], wwwAuthenticate,
}; );
} if (discoveredConfig) {
// Merge discovered config with existing config, preserving clientId and clientSecret
config = {
...config,
authorizationUrl: discoveredConfig.authorizationUrl,
tokenUrl: discoveredConfig.tokenUrl,
scopes: discoveredConfig.scopes || config.scopes || [],
// Preserve existing client credentials
clientId: config.clientId,
clientSecret: config.clientSecret,
};
} }
} }
} catch (error) {
console.debug(
`Failed to check SSE endpoint for authentication requirements: ${getErrorMessage(error)}`,
);
} }
} catch (error) {
console.debug(
`Failed to check endpoint for authentication requirements: ${getErrorMessage(error)}`,
);
} }
// If we still don't have OAuth config, try the standard discovery // If we still don't have OAuth config, try the standard discovery
@ -502,8 +633,16 @@ export class MCPOAuthProvider {
const discoveredConfig = const discoveredConfig =
await this.discoverOAuthFromMCPServer(mcpServerUrl); await this.discoverOAuthFromMCPServer(mcpServerUrl);
if (discoveredConfig) { if (discoveredConfig) {
config = { ...config, ...discoveredConfig }; // Merge discovered config with existing config, preserving clientId and clientSecret
console.log('OAuth configuration discovered successfully'); config = {
...config,
authorizationUrl: discoveredConfig.authorizationUrl,
tokenUrl: discoveredConfig.tokenUrl,
scopes: discoveredConfig.scopes || config.scopes || [],
// Preserve existing client credentials
clientId: config.clientId,
clientSecret: config.clientSecret,
};
} else { } else {
throw new Error( throw new Error(
'Failed to discover OAuth configuration from MCP server', 'Failed to discover OAuth configuration from MCP server',
@ -633,9 +772,13 @@ export class MCPOAuthProvider {
); );
// Convert to our token format // Convert to our token format
if (!tokenResponse.access_token) {
throw new Error('No access token received from token endpoint');
}
const token: MCPOAuthToken = { const token: MCPOAuthToken = {
accessToken: tokenResponse.access_token, accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type, tokenType: tokenResponse.token_type || 'Bearer',
refreshToken: tokenResponse.refresh_token, refreshToken: tokenResponse.refresh_token,
scope: tokenResponse.scope, scope: tokenResponse.scope,
}; };
@ -657,12 +800,16 @@ export class MCPOAuthProvider {
// Verify token was saved // Verify token was saved
const savedToken = await MCPOAuthTokenStorage.getToken(serverName); const savedToken = await MCPOAuthTokenStorage.getToken(serverName);
if (savedToken) { if (savedToken && savedToken.token && savedToken.token.accessToken) {
console.log( const tokenPreview =
`Token verification successful: ${savedToken.token.accessToken.substring(0, 20)}...`, savedToken.token.accessToken.length > 20
); ? `${savedToken.token.accessToken.substring(0, 20)}...`
: '[token]';
console.log(`Token verification successful: ${tokenPreview}`);
} else { } else {
console.error('Token verification failed: token not found after save'); console.error(
'Token verification failed: token not found or invalid after save',
);
} }
} catch (saveError) { } catch (saveError) {
console.error(`Failed to save token: ${getErrorMessage(saveError)}`); console.error(`Failed to save token: ${getErrorMessage(saveError)}`);

View File

@ -28,8 +28,8 @@ describe('OAuthUtils', () => {
}); });
describe('buildWellKnownUrls', () => { describe('buildWellKnownUrls', () => {
it('should build correct well-known URLs', () => { it('should build standard root-based URLs by default', () => {
const urls = OAuthUtils.buildWellKnownUrls('https://example.com/path'); const urls = OAuthUtils.buildWellKnownUrls('https://example.com/mcp');
expect(urls.protectedResource).toBe( expect(urls.protectedResource).toBe(
'https://example.com/.well-known/oauth-protected-resource', 'https://example.com/.well-known/oauth-protected-resource',
); );
@ -37,6 +37,42 @@ describe('OAuthUtils', () => {
'https://example.com/.well-known/oauth-authorization-server', 'https://example.com/.well-known/oauth-authorization-server',
); );
}); });
it('should build path-based URLs when includePathSuffix is true', () => {
const urls = OAuthUtils.buildWellKnownUrls(
'https://example.com/mcp',
true,
);
expect(urls.protectedResource).toBe(
'https://example.com/.well-known/oauth-protected-resource/mcp',
);
expect(urls.authorizationServer).toBe(
'https://example.com/.well-known/oauth-authorization-server/mcp',
);
});
it('should handle root path correctly', () => {
const urls = OAuthUtils.buildWellKnownUrls('https://example.com', true);
expect(urls.protectedResource).toBe(
'https://example.com/.well-known/oauth-protected-resource',
);
expect(urls.authorizationServer).toBe(
'https://example.com/.well-known/oauth-authorization-server',
);
});
it('should handle trailing slash in path', () => {
const urls = OAuthUtils.buildWellKnownUrls(
'https://example.com/mcp/',
true,
);
expect(urls.protectedResource).toBe(
'https://example.com/.well-known/oauth-protected-resource/mcp',
);
expect(urls.authorizationServer).toBe(
'https://example.com/.well-known/oauth-authorization-server/mcp',
);
});
}); });
describe('fetchProtectedResourceMetadata', () => { describe('fetchProtectedResourceMetadata', () => {

View File

@ -43,18 +43,36 @@ export interface OAuthProtectedResourceMetadata {
export class OAuthUtils { export class OAuthUtils {
/** /**
* Construct well-known OAuth endpoint URLs. * Construct well-known OAuth endpoint URLs.
* By default, uses standard root-based well-known URLs.
* If includePathSuffix is true, appends any path from the base URL to the well-known endpoints.
*/ */
static buildWellKnownUrls(baseUrl: string) { static buildWellKnownUrls(baseUrl: string, includePathSuffix = false) {
const serverUrl = new URL(baseUrl); const serverUrl = new URL(baseUrl);
const base = `${serverUrl.protocol}//${serverUrl.host}`; const base = `${serverUrl.protocol}//${serverUrl.host}`;
if (!includePathSuffix) {
// Standard discovery: use root-based well-known URLs
return {
protectedResource: new URL(
'/.well-known/oauth-protected-resource',
base,
).toString(),
authorizationServer: new URL(
'/.well-known/oauth-authorization-server',
base,
).toString(),
};
}
// Path-based discovery: append path suffix to well-known URLs
const pathSuffix = serverUrl.pathname.replace(/\/$/, ''); // Remove trailing slash
return { return {
protectedResource: new URL( protectedResource: new URL(
'/.well-known/oauth-protected-resource', `/.well-known/oauth-protected-resource${pathSuffix}`,
base, base,
).toString(), ).toString(),
authorizationServer: new URL( authorizationServer: new URL(
'/.well-known/oauth-authorization-server', `/.well-known/oauth-authorization-server${pathSuffix}`,
base, base,
).toString(), ).toString(),
}; };
@ -132,25 +150,56 @@ export class OAuthUtils {
serverUrl: string, serverUrl: string,
): Promise<MCPOAuthConfig | null> { ): Promise<MCPOAuthConfig | null> {
try { try {
const wellKnownUrls = this.buildWellKnownUrls(serverUrl); // First try standard root-based discovery
const wellKnownUrls = this.buildWellKnownUrls(serverUrl, false);
// First, try to get the protected resource metadata // Try to get the protected resource metadata at root
const resourceMetadata = await this.fetchProtectedResourceMetadata( let resourceMetadata = await this.fetchProtectedResourceMetadata(
wellKnownUrls.protectedResource, wellKnownUrls.protectedResource,
); );
// If root discovery fails and we have a path, try path-based discovery
if (!resourceMetadata) {
const url = new URL(serverUrl);
if (url.pathname && url.pathname !== '/') {
const pathBasedUrls = this.buildWellKnownUrls(serverUrl, true);
resourceMetadata = await this.fetchProtectedResourceMetadata(
pathBasedUrls.protectedResource,
);
}
}
if (resourceMetadata?.authorization_servers?.length) { if (resourceMetadata?.authorization_servers?.length) {
// Use the first authorization server // Use the first authorization server
const authServerUrl = resourceMetadata.authorization_servers[0]; const authServerUrl = resourceMetadata.authorization_servers[0];
const authServerMetadataUrl = new URL(
'/.well-known/oauth-authorization-server', // The authorization server URL may include a path (e.g., https://github.com/login/oauth)
authServerUrl, // We need to preserve this path when constructing the metadata URL
const authServerUrlObj = new URL(authServerUrl);
const authServerPath =
authServerUrlObj.pathname === '/' ? '' : authServerUrlObj.pathname;
// Try with the authorization server's path first
let authServerMetadataUrl = new URL(
`/.well-known/oauth-authorization-server${authServerPath}`,
`${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
).toString(); ).toString();
const authServerMetadata = await this.fetchAuthorizationServerMetadata( let authServerMetadata = await this.fetchAuthorizationServerMetadata(
authServerMetadataUrl, authServerMetadataUrl,
); );
// If that fails, try root as fallback
if (!authServerMetadata && authServerPath) {
authServerMetadataUrl = new URL(
'/.well-known/oauth-authorization-server',
`${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
).toString();
authServerMetadata = await this.fetchAuthorizationServerMetadata(
authServerMetadataUrl,
);
}
if (authServerMetadata) { if (authServerMetadata) {
const config = this.metadataToOAuthConfig(authServerMetadata); const config = this.metadataToOAuthConfig(authServerMetadata);
if (authServerMetadata.registration_endpoint) { if (authServerMetadata.registration_endpoint) {
@ -221,10 +270,6 @@ export class OAuthUtils {
return null; return null;
} }
console.log(
`Found resource metadata URI from www-authenticate header: ${resourceMetadataUri}`,
);
const resourceMetadata = const resourceMetadata =
await this.fetchProtectedResourceMetadata(resourceMetadataUri); await this.fetchProtectedResourceMetadata(resourceMetadataUri);
if (!resourceMetadata?.authorization_servers?.length) { if (!resourceMetadata?.authorization_servers?.length) {
@ -232,19 +277,36 @@ export class OAuthUtils {
} }
const authServerUrl = resourceMetadata.authorization_servers[0]; const authServerUrl = resourceMetadata.authorization_servers[0];
// The authorization server URL may include a path (e.g., https://github.com/login/oauth)
// We need to preserve this path when constructing the metadata URL
const authServerUrlObj = new URL(authServerUrl);
const authServerPath =
authServerUrlObj.pathname === '/' ? '' : authServerUrlObj.pathname;
// Build auth server metadata URL with the authorization server's path
const authServerMetadataUrl = new URL( const authServerMetadataUrl = new URL(
'/.well-known/oauth-authorization-server', `/.well-known/oauth-authorization-server${authServerPath}`,
authServerUrl, `${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
).toString(); ).toString();
const authServerMetadata = await this.fetchAuthorizationServerMetadata( let authServerMetadata = await this.fetchAuthorizationServerMetadata(
authServerMetadataUrl, authServerMetadataUrl,
); );
if (authServerMetadata) { // If that fails and we have a path, also try the root path as a fallback
console.log( if (!authServerMetadata && authServerPath) {
'OAuth configuration discovered successfully from www-authenticate header', const rootAuthServerMetadataUrl = new URL(
'/.well-known/oauth-authorization-server',
`${authServerUrlObj.protocol}//${authServerUrlObj.host}`,
).toString();
authServerMetadata = await this.fetchAuthorizationServerMetadata(
rootAuthServerMetadataUrl,
); );
}
if (authServerMetadata) {
return this.metadataToOAuthConfig(authServerMetadata); return this.metadataToOAuthConfig(authServerMetadata);
} }

View File

@ -227,10 +227,16 @@ async function handleAutomaticOAuth(
}; };
// Perform OAuth authentication // Perform OAuth authentication
// Pass the server URL for proper discovery
const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url;
console.log( console.log(
`Starting OAuth authentication for server '${mcpServerName}'...`, `Starting OAuth authentication for server '${mcpServerName}'...`,
); );
await MCPOAuthProvider.authenticate(mcpServerName, oauthAuthConfig); await MCPOAuthProvider.authenticate(
mcpServerName,
oauthAuthConfig,
serverUrl,
);
console.log( console.log(
`OAuth authentication successful for server '${mcpServerName}'`, `OAuth authentication successful for server '${mcpServerName}'`,
@ -933,12 +939,15 @@ export async function connectToMcpServer(
}; };
// Perform OAuth authentication // Perform OAuth authentication
// Pass the server URL for proper discovery
const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url;
console.log( console.log(
`Starting OAuth authentication for server '${mcpServerName}'...`, `Starting OAuth authentication for server '${mcpServerName}'...`,
); );
await MCPOAuthProvider.authenticate( await MCPOAuthProvider.authenticate(
mcpServerName, mcpServerName,
oauthAuthConfig, oauthAuthConfig,
serverUrl,
); );
// Retry connection with OAuth token // Retry connection with OAuth token