Path: blob/main/src/vs/workbench/api/test/common/extHostMcp.test.ts
4780 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as assert from 'assert';6import * as sinon from 'sinon';7import { LogLevel } from '../../../../platform/log/common/log.js';8import { createAuthMetadata, CommonResponse, IAuthMetadata } from '../../common/extHostMcp.js';9import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';1011// Test constants to avoid magic strings12const TEST_MCP_URL = 'https://example.com/mcp';13const TEST_AUTH_SERVER = 'https://auth.example.com';14const TEST_RESOURCE_METADATA_URL = 'https://example.com/.well-known/oauth-protected-resource';1516/**17* Creates a mock CommonResponse for testing.18*/19function createMockResponse(options: {20status?: number;21statusText?: string;22url?: string;23headers?: Record<string, string>;24body?: string;25}): CommonResponse {26const headers = new Headers(options.headers ?? {});27return {28status: options.status ?? 200,29statusText: options.statusText ?? 'OK',30url: options.url ?? TEST_MCP_URL,31headers,32body: null,33json: async () => JSON.parse(options.body ?? '{}'),34text: async () => options.body ?? '',35};36}3738/**39* Helper to create an IAuthMetadata instance for testing via the factory function.40* Uses a mock fetch that returns the provided server metadata.41*/42async function createTestAuthMetadata(options: {43scopes?: string[];44serverMetadataIssuer?: string;45resourceMetadata?: { resource: string; authorization_servers?: string[]; scopes_supported?: string[] };46}): Promise<{ authMetadata: IAuthMetadata; logMessages: Array<{ level: LogLevel; message: string }> }> {47const logMessages: Array<{ level: LogLevel; message: string }> = [];48const mockLogger = (level: LogLevel, message: string) => logMessages.push({ level, message });4950const issuer = options.serverMetadataIssuer ?? TEST_AUTH_SERVER;5152const mockFetch = sinon.stub();5354// Mock resource metadata fetch55mockFetch.onCall(0).resolves(createMockResponse({56status: 200,57url: TEST_RESOURCE_METADATA_URL,58body: JSON.stringify(options.resourceMetadata ?? {59resource: TEST_MCP_URL,60authorization_servers: [issuer]61})62}));6364// Mock server metadata fetch65mockFetch.onCall(1).resolves(createMockResponse({66status: 200,67url: `${issuer}/.well-known/oauth-authorization-server`,68body: JSON.stringify({69issuer,70authorization_endpoint: `${issuer}/authorize`,71token_endpoint: `${issuer}/token`,72response_types_supported: ['code']73})74}));7576const wwwAuthHeader = options.scopes77? `Bearer scope="${options.scopes.join(' ')}"`78: 'Bearer realm="example"';7980const originalResponse = createMockResponse({81status: 401,82url: TEST_MCP_URL,83headers: {84'WWW-Authenticate': wwwAuthHeader85}86});8788const authMetadata = await createAuthMetadata(89TEST_MCP_URL,90originalResponse.headers,91{92sameOriginHeaders: {},93fetch: mockFetch,94log: mockLogger95}96);9798return { authMetadata, logMessages };99}100101suite('ExtHostMcp', () => {102ensureNoDisposablesAreLeakedInTestSuite();103104suite('IAuthMetadata', () => {105suite('properties', () => {106test('should expose readonly properties', async () => {107const { authMetadata } = await createTestAuthMetadata({108scopes: ['read', 'write'],109serverMetadataIssuer: TEST_AUTH_SERVER110});111112assert.ok(authMetadata.authorizationServer.toString().startsWith(TEST_AUTH_SERVER));113assert.strictEqual(authMetadata.serverMetadata.issuer, TEST_AUTH_SERVER);114assert.deepStrictEqual(authMetadata.scopes, ['read', 'write']);115});116117test('should allow undefined scopes', async () => {118const { authMetadata } = await createTestAuthMetadata({119scopes: undefined120});121122assert.strictEqual(authMetadata.scopes, undefined);123});124});125126suite('update()', () => {127test('should return true and update scopes when WWW-Authenticate header contains new scopes', async () => {128const { authMetadata } = await createTestAuthMetadata({129scopes: ['read']130});131132const response = createMockResponse({133status: 401,134headers: {135'WWW-Authenticate': 'Bearer scope="read write admin"'136}137});138139const result = authMetadata.update(response.headers);140141assert.strictEqual(result, true);142assert.deepStrictEqual(authMetadata.scopes, ['read', 'write', 'admin']);143});144145test('should return false when scopes are the same', async () => {146const { authMetadata } = await createTestAuthMetadata({147scopes: ['read', 'write']148});149150const response = createMockResponse({151status: 401,152headers: {153'WWW-Authenticate': 'Bearer scope="read write"'154}155});156157const result = authMetadata.update(response.headers);158159assert.strictEqual(result, false);160assert.deepStrictEqual(authMetadata.scopes, ['read', 'write']);161});162163test('should return false when scopes are same but in different order', async () => {164const { authMetadata } = await createTestAuthMetadata({165scopes: ['read', 'write']166});167168const response = createMockResponse({169status: 401,170headers: {171'WWW-Authenticate': 'Bearer scope="write read"'172}173});174175const result = authMetadata.update(response.headers);176177assert.strictEqual(result, false);178});179180test('should return true when updating from undefined scopes to defined scopes', async () => {181const { authMetadata } = await createTestAuthMetadata({182scopes: undefined183});184185const response = createMockResponse({186status: 401,187headers: {188'WWW-Authenticate': 'Bearer scope="read"'189}190});191192const result = authMetadata.update(response.headers);193194assert.strictEqual(result, true);195assert.deepStrictEqual(authMetadata.scopes, ['read']);196});197198test('should return true when updating from defined scopes to undefined (no scope in header)', async () => {199const { authMetadata } = await createTestAuthMetadata({200scopes: ['read']201});202203const response = createMockResponse({204status: 401,205headers: {206'WWW-Authenticate': 'Bearer realm="example"'207}208});209210const result = authMetadata.update(response.headers);211212assert.strictEqual(result, true);213assert.strictEqual(authMetadata.scopes, undefined);214});215216test('should return false when no WWW-Authenticate header and scopes are already undefined', async () => {217const { authMetadata } = await createTestAuthMetadata({218scopes: undefined219});220221const response = createMockResponse({222status: 401,223headers: {}224});225226const result = authMetadata.update(response.headers);227228assert.strictEqual(result, false);229});230231test('should handle multiple Bearer challenges and use first scope', async () => {232const { authMetadata } = await createTestAuthMetadata({233scopes: undefined234});235236const response = createMockResponse({237status: 401,238headers: {239'WWW-Authenticate': 'Bearer scope="first", Bearer scope="second"'240}241});242243authMetadata.update(response.headers);244245assert.deepStrictEqual(authMetadata.scopes, ['first']);246});247248test('should ignore non-Bearer schemes', async () => {249const { authMetadata } = await createTestAuthMetadata({250scopes: undefined251});252253const response = createMockResponse({254status: 401,255headers: {256'WWW-Authenticate': 'Basic realm="example"'257}258});259260const result = authMetadata.update(response.headers);261262assert.strictEqual(result, false);263assert.strictEqual(authMetadata.scopes, undefined);264});265});266});267268suite('createAuthMetadata', () => {269let sandbox: sinon.SinonSandbox;270let logMessages: Array<{ level: LogLevel; message: string }>;271let mockLogger: (level: LogLevel, message: string) => void;272273setup(() => {274sandbox = sinon.createSandbox();275logMessages = [];276mockLogger = (level, message) => logMessages.push({ level, message });277});278279teardown(() => {280sandbox.restore();281});282283test('should create IAuthMetadata with fetched server metadata', async () => {284const mockFetch = sandbox.stub();285286// Mock resource metadata fetch287mockFetch.onCall(0).resolves(createMockResponse({288status: 200,289url: TEST_RESOURCE_METADATA_URL,290body: JSON.stringify({291resource: TEST_MCP_URL,292authorization_servers: [TEST_AUTH_SERVER],293scopes_supported: ['read', 'write']294})295}));296297// Mock server metadata fetch298mockFetch.onCall(1).resolves(createMockResponse({299status: 200,300url: `${TEST_AUTH_SERVER}/.well-known/oauth-authorization-server`,301body: JSON.stringify({302issuer: TEST_AUTH_SERVER,303authorization_endpoint: `${TEST_AUTH_SERVER}/authorize`,304token_endpoint: `${TEST_AUTH_SERVER}/token`,305response_types_supported: ['code']306})307}));308309const originalResponse = createMockResponse({310status: 401,311url: TEST_MCP_URL,312headers: {313'WWW-Authenticate': 'Bearer scope="api.read"'314}315});316317const authMetadata = await createAuthMetadata(318TEST_MCP_URL,319originalResponse.headers,320{321sameOriginHeaders: { 'X-Custom': 'value' },322fetch: mockFetch,323log: mockLogger324}325);326327assert.ok(authMetadata.authorizationServer.toString().startsWith(TEST_AUTH_SERVER));328assert.strictEqual(authMetadata.serverMetadata.issuer, TEST_AUTH_SERVER);329assert.deepStrictEqual(authMetadata.scopes, ['api.read']);330});331332test('should fall back to default metadata when server metadata fetch fails', async () => {333const mockFetch = sandbox.stub();334335// Mock resource metadata fetch - fails336mockFetch.onCall(0).rejects(new Error('Network error'));337338// Mock server metadata fetch - also fails339mockFetch.onCall(1).rejects(new Error('Network error'));340341const originalResponse = createMockResponse({342status: 401,343url: TEST_MCP_URL,344headers: {}345});346347const authMetadata = await createAuthMetadata(348TEST_MCP_URL,349originalResponse.headers,350{351sameOriginHeaders: {},352fetch: mockFetch,353log: mockLogger354}355);356357// Should use default metadata based on the URL358assert.ok(authMetadata.authorizationServer.toString().startsWith('https://example.com'));359assert.ok(authMetadata.serverMetadata.issuer.startsWith('https://example.com'));360assert.ok(authMetadata.serverMetadata.authorization_endpoint?.startsWith('https://example.com/authorize'));361assert.ok(authMetadata.serverMetadata.token_endpoint?.startsWith('https://example.com/token'));362363// Should log the fallback364assert.ok(logMessages.some(m =>365m.level === LogLevel.Info &&366m.message.includes('Using default auth metadata')367));368});369370test('should use scopes from WWW-Authenticate header when resource metadata has none', async () => {371const mockFetch = sandbox.stub();372373// Mock resource metadata fetch - no scopes_supported374mockFetch.onCall(0).resolves(createMockResponse({375status: 200,376url: TEST_RESOURCE_METADATA_URL,377body: JSON.stringify({378resource: TEST_MCP_URL,379authorization_servers: [TEST_AUTH_SERVER]380})381}));382383// Mock server metadata fetch384mockFetch.onCall(1).resolves(createMockResponse({385status: 200,386url: `${TEST_AUTH_SERVER}/.well-known/oauth-authorization-server`,387body: JSON.stringify({388issuer: TEST_AUTH_SERVER,389authorization_endpoint: `${TEST_AUTH_SERVER}/authorize`,390token_endpoint: `${TEST_AUTH_SERVER}/token`,391response_types_supported: ['code']392})393}));394395const originalResponse = createMockResponse({396status: 401,397url: TEST_MCP_URL,398headers: {399'WWW-Authenticate': 'Bearer scope="header.scope"'400}401});402403const authMetadata = await createAuthMetadata(404TEST_MCP_URL,405originalResponse.headers,406{407sameOriginHeaders: {},408fetch: mockFetch,409log: mockLogger410}411);412413assert.deepStrictEqual(authMetadata.scopes, ['header.scope']);414});415416test('should use scopes from WWW-Authenticate header even when resource metadata has scopes_supported', async () => {417const mockFetch = sandbox.stub();418419// Mock resource metadata fetch - has scopes_supported420mockFetch.onCall(0).resolves(createMockResponse({421status: 200,422url: TEST_RESOURCE_METADATA_URL,423body: JSON.stringify({424resource: TEST_MCP_URL,425authorization_servers: [TEST_AUTH_SERVER],426scopes_supported: ['resource.scope1', 'resource.scope2']427})428}));429430// Mock server metadata fetch431mockFetch.onCall(1).resolves(createMockResponse({432status: 200,433url: `${TEST_AUTH_SERVER}/.well-known/oauth-authorization-server`,434body: JSON.stringify({435issuer: TEST_AUTH_SERVER,436authorization_endpoint: `${TEST_AUTH_SERVER}/authorize`,437token_endpoint: `${TEST_AUTH_SERVER}/token`,438response_types_supported: ['code']439})440}));441442const originalResponse = createMockResponse({443status: 401,444url: TEST_MCP_URL,445headers: {446'WWW-Authenticate': 'Bearer scope="header.scope"'447}448});449450const authMetadata = await createAuthMetadata(451TEST_MCP_URL,452originalResponse.headers,453{454sameOriginHeaders: {},455fetch: mockFetch,456log: mockLogger457}458);459460// WWW-Authenticate header scopes take precedence over resource metadata scopes_supported461assert.deepStrictEqual(authMetadata.scopes, ['header.scope']);462});463464test('should use resource_metadata challenge URL from WWW-Authenticate header', async () => {465const mockFetch = sandbox.stub();466467// Mock resource metadata fetch from challenge URL468mockFetch.onCall(0).resolves(createMockResponse({469status: 200,470url: 'https://example.com/custom-resource-metadata',471body: JSON.stringify({472resource: TEST_MCP_URL,473authorization_servers: [TEST_AUTH_SERVER]474})475}));476477// Mock server metadata fetch478mockFetch.onCall(1).resolves(createMockResponse({479status: 200,480url: `${TEST_AUTH_SERVER}/.well-known/oauth-authorization-server`,481body: JSON.stringify({482issuer: TEST_AUTH_SERVER,483authorization_endpoint: `${TEST_AUTH_SERVER}/authorize`,484token_endpoint: `${TEST_AUTH_SERVER}/token`,485response_types_supported: ['code']486})487}));488489const originalResponse = createMockResponse({490status: 401,491url: TEST_MCP_URL,492headers: {493'WWW-Authenticate': 'Bearer resource_metadata="https://example.com/custom-resource-metadata"'494}495});496497const authMetadata = await createAuthMetadata(498TEST_MCP_URL,499originalResponse.headers,500{501sameOriginHeaders: {},502fetch: mockFetch,503log: mockLogger504}505);506507assert.ok(authMetadata.authorizationServer.toString().startsWith(TEST_AUTH_SERVER));508509// Verify the resource_metadata URL was logged510assert.ok(logMessages.some(m =>511m.level === LogLevel.Debug &&512m.message.includes('resource_metadata challenge')513));514});515516test('should pass launch headers when fetching metadata from same origin', async () => {517const mockFetch = sandbox.stub();518519// Mock resource metadata fetch to succeed so we can verify headers520mockFetch.onCall(0).resolves(createMockResponse({521status: 200,522url: TEST_RESOURCE_METADATA_URL,523body: JSON.stringify({524resource: TEST_MCP_URL,525authorization_servers: [TEST_AUTH_SERVER]526})527}));528529// Mock server metadata fetch530mockFetch.onCall(1).resolves(createMockResponse({531status: 200,532url: `${TEST_AUTH_SERVER}/.well-known/oauth-authorization-server`,533body: JSON.stringify({534issuer: TEST_AUTH_SERVER,535authorization_endpoint: `${TEST_AUTH_SERVER}/authorize`,536token_endpoint: `${TEST_AUTH_SERVER}/token`,537response_types_supported: ['code']538})539}));540541const originalResponse = createMockResponse({542status: 401,543url: TEST_MCP_URL,544headers: {}545});546547const launchHeaders = {548'Authorization': 'Bearer existing-token',549'X-Custom-Header': 'custom-value'550};551552await createAuthMetadata(553TEST_MCP_URL,554originalResponse.headers,555{556sameOriginHeaders: launchHeaders,557fetch: mockFetch,558log: mockLogger559}560);561562// Verify fetch was called563assert.ok(mockFetch.called, 'fetch should have been called');564565// Verify the first call (resource metadata) included the launch headers566const firstCallArgs = mockFetch.firstCall.args;567assert.ok(firstCallArgs.length >= 2, 'fetch should have been called with options');568const fetchOptions = firstCallArgs[1] as RequestInit;569assert.ok(fetchOptions.headers, 'fetch options should include headers');570});571572test('should handle empty scope string in WWW-Authenticate header', async () => {573const mockFetch = sandbox.stub();574575// Mock resource metadata fetch576mockFetch.onCall(0).resolves(createMockResponse({577status: 200,578url: TEST_RESOURCE_METADATA_URL,579body: JSON.stringify({580resource: TEST_MCP_URL,581authorization_servers: [TEST_AUTH_SERVER]582})583}));584585// Mock server metadata fetch586mockFetch.onCall(1).resolves(createMockResponse({587status: 200,588url: `${TEST_AUTH_SERVER}/.well-known/oauth-authorization-server`,589body: JSON.stringify({590issuer: TEST_AUTH_SERVER,591authorization_endpoint: `${TEST_AUTH_SERVER}/authorize`,592token_endpoint: `${TEST_AUTH_SERVER}/token`,593response_types_supported: ['code']594})595}));596597const originalResponse = createMockResponse({598status: 401,599url: TEST_MCP_URL,600headers: {601'WWW-Authenticate': 'Bearer scope=""'602}603});604605const authMetadata = await createAuthMetadata(606TEST_MCP_URL,607originalResponse.headers,608{609sameOriginHeaders: {},610fetch: mockFetch,611log: mockLogger612}613);614615// Empty scope string should result in empty array or undefined616assert.ok(617authMetadata.scopes === undefined ||618(Array.isArray(authMetadata.scopes) && authMetadata.scopes.length === 0) ||619(Array.isArray(authMetadata.scopes) && authMetadata.scopes.every(s => s === '')),620'Empty scope string should be handled gracefully'621);622});623624test('should handle malformed WWW-Authenticate header gracefully', async () => {625const mockFetch = sandbox.stub();626627// Mock resource metadata fetch628mockFetch.onCall(0).resolves(createMockResponse({629status: 200,630url: TEST_RESOURCE_METADATA_URL,631body: JSON.stringify({632resource: TEST_MCP_URL,633authorization_servers: [TEST_AUTH_SERVER]634})635}));636637// Mock server metadata fetch638mockFetch.onCall(1).resolves(createMockResponse({639status: 200,640url: `${TEST_AUTH_SERVER}/.well-known/oauth-authorization-server`,641body: JSON.stringify({642issuer: TEST_AUTH_SERVER,643authorization_endpoint: `${TEST_AUTH_SERVER}/authorize`,644token_endpoint: `${TEST_AUTH_SERVER}/token`,645response_types_supported: ['code']646})647}));648649const originalResponse = createMockResponse({650status: 401,651url: TEST_MCP_URL,652headers: {653// Malformed header - missing closing quote654'WWW-Authenticate': 'Bearer scope="unclosed'655}656});657658// Should not throw - should handle gracefully659const authMetadata = await createAuthMetadata(660TEST_MCP_URL,661originalResponse.headers,662{663sameOriginHeaders: {},664fetch: mockFetch,665log: mockLogger666}667);668669// Should still create valid auth metadata670assert.ok(authMetadata.authorizationServer);671assert.ok(authMetadata.serverMetadata);672});673674test('should handle invalid JSON in resource metadata response', async () => {675const mockFetch = sandbox.stub();676677// Mock resource metadata fetch - returns invalid JSON678mockFetch.onCall(0).resolves(createMockResponse({679status: 200,680url: TEST_RESOURCE_METADATA_URL,681body: 'not valid json {'682}));683684// Mock server metadata fetch - also returns invalid JSON685mockFetch.onCall(1).resolves(createMockResponse({686status: 200,687url: 'https://example.com/.well-known/oauth-authorization-server',688body: '{ invalid }'689}));690691const originalResponse = createMockResponse({692status: 401,693url: TEST_MCP_URL,694headers: {}695});696697// Should fall back to default metadata, not throw698const authMetadata = await createAuthMetadata(699TEST_MCP_URL,700originalResponse.headers,701{702sameOriginHeaders: {},703fetch: mockFetch,704log: mockLogger705}706);707708// Should use default metadata709assert.ok(authMetadata.authorizationServer);710assert.ok(authMetadata.serverMetadata);711});712713test('should handle non-401 status codes in update()', async () => {714const { authMetadata } = await createTestAuthMetadata({715scopes: ['read']716});717718// Response with 403 instead of 401719const response = createMockResponse({720status: 403,721headers: {722'WWW-Authenticate': 'Bearer scope="new.scope"'723}724});725726// update() should still process the WWW-Authenticate header regardless of status727const result = authMetadata.update(response.headers);728729// The behavior depends on implementation - either it updates or ignores non-401730// This test documents the actual behavior731assert.strictEqual(typeof result, 'boolean');732});733});734});735736737738