Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 136 additions & 25 deletions src/vs/base/common/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,107 @@ export interface IAuthorizationServerMetadata {
code_challenge_methods_supported?: string[];
}

/**
* Request for the dynamic client registration endpoint as per RFC 7591.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: a URL for the RFC would be nice

*/
export interface IAuthorizationDynamicClientRegistrationRequest {
/**
* OPTIONAL. Array of redirection URI strings for use in redirect-based flows
* such as the authorization code and implicit flows.
*/
redirect_uris?: string[];

/**
* OPTIONAL. String indicator of the requested authentication method for the token endpoint.
* Values: "none", "client_secret_post", "client_secret_basic".
* Default is "client_secret_basic".
*/
token_endpoint_auth_method?: string;

/**
* OPTIONAL. Array of OAuth 2.0 grant type strings that the client can use at the token endpoint.
* Default is ["authorization_code"].
*/
grant_types?: string[];

/**
* OPTIONAL. Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint.
* Default is ["code"].
*/
response_types?: string[];

/**
* OPTIONAL. Human-readable string name of the client to be presented to the end-user during authorization.
*/
client_name?: string;

/**
* OPTIONAL. URL string of a web page providing information about the client.
*/
client_uri?: string;

/**
* OPTIONAL. URL string that references a logo for the client.
*/
logo_uri?: string;

/**
* OPTIONAL. String containing a space-separated list of scope values that the client can use when requesting access tokens.
*/
scope?: string;

/**
* OPTIONAL. Array of strings representing ways to contact people responsible for this client, typically email addresses.
*/
contacts?: string[];

/**
* OPTIONAL. URL string that points to a human-readable terms of service document for the client.
*/
tos_uri?: string;

/**
* OPTIONAL. URL string that points to a human-readable privacy policy document.
*/
policy_uri?: string;

/**
* OPTIONAL. URL string referencing the client's JSON Web Key (JWK) Set document.
*/
jwks_uri?: string;

/**
* OPTIONAL. Client's JSON Web Key Set document value.
*/
jwks?: object;

/**
* OPTIONAL. A unique identifier string assigned by the client developer or software publisher.
*/
software_id?: string;

/**
* OPTIONAL. A version identifier string for the client software.
*/
software_version?: string;

/**
* OPTIONAL. A software statement containing client metadata values about the client software as claims.
*/
software_statement?: string;

/**
* OPTIONAL. Application type. Usually "native" for OAuth clients.
* https://openid.net/specs/openid-connect-registration-1_0.html
*/
application_type?: 'native' | 'web' | string;

/**
* OPTIONAL. Additional metadata fields as defined by extensions.
*/
[key: string]: unknown;
}

/**
* Response from the dynamic client registration endpoint.
*/
Expand Down Expand Up @@ -749,33 +850,35 @@ export async function fetchDynamicRegistration(serverMetadata: IAuthorizationSer
if (!serverMetadata.registration_endpoint) {
throw new Error('Server does not support dynamic registration');
}

const requestBody: IAuthorizationDynamicClientRegistrationRequest = {
client_name: clientName,
client_uri: 'https://code.visualstudio.com',
grant_types: serverMetadata.grant_types_supported
? serverMetadata.grant_types_supported.filter(gt => grantTypesSupported.includes(gt))
: grantTypesSupported,
response_types: ['code'],
redirect_uris: [
'https://insiders.vscode.dev/redirect',
'https://vscode.dev/redirect',
'http://127.0.0.1/',
// Added these for any server that might do
// only exact match on the redirect URI even
// though the spec says it should not care
// about the port.
`http://127.0.0.1:${DEFAULT_AUTH_FLOW_PORT}/`
],
scope: scopes?.join(AUTH_SCOPE_SEPARATOR),
token_endpoint_auth_method: 'none',
application_type: 'native'
};

const response = await fetch(serverMetadata.registration_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_name: clientName,
client_uri: 'https://code.visualstudio.com',
grant_types: serverMetadata.grant_types_supported
? serverMetadata.grant_types_supported.filter(gt => grantTypesSupported.includes(gt))
: grantTypesSupported,
response_types: ['code'],
redirect_uris: [
'https://insiders.vscode.dev/redirect',
'https://vscode.dev/redirect',
'http://127.0.0.1/',
// Added these for any server that might do
// only exact match on the redirect URI even
// though the spec says it should not care
// about the port.
`http://127.0.0.1:${DEFAULT_AUTH_FLOW_PORT}/`
],
scope: scopes?.join(AUTH_SCOPE_SEPARATOR),
token_endpoint_auth_method: 'none',
// https://openid.net/specs/openid-connect-registration-1_0.html
application_type: 'native'
})
body: JSON.stringify(requestBody)
});

if (!response.ok) {
Expand Down Expand Up @@ -936,17 +1039,25 @@ export function getClaimsFromJWT(token: string): IAuthorizationJWTClaims {
* Checks if two scope lists are equivalent, regardless of order.
* This is useful for comparing OAuth scopes where the order should not matter.
*
* @param scopes1 First list of scopes to compare
* @param scopes2 Second list of scopes to compare
* @param scopes1 First list of scopes to compare (can be undefined)
* @param scopes2 Second list of scopes to compare (can be undefined)
* @returns true if the scope lists contain the same scopes (order-independent), false otherwise
*
* @example
* ```typescript
* scopesMatch(['read', 'write'], ['write', 'read']) // Returns: true
* scopesMatch(['read'], ['write']) // Returns: false
* scopesMatch(undefined, undefined) // Returns: true
* scopesMatch(['read'], undefined) // Returns: false
* ```
*/
export function scopesMatch(scopes1: readonly string[], scopes2: readonly string[]): boolean {
export function scopesMatch(scopes1: readonly string[] | undefined, scopes2: readonly string[] | undefined): boolean {
if (scopes1 === scopes2) {
return true;
}
if (!scopes1 || !scopes2) {
return false;
}
if (scopes1.length !== scopes2.length) {
return false;
}
Expand Down
12 changes: 12 additions & 0 deletions src/vs/base/test/common/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,18 @@ suite('OAuth', () => {
const scopes2 = ['scope2', 'scope1', 'scope1'];
assert.strictEqual(scopesMatch(scopes1, scopes2), true);
});

test('scopesMatch should handle undefined values', () => {
assert.strictEqual(scopesMatch(undefined, undefined), true);
assert.strictEqual(scopesMatch(['read'], undefined), false);
assert.strictEqual(scopesMatch(undefined, ['write']), false);
});

test('scopesMatch should handle mixed undefined and empty arrays', () => {
assert.strictEqual(scopesMatch([], undefined), false);
assert.strictEqual(scopesMatch(undefined, []), false);
assert.strictEqual(scopesMatch([], []), true);
});
});

suite('Utility Functions', () => {
Expand Down
5 changes: 2 additions & 3 deletions src/vs/workbench/api/browser/mainThreadMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,13 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape {
this._servers.get(id)?.pushMessage(message);
}

async $getTokenFromServerMetadata(id: number, authServerComponents: UriComponents, serverMetadata: IAuthorizationServerMetadata, resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined, errorOnUserInteraction?: boolean): Promise<string | undefined> {
async $getTokenFromServerMetadata(id: number, authServerComponents: UriComponents, serverMetadata: IAuthorizationServerMetadata, resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined, scopes: string[] | undefined, errorOnUserInteraction?: boolean): Promise<string | undefined> {
const server = this._serverDefinitions.get(id);
if (!server) {
return undefined;
}

const authorizationServer = URI.revive(authServerComponents);
const scopesSupported = resourceMetadata?.scopes_supported || serverMetadata.scopes_supported || [];
const scopesSupported = scopes ?? resourceMetadata?.scopes_supported ?? serverMetadata.scopes_supported ?? [];
Copy link

Copilot AI Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The variable name scopesSupported is misleading since it now represents the actual requested scopes rather than supported scopes. Consider renaming to requestedScopes or scopesToRequest for clarity.

Copilot uses AI. Check for mistakes.
let providerId = await this._authenticationService.getOrActivateProviderIdForServer(authorizationServer);
if (!providerId) {
const provider = await this._authenticationService.createDynamicAuthenticationProvider(authorizationServer, serverMetadata, resourceMetadata);
Expand Down
2 changes: 1 addition & 1 deletion src/vs/workbench/api/common/extHost.protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3041,7 +3041,7 @@ export interface MainThreadMcpShape {
$onDidReceiveMessage(id: number, message: string): void;
$upsertMcpCollection(collection: McpCollectionDefinition.FromExtHost, servers: McpServerDefinition.Serialized[]): void;
$deleteMcpCollection(collectionId: string): void;
$getTokenFromServerMetadata(id: number, authorizationServer: UriComponents, serverMetadata: IAuthorizationServerMetadata, resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined, errorOnUserInteraction?: boolean): Promise<string | undefined>;
$getTokenFromServerMetadata(id: number, authorizationServer: UriComponents, serverMetadata: IAuthorizationServerMetadata, resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined, scopes: string[] | undefined, errorOnUserInteraction?: boolean): Promise<string | undefined>;
}

export interface MainThreadDataChannelsShape extends IDisposable {
Expand Down
71 changes: 52 additions & 19 deletions src/vs/workbench/api/common/extHostMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { DeferredPromise, raceCancellationError, Sequencer, timeout } from '../.
import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';
import { CancellationError } from '../../../base/common/errors.js';
import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { AUTH_SERVER_METADATA_DISCOVERY_PATH, fetchResourceMetadata, getDefaultMetadataForUrl, IAuthorizationProtectedResourceMetadata, IAuthorizationServerMetadata, isAuthorizationServerMetadata, OPENID_CONNECT_DISCOVERY_PATH, parseWWWAuthenticateHeader } from '../../../base/common/oauth.js';
import { AUTH_SCOPE_SEPARATOR, AUTH_SERVER_METADATA_DISCOVERY_PATH, fetchResourceMetadata, getDefaultMetadataForUrl, IAuthorizationProtectedResourceMetadata, IAuthorizationServerMetadata, isAuthorizationServerMetadata, OPENID_CONNECT_DISCOVERY_PATH, parseWWWAuthenticateHeader, scopesMatch } from '../../../base/common/oauth.js';
import { SSEParser } from '../../../base/common/sseParser.js';
import { URI, UriComponents } from '../../../base/common/uri.js';
import { ConfigurationTarget } from '../../../platform/configuration/common/configuration.js';
Expand Down Expand Up @@ -215,6 +215,7 @@ export class McpHTTPHandle extends Disposable {
authorizationServer: URI;
serverMetadata: IAuthorizationServerMetadata;
resourceMetadata?: IAuthorizationProtectedResourceMetadata;
scopes?: string[];
};

constructor(
Expand Down Expand Up @@ -337,22 +338,11 @@ export class McpHTTPHandle extends Disposable {
private async _populateAuthMetadata(mcpUrl: string, originalResponse: CommonResponse): Promise<void> {
// If there is a resource_metadata challenge, use that to get the oauth server. This is done in 2 steps.
// First, extract the resource_metada challenge from the WWW-Authenticate header (if available)
let resourceMetadataChallenge: string | undefined;
if (originalResponse.headers.has('WWW-Authenticate')) {
const authHeader = originalResponse.headers.get('WWW-Authenticate')!;
const challenges = parseWWWAuthenticateHeader(authHeader);
for (const challenge of challenges) {
if (challenge.scheme === 'Bearer' && challenge.params['resource_metadata']) {
this._log(LogLevel.Debug, `Found resource_metadata challenge in WWW-Authenticate header: ${challenge.params['resource_metadata']}`);
resourceMetadataChallenge = challenge.params['resource_metadata'];
break;
}
}
}
const { resourceMetadataChallenge, scopesChallenge: scopesChallengeFromHeader } = this._parseWWWAuthenticateHeader(originalResponse);
// Second, fetch the resource metadata either from the challenge URL or from well-known URIs
let serverMetadataUrl: string | undefined;
let scopesSupported: string[] | undefined;
let resource: IAuthorizationProtectedResourceMetadata | undefined;
let scopesChallenge = scopesChallengeFromHeader;
try {
const resourceMetadata = await fetchResourceMetadata(mcpUrl, resourceMetadataChallenge, {
sameOriginHeaders: {
Expand All @@ -365,7 +355,7 @@ export class McpHTTPHandle extends Disposable {
// Consider using one that has an auth provider first, over the dynamic flow
serverMetadataUrl = resourceMetadata.authorization_servers?.[0];
this._log(LogLevel.Debug, `Using auth server metadata url: ${serverMetadataUrl}`);
scopesSupported = resourceMetadata.scopes_supported;
scopesChallenge ??= resourceMetadata.scopes_supported;
resource = resourceMetadata;
} catch (e) {
this._log(LogLevel.Debug, `Could not fetch resource metadata: ${String(e)}`);
Expand All @@ -389,7 +379,8 @@ export class McpHTTPHandle extends Disposable {
this._authMetadata = {
authorizationServer: URI.parse(serverMetadataUrl),
serverMetadata: serverMetadataResponse,
resourceMetadata: resource
resourceMetadata: resource,
scopes: scopesChallenge
};
return;
} catch (e) {
Expand All @@ -398,11 +389,11 @@ export class McpHTTPHandle extends Disposable {

// If there's no well-known server metadata, then use the default values based off of the url.
const defaultMetadata = getDefaultMetadataForUrl(new URL(baseUrl));
defaultMetadata.scopes_supported = scopesSupported ?? defaultMetadata.scopes_supported ?? [];
this._authMetadata = {
authorizationServer: URI.parse(baseUrl),
serverMetadata: defaultMetadata,
resourceMetadata: resource
resourceMetadata: resource,
scopes: scopesChallenge
};
this._log(LogLevel.Info, 'Using default auth metadata');
}
Expand Down Expand Up @@ -663,7 +654,7 @@ export class McpHTTPHandle extends Disposable {
private async _addAuthHeader(headers: Record<string, string>) {
if (this._authMetadata) {
try {
const token = await this._proxy.$getTokenFromServerMetadata(this._id, this._authMetadata.authorizationServer, this._authMetadata.serverMetadata, this._authMetadata.resourceMetadata, this._errorOnUserInteraction);
const token = await this._proxy.$getTokenFromServerMetadata(this._id, this._authMetadata.authorizationServer, this._authMetadata.serverMetadata, this._authMetadata.resourceMetadata, this._authMetadata.scopes, this._errorOnUserInteraction);
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
Expand All @@ -684,6 +675,34 @@ export class McpHTTPHandle extends Disposable {
}
}

private _parseWWWAuthenticateHeader(response: CommonResponse): { resourceMetadataChallenge: string | undefined; scopesChallenge: string[] | undefined } {
let resourceMetadataChallenge: string | undefined;
let scopesChallenge: string[] | undefined;
if (response.headers.has('WWW-Authenticate')) {
const authHeader = response.headers.get('WWW-Authenticate')!;
const challenges = parseWWWAuthenticateHeader(authHeader);
for (const challenge of challenges) {
if (challenge.scheme === 'Bearer') {
if (!resourceMetadataChallenge && challenge.params['resource_metadata']) {
resourceMetadataChallenge = challenge.params['resource_metadata'];
this._log(LogLevel.Debug, `Found resource_metadata challenge in WWW-Authenticate header: ${resourceMetadataChallenge}`);
}
if (!scopesChallenge && challenge.params['scope']) {
const scopes = challenge.params['scope'].split(AUTH_SCOPE_SEPARATOR);
if (scopes.length) {
this._log(LogLevel.Debug, `Found scope challenge in WWW-Authenticate header: ${challenge.params['scope']}`);
scopesChallenge = scopes;
Comment on lines +692 to +694
Copy link

Copilot AI Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check if (scopes.length) will always be true since split() returns at least one element (even for empty strings). Consider checking for meaningful content: if (scopes.length > 0 && scopes[0].trim()) or filter out empty strings after splitting.

Suggested change
if (scopes.length) {
this._log(LogLevel.Debug, `Found scope challenge in WWW-Authenticate header: ${challenge.params['scope']}`);
scopesChallenge = scopes;
const filteredScopes = scopes.filter(s => s.trim());
if (filteredScopes.length) {
this._log(LogLevel.Debug, `Found scope challenge in WWW-Authenticate header: ${challenge.params['scope']}`);
scopesChallenge = filteredScopes;

Copilot uses AI. Check for mistakes.
}
}
if (resourceMetadataChallenge && scopesChallenge) {
break;
}
}
}
}
return { resourceMetadataChallenge, scopesChallenge };
}

private async _getErrText(res: CommonResponse) {
try {
return await res.text();
Expand All @@ -696,6 +715,7 @@ export class McpHTTPHandle extends Disposable {
* Helper method to perform fetch with 401 authentication retry logic.
* If the initial request returns 401 and we don't have auth metadata,
* it will populate the auth metadata and retry once.
* If we already have auth metadata, check if the scopes changed and update them.
*/
private async _fetchWithAuthRetry(mcpUrl: string, init: MinimalRequestInit, headers: Record<string, string>): Promise<CommonResponse> {
const doFetch = () => this._fetch(mcpUrl, init);
Expand All @@ -710,6 +730,19 @@ export class McpHTTPHandle extends Disposable {
init.headers = headers;
res = await doFetch();
}
} else {
// We have auth metadata, but got a 401. Check if the scopes changed.
const { scopesChallenge } = this._parseWWWAuthenticateHeader(res);
if (!scopesMatch(scopesChallenge, this._authMetadata.scopes)) {
this._log(LogLevel.Debug, `Scopes changed from ${JSON.stringify(this._authMetadata.scopes)} to ${JSON.stringify(scopesChallenge)}, updating and retrying`);
this._authMetadata.scopes = scopesChallenge;
await this._addAuthHeader(headers);
if (headers['Authorization']) {
// Update the headers in the init object
init.headers = headers;
res = await doFetch();
}
}
}
}
return res;
Expand Down
Loading