fixes for oauth spec - adds github oauth support. Resource paramater. (#6281)
This commit is contained in:
parent
01b8a7565c
commit
2c07dc0757
|
@ -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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockTokenResponse),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockResourceMetadata),
|
status: 200,
|
||||||
})
|
}),
|
||||||
.mockResolvedValueOnce({
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockAuthServerMetadata),
|
contentType: 'application/json',
|
||||||
});
|
text: JSON.stringify(mockResourceMetadata),
|
||||||
|
json: mockResourceMetadata,
|
||||||
// Patch config after discovery
|
}),
|
||||||
configWithoutAuth.authorizationUrl =
|
)
|
||||||
mockAuthServerMetadata.authorization_endpoint;
|
.mockResolvedValueOnce(
|
||||||
configWithoutAuth.tokenUrl = mockAuthServerMetadata.token_endpoint;
|
createMockResponse({
|
||||||
configWithoutAuth.scopes = mockAuthServerMetadata.scopes_supported;
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockTokenResponse),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockAuthServerMetadata),
|
contentType: 'application/json',
|
||||||
})
|
text: JSON.stringify(mockAuthServerMetadata),
|
||||||
.mockResolvedValueOnce({
|
json: mockAuthServerMetadata,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockRegistrationResponse),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockTokenResponse),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 400,
|
status: 400,
|
||||||
text: () => Promise.resolve('Invalid grant'),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(refreshResponse),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockTokenResponse),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 400,
|
status: 400,
|
||||||
text: () => Promise.resolve('Invalid refresh token'),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(refreshResponse),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 400,
|
status: 400,
|
||||||
text: () => Promise.resolve('Invalid refresh token'),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockTokenResponse),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockTokenResponse),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockTokenResponse),
|
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(
|
||||||
|
createMockResponse({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockTokenResponse),
|
contentType: 'application/json',
|
||||||
});
|
text: JSON.stringify(mockTokenResponse),
|
||||||
|
json: mockTokenResponse,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const configWithFragment = {
|
const configWithFragment = {
|
||||||
...mockConfig,
|
...mockConfig,
|
||||||
|
|
|
@ -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) {
|
||||||
|
const resourceUrl = mcpServerUrl;
|
||||||
try {
|
try {
|
||||||
params.append('resource', OAuthUtils.buildResourceParameter(resourceUrl));
|
params.append(
|
||||||
} catch (error) {
|
'resource',
|
||||||
throw new Error(
|
OAuthUtils.buildResourceParameter(resourceUrl),
|
||||||
`Invalid resource URL: "${resourceUrl}". ${getErrorMessage(error)}`,
|
|
||||||
);
|
);
|
||||||
|
} 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,46 +589,60 @@ 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)
|
||||||
|
? { Accept: 'text/event-stream' }
|
||||||
|
: { Accept: 'application/json' };
|
||||||
|
|
||||||
const response = await fetch(mcpServerUrl, {
|
const response = await fetch(mcpServerUrl, {
|
||||||
method: 'HEAD',
|
method: 'HEAD',
|
||||||
headers: {
|
headers,
|
||||||
Accept: 'text/event-stream',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 307) {
|
if (response.status === 401 || response.status === 307) {
|
||||||
const wwwAuthenticate = response.headers.get('www-authenticate');
|
const wwwAuthenticate = response.headers.get('www-authenticate');
|
||||||
|
|
||||||
if (wwwAuthenticate) {
|
if (wwwAuthenticate) {
|
||||||
const discoveredConfig =
|
const discoveredConfig =
|
||||||
await OAuthUtils.discoverOAuthFromWWWAuthenticate(
|
await OAuthUtils.discoverOAuthFromWWWAuthenticate(
|
||||||
wwwAuthenticate,
|
wwwAuthenticate,
|
||||||
);
|
);
|
||||||
if (discoveredConfig) {
|
if (discoveredConfig) {
|
||||||
|
// Merge discovered config with existing config, preserving clientId and clientSecret
|
||||||
config = {
|
config = {
|
||||||
...config,
|
...config,
|
||||||
...discoveredConfig,
|
authorizationUrl: discoveredConfig.authorizationUrl,
|
||||||
|
tokenUrl: discoveredConfig.tokenUrl,
|
||||||
scopes: discoveredConfig.scopes || config.scopes || [],
|
scopes: discoveredConfig.scopes || config.scopes || [],
|
||||||
|
// Preserve existing client credentials
|
||||||
|
clientId: config.clientId,
|
||||||
|
clientSecret: config.clientSecret,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.debug(
|
console.debug(
|
||||||
`Failed to check SSE endpoint for authentication requirements: ${getErrorMessage(error)}`,
|
`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
|
||||||
if (!config.authorizationUrl) {
|
if (!config.authorizationUrl) {
|
||||||
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)}`);
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -43,11 +43,15 @@ 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 {
|
return {
|
||||||
protectedResource: new URL(
|
protectedResource: new URL(
|
||||||
'/.well-known/oauth-protected-resource',
|
'/.well-known/oauth-protected-resource',
|
||||||
|
@ -60,6 +64,20 @@ export class OAuthUtils {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path-based discovery: append path suffix to well-known URLs
|
||||||
|
const pathSuffix = serverUrl.pathname.replace(/\/$/, ''); // Remove trailing slash
|
||||||
|
return {
|
||||||
|
protectedResource: new URL(
|
||||||
|
`/.well-known/oauth-protected-resource${pathSuffix}`,
|
||||||
|
base,
|
||||||
|
).toString(),
|
||||||
|
authorizationServer: new URL(
|
||||||
|
`/.well-known/oauth-authorization-server${pathSuffix}`,
|
||||||
|
base,
|
||||||
|
).toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch OAuth protected resource metadata.
|
* Fetch OAuth protected resource metadata.
|
||||||
*
|
*
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue