diff --git a/src/vs/base/common/oauth.ts b/src/vs/base/common/oauth.ts index 264f8764b18f6..8f4d0f826d934 100644 --- a/src/vs/base/common/oauth.ts +++ b/src/vs/base/common/oauth.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { decodeBase64 } from './buffer.js'; + const WELL_KNOWN = '.well-known'; export const AUTH_PROTECTED_RESOURCE_METADATA_DISCOVERY_PATH = `${WELL_KNOWN}/oauth-protected-resource`; export const AUTH_SERVER_METADATA_DISCOVERY_PATH = `${WELL_KNOWN}/oauth-authorization-server`; @@ -195,8 +197,292 @@ export interface IRequiredAuthorizationServerMetadata extends IAuthorizationServ registration_endpoint: string; } +/** + * Response from the dynamic client registration endpoint. + */ +export interface IAuthorizationDynamicClientRegistrationResponse { + /** + * REQUIRED. The client identifier issued by the authorization server. + */ + client_id: string; + + /** + * OPTIONAL. The client secret issued by the authorization server. + * Not returned for public clients. + */ + client_secret?: string; + + /** + * OPTIONAL. Time at which the client secret will expire in seconds since the Unix Epoch. + */ + client_secret_expires_at?: number; + + /** + * REQUIRED. Client name as provided during registration. + */ + client_name: string; + + /** + * OPTIONAL. Client URI as provided during registration. + */ + client_uri?: string; + + /** + * OPTIONAL. Array of redirection URIs as provided during registration. + */ + redirect_uris?: string[]; + + /** + * OPTIONAL. Array of grant types allowed for the client. + */ + grant_types?: string[]; + + /** + * OPTIONAL. Array of response types allowed for the client. + */ + response_types?: string[]; + + /** + * OPTIONAL. Type of authentication method used by the client. + */ + token_endpoint_auth_method?: string; +} + +/** + * Response from the authorization endpoint. + * Typically returned as query parameters in a redirect. + */ +export interface IAuthorizationAuthorizeResponse { + /** + * REQUIRED. The authorization code generated by the authorization server. + */ + code: string; + + /** + * REQUIRED. The state value that was sent in the authorization request. + * Used to prevent CSRF attacks. + */ + state: string; +} + +/** + * Error response from the authorization endpoint. + */ +export interface IAuthorizationAuthorizeErrorResponse { + /** + * REQUIRED. Error code as specified in OAuth 2.0. + */ + error: string; + + /** + * OPTIONAL. Human-readable description of the error. + */ + error_description?: string; + + /** + * OPTIONAL. URI to a human-readable web page with more information about the error. + */ + error_uri?: string; + + /** + * REQUIRED. The state value that was sent in the authorization request. + */ + state: string; +} + +/** + * Response from the token endpoint. + */ +export interface IAuthorizationTokenResponse { + /** + * REQUIRED. The access token issued by the authorization server. + */ + access_token: string; + + /** + * REQUIRED. The type of the token issued. Usually "Bearer". + */ + token_type: string; + + /** + * RECOMMENDED. The lifetime in seconds of the access token. + */ + expires_in?: number; + + /** + * OPTIONAL. The refresh token, which can be used to obtain new access tokens. + */ + refresh_token?: string; + + /** + * OPTIONAL. The scope of the access token as a space-delimited list of strings. + */ + scope?: string; + + /** + * OPTIONAL. ID Token value associated with the authenticated session for OpenID Connect flows. + */ + id_token?: string; +} + +/** + * Error response from the token endpoint. + */ +export interface IAuthorizationTokenErrorResponse { + /** + * REQUIRED. Error code as specified in OAuth 2.0. + */ + error: string; + + /** + * OPTIONAL. Human-readable description of the error. + */ + error_description?: string; + + /** + * OPTIONAL. URI to a human-readable web page with more information about the error. + */ + error_uri?: string; +} + +export interface IAuthorizationJWTClaims { + /** + * REQUIRED. JWT ID. Unique identifier for the token. + */ + jti: string; + + /** + * REQUIRED. Subject. Principal about which the token asserts information. + */ + sub: string; + + /** + * REQUIRED. Issuer. Entity that issued the token. + */ + iss: string; + + /** + * OPTIONAL. Audience. Recipients that the token is intended for. + */ + aud?: string | string[]; + + /** + * OPTIONAL. Expiration time. Time after which the token is invalid (seconds since Unix epoch). + */ + exp?: number; + + /** + * OPTIONAL. Not before time. Time before which the token is not valid (seconds since Unix epoch). + */ + nbf?: number; + + /** + * OPTIONAL. Issued at time when the token was issued (seconds since Unix epoch). + */ + iat?: number; + + /** + * OPTIONAL. Authorized party. The party to which the token was issued. + */ + azp?: string; + + /** + * OPTIONAL. Scope values for which the token is valid. + */ + scope?: string; + + /** + * OPTIONAL. Full name of the user. + */ + name?: string; + + /** + * OPTIONAL. Given or first name of the user. + */ + given_name?: string; + + /** + * OPTIONAL. Family name or last name of the user. + */ + family_name?: string; + + /** + * OPTIONAL. Middle name of the user. + */ + middle_name?: string; + + /** + * OPTIONAL. Preferred username or email the user wishes to be referred to. + */ + preferred_username?: string; + + /** + * OPTIONAL. Email address of the user. + */ + email?: string; + + /** + * OPTIONAL. True if the user's email has been verified. + */ + email_verified?: boolean; + + /** + * OPTIONAL. User's profile picture URL. + */ + picture?: string; + + /** + * OPTIONAL. Authentication time. Time when the user authentication occurred. + */ + auth_time?: number; + + /** + * OPTIONAL. Authentication context class reference. + */ + acr?: string; + + /** + * OPTIONAL. Authentication methods references. + */ + amr?: string[]; + + /** + * OPTIONAL. Session ID. String identifier for a session. + */ + sid?: string; + + /** + * OPTIONAL. Address component. + */ + address?: { + formatted?: string; + street_address?: string; + locality?: string; + region?: string; + postal_code?: string; + country?: string; + }; + + /** + * OPTIONAL. Groups that the user belongs to. + */ + groups?: string[]; + + /** + * OPTIONAL. Roles assigned to the user. + */ + roles?: string[]; + + /** + * OPTIONAL. Handles optional claims that are not explicitly defined in the standard. + */ + [key: string]: unknown; +} + //#endregion +//#region is functions + export function isAuthorizationProtectedResourceMetadata(obj: unknown): obj is IAuthorizationProtectedResourceMetadata { if (typeof obj !== 'object' || obj === null) { return false; @@ -214,6 +500,40 @@ export function isAuthorizationServerMetadata(obj: unknown): obj is IAuthorizati return metadata.issuer !== undefined; } +export function isAuthorizationDynamicClientRegistrationResponse(obj: unknown): obj is IAuthorizationDynamicClientRegistrationResponse { + if (typeof obj !== 'object' || obj === null) { + return false; + } + const response = obj as IAuthorizationDynamicClientRegistrationResponse; + return response.client_id !== undefined && response.client_name !== undefined; +} + +export function isAuthorizationAuthorizeResponse(obj: unknown): obj is IAuthorizationAuthorizeResponse { + if (typeof obj !== 'object' || obj === null) { + return false; + } + const response = obj as IAuthorizationAuthorizeResponse; + return response.code !== undefined && response.state !== undefined; +} + +export function isAuthorizationTokenResponse(obj: unknown): obj is IAuthorizationTokenResponse { + if (typeof obj !== 'object' || obj === null) { + return false; + } + const response = obj as IAuthorizationTokenResponse; + return response.access_token !== undefined && response.token_type !== undefined; +} + +export function isDynamicClientRegistrationResponse(obj: unknown): obj is IAuthorizationDynamicClientRegistrationResponse { + if (typeof obj !== 'object' || obj === null) { + return false; + } + const response = obj as IAuthorizationDynamicClientRegistrationResponse; + return response.client_id !== undefined && response.client_name !== undefined; +} + +//#endregion + export function getDefaultMetadataForUrl(issuer: URL): IRequiredAuthorizationServerMetadata & IRequiredAuthorizationServerMetadata { return { issuer: issuer.toString(), @@ -236,6 +556,38 @@ export function getMetadataWithDefaultValues(metadata: IAuthorizationServerMetad }; } +export async function fetchDynamicRegistration(registrationEndpoint: string, clientName: string, additionalRedirectUris: string[] = []): Promise { + const response = await fetch(registrationEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + client_name: clientName, + client_uri: 'https://code.visualstudio.com', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + redirect_uris: [ + 'https://insiders.vscode.dev/redirect', + 'https://vscode.dev/redirect', + ...additionalRedirectUris + ], + token_endpoint_auth_method: 'none' + }) + }); + + if (!response.ok) { + throw new Error(`Registration failed: ${response.statusText}`); + } + + const registration = await response.json(); + if (isAuthorizationDynamicClientRegistrationResponse(registration)) { + return registration; + } + throw new Error(`Invalid authorization dynamic client registration response: ${JSON.stringify(registration)}`); +} + + export function parseWWWAuthenticateHeader(wwwAuthenticateHeaderValue: string) { const parts = wwwAuthenticateHeaderValue.split(' '); const scheme = parts[0]; @@ -251,3 +603,31 @@ export function parseWWWAuthenticateHeader(wwwAuthenticateHeaderValue: string) { return { scheme, params }; } + +export function getClaimsFromJWT(token: string): IAuthorizationJWTClaims { + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT token format: token must have three parts separated by dots'); + } + + const [header, payload, _signature] = parts; + + try { + const decodedHeader = JSON.parse(decodeBase64(header).toString()); + if (typeof decodedHeader !== 'object') { + throw new Error('Invalid JWT token format: header is not a JSON object'); + } + + const decodedPayload = JSON.parse(decodeBase64(payload).toString()); + if (typeof decodedPayload !== 'object') { + throw new Error('Invalid JWT token format: payload is not a JSON object'); + } + + return decodedPayload; + } catch (e) { + if (e instanceof Error) { + throw new Error(`Failed to parse JWT token: ${e.message}`); + } + throw new Error('Failed to parse JWT token'); + } +} diff --git a/src/vs/workbench/api/browser/mainThreadAuthentication.ts b/src/vs/workbench/api/browser/mainThreadAuthentication.ts index db00ad2aa6078..549d0aaf596cf 100644 --- a/src/vs/workbench/api/browser/mainThreadAuthentication.ts +++ b/src/vs/workbench/api/browser/mainThreadAuthentication.ts @@ -21,6 +21,12 @@ import { URI, UriComponents } from '../../../base/common/uri.js'; import { IOpenerService } from '../../../platform/opener/common/opener.js'; import { CancellationError } from '../../../base/common/errors.js'; import { ILogService } from '../../../platform/log/common/log.js'; +import { ExtensionHostKind } from '../../services/extensions/common/extensionHostKind.js'; +import { IURLService } from '../../../platform/url/common/url.js'; +import { DeferredPromise, Queue, raceTimeout } from '../../../base/common/async.js'; +import { ISecretStorageService } from '../../../platform/secrets/common/secrets.js'; +import { IAuthorizationTokenResponse, isAuthorizationTokenResponse } from '../../../base/common/oauth.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js'; export interface AuthenticationInteractiveOptions { detail?: string; @@ -86,7 +92,10 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu @IExtensionService private readonly extensionService: IExtensionService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IOpenerService private readonly openerService: IOpenerService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @IURLService private readonly urlService: IURLService, + @ISecretStorageService private readonly secretStorageService: ISecretStorageService, + @IStorageService private readonly storageService: IStorageService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAuthentication); @@ -98,6 +107,33 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu const providerInfo = this.authenticationService.getProvider(e.providerId); this._proxy.$onDidChangeAuthenticationSessions(providerInfo.id, providerInfo.label, e.extensionIds); })); + this._register(authenticationService.registerAuthenticationProviderHostDelegate({ + // Prefer Node.js extension hosts when they're available. No CORS issues etc. + priority: extHostContext.extensionHostKind === ExtensionHostKind.LocalWebWorker ? 0 : 1, + create: async (serverMetadata) => { + const clientId = storageService.get(`dynamicAuthClientId/${serverMetadata.issuer}`, StorageScope.APPLICATION, undefined); + let initialTokens: (IAuthorizationTokenResponse & { created_at: number })[] | undefined = undefined; + if (clientId) { + initialTokens = await this._getSessionsForDynamicAuthProvider(serverMetadata.issuer, clientId); + } + return this._proxy.$registerDynamicAuthProvider(serverMetadata, clientId, initialTokens); + } + })); + const queue = new Queue(); + this._register(this.secretStorageService.onDidChangeSecret(async key => { + let payload: { isDynamicAuthProvider: boolean; authProviderId: string; clientId: string } | undefined; + try { + payload = JSON.parse(key); + } catch (error) { + // Ignore errors... must not be a dynamic auth provider + } + if (payload?.isDynamicAuthProvider) { + void queue.queue(async () => { + const tokens = await this._getSessionsForDynamicAuthProvider(payload.authProviderId, payload.clientId); + this._proxy.$onDidChangeDynamicAuthProviderTokens(payload.authProviderId, payload.clientId, tokens); + }); + } + })); } async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean, supportedIssuers: UriComponents[] = []): Promise { @@ -118,7 +154,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu this.authenticationService.registerAuthenticationProvider(id, provider); } - $unregisterAuthenticationProvider(id: string): void { + async $unregisterAuthenticationProvider(id: string): Promise { this._registrations.deleteAndDispose(id); this.authenticationService.unregisterAuthenticationProvider(id); } @@ -129,7 +165,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu } } - $sendDidChangeSessions(providerId: string, event: AuthenticationSessionsChangeEvent): void { + async $sendDidChangeSessions(providerId: string, event: AuthenticationSessionsChangeEvent): Promise { const obj = this._registrations.get(providerId); if (obj instanceof Emitter) { obj.fire(event); @@ -139,6 +175,53 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu $removeSession(providerId: string, sessionId: string): Promise { return this.authenticationService.removeSession(providerId, sessionId); } + + async $waitForUriHandler(expectedUri: UriComponents): Promise { + const deferredPromise = new DeferredPromise(); + const disposable = this.urlService.registerHandler({ + handleURL: async (uri: URI) => { + if (uri.scheme !== expectedUri.scheme || uri.authority !== expectedUri.authority || uri.path !== expectedUri.path) { + return false; + } + deferredPromise.complete(uri); + disposable.dispose(); + return true; + } + }); + const result = await raceTimeout(deferredPromise.p, 5 * 60 * 1000); // 5 minutes + if (!result) { + throw new Error('Timed out waiting for URI handler'); + } + return await deferredPromise.p; + } + + async $registerDynamicAuthenticationProvider(id: string, label: string, issuer: UriComponents, clientId: string): Promise { + await this.$registerAuthenticationProvider(id, label, false, [issuer]); + this.storageService.store(`dynamicAuthClientId/${id}`, clientId, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + private async _getSessionsForDynamicAuthProvider(authProviderId: string, clientId: string): Promise<(IAuthorizationTokenResponse & { created_at: number })[] | undefined> { + const key = JSON.stringify({ isDynamicAuthProvider: true, authProviderId, clientId }); + const value = await this.secretStorageService.get(key); + if (value) { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed) || !parsed.every((t) => typeof t.created_at === 'number' && isAuthorizationTokenResponse(t))) { + this.logService.error(`Invalid session data for ${authProviderId} (${clientId}) in secret storage:`, parsed); + this.secretStorageService.delete(key); + return undefined; + } + return parsed; + } + return undefined; + } + + async $setSessionsForDynamicAuthProvider(authProviderId: string, clientId: string, sessions: (IAuthorizationTokenResponse & { created_at: number })[]): Promise { + const key = JSON.stringify({ isDynamicAuthProvider: true, authProviderId, clientId }); + const value = JSON.stringify(sessions); + await this.secretStorageService.set(key, value); + this.logService.trace(`Set session data for ${authProviderId} (${clientId}) in secret storage:`, sessions); + } + private async loginPrompt(provider: IAuthenticationProvider, extensionName: string, recreatingSession: boolean, options?: AuthenticationInteractiveOptions): Promise { let message: string; diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index 54451480d761b..8c078b3904ccf 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -24,6 +24,7 @@ import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; import { ExtHostContext, ExtHostMcpShape, MainContext, MainThreadMcpShape } from '../common/extHost.protocol.js'; import { CancellationError } from '../../../base/common/errors.js'; +import { IAuthorizationServerMetadata } from '../../../base/common/oauth.js'; @extHostNamedCustomer(MainContext.MainThreadMcp) export class MainThreadMcp extends Disposable implements MainThreadMcpShape { @@ -137,17 +138,22 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { this._servers.get(id)?.pushMessage(message); } - async $getTokenFromServerMetadata(id: number, metadata: { issuer: string; authorizationEndpoint: string; tokenEndpoint: string; registrationEndpoint: string; scopesSupported: string[] }): Promise { + async $getTokenFromServerMetadata(id: number, metadata: IAuthorizationServerMetadata): Promise { const server = this._serverDefinitions.get(id); if (!server) { return undefined; } const issuer = URI.parse(metadata.issuer); - const scopesSupported = metadata.scopesSupported; - const providerId = await this._authenticationService.getOrActivateProviderIdForIssuer(issuer); + // Some better default? + const scopesSupported = metadata.scopes_supported || []; + let providerId = await this._authenticationService.getOrActivateProviderIdForIssuer(issuer); if (!providerId) { - return undefined; + const provider = await this._authenticationService.createDynamicAuthenticationProvider(metadata); + if (!provider) { + return undefined; + } + providerId = provider.id; } const sessions = await this._authenticationService.getSessions(providerId, scopesSupported, undefined, true, issuer); const accountNamePreference = this.authenticationMcpServersService.getAccountPreference(server.id, providerId); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 9c65df21dca3c..a22a562ec658d 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -104,7 +104,7 @@ import * as typeConverters from './extHostTypeConverters.js'; import * as extHostTypes from './extHostTypes.js'; import { ExtHostUriOpeners } from './extHostUriOpener.js'; import { IURITransformerService } from './extHostUriTransformerService.js'; -import { ExtHostUrls } from './extHostUrls.js'; +import { IExtHostUrlsService } from './extHostUrls.js'; import { ExtHostWebviews } from './extHostWebview.js'; import { ExtHostWebviewPanels } from './extHostWebviewPanels.js'; import { ExtHostWebviewViews } from './extHostWebviewView.js'; @@ -143,6 +143,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostTunnelService = accessor.get(IExtHostTunnelService); const extHostApiDeprecation = accessor.get(IExtHostApiDeprecationService); const extHostWindow = accessor.get(IExtHostWindow); + const extHostUrls = accessor.get(IExtHostUrlsService); const extHostSecretState = accessor.get(IExtHostSecretState); const extHostEditorTabs = accessor.get(IExtHostEditorTabs); const extHostManagedSockets = accessor.get(IExtHostManagedSockets); @@ -159,6 +160,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I rpcProtocol.set(ExtHostContext.ExtHostStorage, extHostStorage); rpcProtocol.set(ExtHostContext.ExtHostTunnelService, extHostTunnelService); rpcProtocol.set(ExtHostContext.ExtHostWindow, extHostWindow); + rpcProtocol.set(ExtHostContext.ExtHostUrls, extHostUrls); rpcProtocol.set(ExtHostContext.ExtHostSecretState, extHostSecretState); rpcProtocol.set(ExtHostContext.ExtHostTelemetry, extHostTelemetry); rpcProtocol.set(ExtHostContext.ExtHostEditorTabs, extHostEditorTabs); @@ -179,7 +181,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostLocalization = rpcProtocol.set(ExtHostContext.ExtHostLocalization, accessor.get(IExtHostLocalizationService)); // manually create and register addressable instances - const extHostUrls = rpcProtocol.set(ExtHostContext.ExtHostUrls, new ExtHostUrls(rpcProtocol)); const extHostDocuments = rpcProtocol.set(ExtHostContext.ExtHostDocuments, new ExtHostDocuments(rpcProtocol, extHostDocumentsAndEditors)); const extHostDocumentContentProviders = rpcProtocol.set(ExtHostContext.ExtHostDocumentContentProviders, new ExtHostDocumentContentProvider(rpcProtocol, extHostDocumentsAndEditors, extHostLogService)); const extHostDocumentSaveParticipant = rpcProtocol.set(ExtHostContext.ExtHostDocumentSaveParticipant, new ExtHostDocumentSaveParticipant(extHostLogService, extHostDocuments, rpcProtocol.getProxy(MainContext.MainThreadBulkEdits))); diff --git a/src/vs/workbench/api/common/extHost.common.services.ts b/src/vs/workbench/api/common/extHost.common.services.ts index 5551bfa745225..f21a2916fc050 100644 --- a/src/vs/workbench/api/common/extHost.common.services.ts +++ b/src/vs/workbench/api/common/extHost.common.services.ts @@ -32,6 +32,7 @@ import { ExtHostLanguageModels, IExtHostLanguageModels } from './extHostLanguage import { IExtHostTerminalShellIntegration, ExtHostTerminalShellIntegration } from './extHostTerminalShellIntegration.js'; import { ExtHostTesting, IExtHostTesting } from './extHostTesting.js'; import { ExtHostMcpService, IExtHostMpcService } from './extHostMcp.js'; +import { ExtHostUrls, IExtHostUrlsService } from './extHostUrls.js'; registerSingleton(IExtHostLocalizationService, ExtHostLocalizationService, InstantiationType.Delayed); registerSingleton(ILoggerService, ExtHostLoggerService, InstantiationType.Delayed); @@ -55,6 +56,7 @@ registerSingleton(IExtHostTerminalService, WorkerExtHostTerminalService, Instant registerSingleton(IExtHostTerminalShellIntegration, ExtHostTerminalShellIntegration, InstantiationType.Eager); registerSingleton(IExtHostTunnelService, ExtHostTunnelService, InstantiationType.Eager); registerSingleton(IExtHostWindow, ExtHostWindow, InstantiationType.Eager); +registerSingleton(IExtHostUrlsService, ExtHostUrls, InstantiationType.Eager); registerSingleton(IExtHostWorkspace, ExtHostWorkspace, InstantiationType.Eager); registerSingleton(IExtHostSecretState, ExtHostSecretState, InstantiationType.Eager); registerSingleton(IExtHostEditorTabs, ExtHostEditorTabs, InstantiationType.Eager); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index a58d81b385954..c76e75e0d8064 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -11,6 +11,7 @@ import { IRelativePattern } from '../../../base/common/glob.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; import { IJSONSchema } from '../../../base/common/jsonSchema.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; +import { IAuthorizationServerMetadata, IAuthorizationTokenResponse } from '../../../base/common/oauth.js'; import * as performance from '../../../base/common/performance.js'; import Severity from '../../../base/common/severity.js'; import { ThemeColor, ThemeIcon } from '../../../base/common/themables.js'; @@ -181,13 +182,16 @@ export interface AuthenticationGetSessionOptions { } export interface MainThreadAuthenticationShape extends IDisposable { - $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean, supportedIssuers?: UriComponents[]): void; - $unregisterAuthenticationProvider(id: string): void; + $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean, supportedIssuers?: UriComponents[]): Promise; + $unregisterAuthenticationProvider(id: string): Promise; $ensureProvider(id: string): Promise; - $sendDidChangeSessions(providerId: string, event: AuthenticationSessionsChangeEvent): void; + $sendDidChangeSessions(providerId: string, event: AuthenticationSessionsChangeEvent): Promise; $getSession(providerId: string, scopes: readonly string[], extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise; $getAccounts(providerId: string): Promise>; $removeSession(providerId: string, sessionId: string): Promise; + $waitForUriHandler(expectedUri: UriComponents): Promise; + $registerDynamicAuthenticationProvider(id: string, label: string, issuer: UriComponents, clientId: string): Promise; + $setSessionsForDynamicAuthProvider(authProviderId: string, clientId: string, sessions: (IAuthorizationTokenResponse & { created_at: number })[]): Promise; } export interface MainThreadSecretStateShape extends IDisposable { @@ -1983,6 +1987,8 @@ export interface ExtHostAuthenticationShape { $createSession(id: string, scopes: string[], options: IAuthenticationCreateSessionOptions): Promise; $removeSession(id: string, sessionId: string): Promise; $onDidChangeAuthenticationSessions(id: string, label: string, extensionIdFilter?: string[]): Promise; + $registerDynamicAuthProvider(serverMetadata: IAuthorizationServerMetadata, clientId?: string, initialTokens?: (IAuthorizationTokenResponse & { created_at: number })[]): Promise; + $onDidChangeDynamicAuthProviderTokens(authProviderId: string, clientId: string, tokens?: (IAuthorizationTokenResponse & { created_at: number })[]): Promise; } export interface ExtHostAiRelatedInformationShape { @@ -3010,7 +3016,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, metadata: { issuer: string; authorizationEndpoint: string; tokenEndpoint: string; registrationEndpoint: string; scopesSupported: string[] }): Promise; + $getTokenFromServerMetadata(id: number, metadata: IAuthorizationServerMetadata): Promise; } export interface ExtHostLocalizationShape { diff --git a/src/vs/workbench/api/common/extHostAuthentication.ts b/src/vs/workbench/api/common/extHostAuthentication.ts index ee09391811b0a..83b6309fff0ed 100644 --- a/src/vs/workbench/api/common/extHostAuthentication.ts +++ b/src/vs/workbench/api/common/extHostAuthentication.ts @@ -12,6 +12,15 @@ import { INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/com import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { IExtHostRpcService } from './extHostRpcService.js'; import { URI } from '../../../base/common/uri.js'; +import { fetchDynamicRegistration, getClaimsFromJWT, IAuthorizationJWTClaims, IAuthorizationServerMetadata, IAuthorizationTokenResponse, isAuthorizationTokenResponse } from '../../../base/common/oauth.js'; +import { IExtHostWindow } from './extHostWindow.js'; +import { IExtHostInitDataService } from './extHostInitDataService.js'; +import { ILogger, ILoggerService } from '../../../platform/log/common/log.js'; +import { derived, IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; +import { stringHash } from '../../../base/common/hash.js'; +import { DisposableStore, isDisposable } from '../../../base/common/lifecycle.js'; +import { IExtHostUrlsService } from './extHostUrls.js'; +import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; export interface IExtHostAuthentication extends ExtHostAuthentication { } export const IExtHostAuthentication = createDecorator('IExtHostAuthentication'); @@ -19,6 +28,7 @@ export const IExtHostAuthentication = createDecorator('I interface ProviderWithMetadata { label: string; provider: vscode.AuthenticationProvider; + disposable?: vscode.Disposable; options: vscode.AuthenticationProviderOptions; } @@ -32,8 +42,14 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { private _onDidChangeSessions = new Emitter(); private _getSessionTaskSingler = new TaskSingler(); + private _onDidDynamicAuthProviderTokensChange = new Emitter<{ authProviderId: string; clientId: string; tokens: IAuthorizationToken[] }>(); + constructor( - @IExtHostRpcService extHostRpc: IExtHostRpcService + @IExtHostRpcService extHostRpc: IExtHostRpcService, + @IExtHostInitDataService private readonly _initData: IExtHostInitDataService, + @IExtHostWindow private readonly _extHostWindow: IExtHostWindow, + @IExtHostUrlsService private readonly _extHostUrls: IExtHostUrlsService, + @ILoggerService private readonly _extHostLoggerService: ILoggerService, ) { this._proxy = extHostRpc.getProxy(MainContext.MainThreadAuthentication); } @@ -95,6 +111,9 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { listener.dispose(); this._authenticationProviders.delete(id); this._proxy.$unregisterAuthenticationProvider(id); + if (isDisposable(provider)) { + provider.dispose(); + } }); } @@ -134,6 +153,35 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { } return Promise.resolve(); } + + async $registerDynamicAuthProvider(serverMetadata: IAuthorizationServerMetadata, clientId?: string, initialTokens?: IAuthorizationToken[]): Promise { + const issuerUri = URI.parse(serverMetadata.issuer); + const provider = await DynamicAuthProvider.create( + this._extHostWindow, + this._extHostUrls, + this._initData, + this._proxy, + this._extHostLoggerService.createLogger(serverMetadata.issuer, { name: issuerUri.authority }), + serverMetadata, + this._onDidDynamicAuthProviderTokensChange, + { clientId, initialTokens } + ); + const disposable = provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(serverMetadata.issuer, e)); + this._authenticationProviders.set( + serverMetadata.issuer, + { + label: issuerUri.authority, + provider, + disposable: Disposable.from(provider, disposable), + options: { supportsMultipleAccounts: false } + } + ); + await this._proxy.$registerDynamicAuthenticationProvider(serverMetadata.issuer, issuerUri.authority, issuerUri, provider.clientId); + } + + async $onDidChangeDynamicAuthProviderTokens(authProviderId: string, clientId: string, tokens: IAuthorizationToken[]): Promise { + this._onDidDynamicAuthProviderTokensChange.fire({ authProviderId, clientId, tokens }); + } } class TaskSingler { @@ -150,3 +198,448 @@ class TaskSingler { return promise; } } + +export class DynamicAuthProvider implements vscode.AuthenticationProvider { + private _onDidChangeSessions = new Emitter(); + readonly onDidChangeSessions = this._onDidChangeSessions.event; + + private readonly _tokenStore: TokenStore; + + private readonly _createFlows: Array<(scopes: string[]) => Promise>; + + private readonly _disposable: DisposableStore; + + constructor( + @IExtHostWindow private readonly _extHostWindow: IExtHostWindow, + @IExtHostUrlsService private readonly _extHostUrls: IExtHostUrlsService, + @IExtHostInitDataService private readonly _initData: IExtHostInitDataService, + private readonly _proxy: MainThreadAuthenticationShape, + private readonly _logger: ILogger, + private readonly _serverMetadata: IAuthorizationServerMetadata, + readonly clientId: string, + scopedEvent: Event, + initialTokens: IAuthorizationToken[], + ) { + this._disposable = new DisposableStore(); + this._disposable.add(this._onDidChangeSessions); + this._tokenStore = this._disposable.add(new TokenStore( + { + onDidChange: scopedEvent, + set: (tokens) => _proxy.$setSessionsForDynamicAuthProvider(this._serverMetadata.issuer, this.clientId, tokens), + }, + initialTokens + )); + // Will be extended later to support other flows + this._createFlows = [scopes => this._createWithUrlHandler(scopes)]; + } + + static async create( + @IExtHostWindow extHostWindow: IExtHostWindow, + @IExtHostUrlsService extHostUrls: IExtHostUrlsService, + @IExtHostInitDataService initData: IExtHostInitDataService, + proxy: MainThreadAuthenticationShape, + logger: ILogger, + serverMetadata: IAuthorizationServerMetadata, + onDidDynamicAuthProviderTokensChange: Emitter<{ authProviderId: string; clientId: string; tokens: IAuthorizationToken[] }>, + existingState: { clientId?: string; initialTokens?: IAuthorizationToken[] } = {}, + ): Promise { + let { clientId, initialTokens } = existingState; + try { + if (!clientId) { + if (!serverMetadata.registration_endpoint) { + throw new Error('Server does not support dynamic registration'); + } + const registration = await fetchDynamicRegistration(serverMetadata.registration_endpoint, initData.environment.appName); + clientId = registration.client_id; + } + const scopedEvent = Event.chain(onDidDynamicAuthProviderTokensChange.event, $ => $ + .filter(e => e.authProviderId === serverMetadata.issuer && e.clientId === clientId) + .map(e => e.tokens) + ); + const provider = new DynamicAuthProvider( + extHostWindow, + extHostUrls, + initData, + proxy, + logger, + serverMetadata, + clientId, + scopedEvent, + initialTokens || [] + ); + return provider; + } catch (err) { + throw new Error(`Dynamic registration failed: ${err.message}`); + } + } + + async getSessions(scopes: readonly string[] | undefined, options: vscode.AuthenticationProviderSessionOptions): Promise { + if (!scopes) { + return this._tokenStore.sessions || []; + } + const sessions = this._tokenStore.sessions?.filter(session => session.scopes.join(' ') === scopes.join(' ')) || []; + if (sessions.length) { + const newTokens: IAuthorizationToken[] = []; + const removedTokens: IAuthorizationToken[] = []; + const newSessions: vscode.AuthenticationSession[] = []; + const removedSessions: vscode.AuthenticationSession[] = []; + const tokenMap = new Map(this._tokenStore.tokens!.map(token => [token.access_token, token])); + for (const session of sessions) { + const token = tokenMap.get(session.accessToken); + if (token && token.expires_in) { + const now = Date.now(); + const expiresInMS = token.expires_in * 1000; + // Check if the token is about to expire in 5 minutes or if it is expired + if (now > token.created_at + expiresInMS - (5 * 60 * 1000)) { + removedTokens.push(token); + removedSessions.push(session); + if (!token.refresh_token) { + // No refresh token available, cannot refresh + continue; + } + try { + const newToken = await this.exchangeRefreshTokenForToken(token.refresh_token); + newTokens.push(newToken); + newSessions.push(this._getSessionFromToken(newToken)); + } catch (err) { + this._logger.error(`Failed to refresh token: ${err}`); + } + + } + } + } + if (newTokens.length || removedTokens.length) { + this._tokenStore.update({ added: newTokens, removed: removedTokens }); + this._onDidChangeSessions.fire({ + added: newSessions, + removed: removedSessions, + changed: [] + }); + } + return sessions; + } + return []; + } + + async createSession(scopes: string[], _options: vscode.AuthenticationProviderSessionOptions): Promise { + let token: IAuthorizationTokenResponse | undefined; + for (const createFlow of this._createFlows) { + try { + token = await createFlow(scopes); + if (token) { + break; + } + } catch (err) { + this._logger.error(`Failed to create token: ${err}`); + } + } + if (!token) { + throw new Error('Failed to create authentication token'); + } + + // Store session for later retrieval + this._tokenStore.update({ added: [{ ...token, created_at: Date.now() }], removed: [] }); + const session = this._tokenStore.sessions?.find(t => t.accessToken === token.access_token)!; + + // Notify that sessions have changed + this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); + + return session; + } + + async removeSession(sessionId: string): Promise { + const session = this._tokenStore.sessions?.find(session => session.id === sessionId); + if (!session) { + this._logger.error(`Session with id ${sessionId} not found`); + return; + } + const token = this._tokenStore.tokens?.find(token => token.access_token === session.accessToken); + if (!token) { + this._logger.error(`Failed to retrieve token for removed session: ${session.id}`); + return; + } + this._tokenStore.update({ added: [], removed: [token] }); + this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); + } + + dispose(): void { + this._disposable.dispose(); + } + + private async _createWithUrlHandler(scopes: string[]): Promise { + // Generate PKCE code verifier (random string) and code challenge (SHA-256 hash of verifier) + const codeVerifier = this.generateRandomString(64); + const codeChallenge = await this.generateCodeChallenge(codeVerifier); + + // Generate a random state value to prevent CSRF + const nonce = this.generateRandomString(32); + const issuer = URI.parse(this._serverMetadata.issuer); + const callbackUri = URI.parse(`${this._initData.environment.appUriScheme}://dynamicauthprovider/${issuer.authority}/authorize?nonce=${nonce}`); + let state: URI; + try { + state = await this._extHostUrls.createAppUri(callbackUri); + } catch (error) { + throw new Error(`Failed to create external URI: ${error}`); + } + + // Prepare the authorization request URL + const authorizationUrl = new URL(this._serverMetadata.authorization_endpoint!); + authorizationUrl.searchParams.append('client_id', this.clientId); + authorizationUrl.searchParams.append('response_type', 'code'); + authorizationUrl.searchParams.append('scope', scopes.join(' ')); + authorizationUrl.searchParams.append('state', state.toString()); + authorizationUrl.searchParams.append('code_challenge', codeChallenge); + authorizationUrl.searchParams.append('code_challenge_method', 'S256'); + + // Use a redirect URI that matches what was registered during dynamic registration + const redirectUri = 'https://vscode.dev/redirect'; + authorizationUrl.searchParams.append('redirect_uri', redirectUri); + + const promise = this.waitForAuthorizationCode(callbackUri); + + // Open the browser for user authorization + await this._extHostWindow.openUri(authorizationUrl.toString(), {}); + + // Wait for the authorization code via a redirect + const { code } = await promise; + + if (!code) { + throw new Error('Authentication failed: No authorization code received'); + } + + // Exchange the authorization code for tokens + const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, redirectUri); + return tokenResponse; + } + + protected generateRandomString(length: number): string { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + .substring(0, length); + } + + protected async generateCodeChallenge(codeVerifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(codeVerifier); + const digest = await crypto.subtle.digest('SHA-256', data); + + // Base64url encode the digest + return encodeBase64(VSBuffer.wrap(new Uint8Array(digest)), false, false) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + } + + private async waitForAuthorizationCode(expectedState: URI): Promise<{ code: string }> { + const result = await this._proxy.$waitForUriHandler(expectedState); + // Extract the code parameter directly from the query string. NOTE, URLSearchParams does not work here because + // it will decode the query string and we need to keep it encoded. + const codeMatch = /[?&]code=([^&]+)/.exec(result.query || ''); + if (!codeMatch || codeMatch.length < 2) { + // No code parameter found in the query string + throw new Error('Authentication failed: No authorization code received'); + } + return { code: codeMatch[1] }; + } + + protected async exchangeCodeForToken(code: string, codeVerifier: string, redirectUri: string): Promise { + if (!this._serverMetadata.token_endpoint) { + throw new Error('Token endpoint not available in server metadata'); + } + + const tokenRequest = new URLSearchParams(); + tokenRequest.append('client_id', this.clientId); + tokenRequest.append('grant_type', 'authorization_code'); + tokenRequest.append('code', code); + tokenRequest.append('redirect_uri', redirectUri); + tokenRequest.append('code_verifier', codeVerifier); + + const response = await fetch(this._serverMetadata.token_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + body: tokenRequest.toString() + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${text}`); + } + + const result = await response.json(); + if (isAuthorizationTokenResponse(result)) { + return result; + } + throw new Error(`Invalid authorization token response: ${JSON.stringify(result)}`); + } + + protected async exchangeRefreshTokenForToken(refreshToken: string): Promise { + if (!this._serverMetadata.token_endpoint) { + throw new Error('Token endpoint not available in server metadata'); + } + + const tokenRequest = new URLSearchParams(); + tokenRequest.append('client_id', this.clientId); + tokenRequest.append('grant_type', 'refresh_token'); + tokenRequest.append('refresh_token', refreshToken); + + const response = await fetch(this._serverMetadata.token_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + body: tokenRequest.toString() + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${text}`); + } + + const result = await response.json(); + if (isAuthorizationTokenResponse(result)) { + return { + ...result, + created_at: Date.now(), + }; + } + throw new Error(`Invalid authorization token response: ${JSON.stringify(result)}`); + } + + private _getSessionFromToken(token: IAuthorizationTokenResponse): vscode.AuthenticationSession { + let claims: IAuthorizationJWTClaims | undefined; + if (token.id_token) { + try { + claims = getClaimsFromJWT(token.id_token); + } catch (e) { + // log + } + } + if (!claims) { + try { + claims = getClaimsFromJWT(token.access_token); + } catch (e) { + // log + } + } + const scopes = token.scope + ? token.scope.split(' ') + : claims?.scope + ? claims.scope.split(' ') + : []; + return { + id: stringHash(token.access_token, 0).toString(), + accessToken: token.access_token, + account: { + id: claims?.sub || 'unknown', + label: claims?.preferred_username || claims?.name || claims?.email || 'Account', + }, + scopes: scopes, + idToken: token.id_token + }; + } +} + +type IAuthorizationToken = IAuthorizationTokenResponse & { + /** + * The time when the token was created, in milliseconds since the epoch. + */ + created_at: number; +}; + +class TokenStore implements Disposable { + private readonly _tokensObservable: ISettableObservable; + private readonly _sessionsObservable: IObservable; + + private readonly _disposable: DisposableStore; + + constructor( + private readonly _persistence: { onDidChange: Event; set: (tokens: IAuthorizationToken[]) => void }, + initialTokens: IAuthorizationToken[] + ) { + this._disposable = new DisposableStore(); + this._tokensObservable = observableValue('tokens', initialTokens); + this._sessionsObservable = derived((reader) => this._tokensObservable.read(reader).map(t => this._getSessionFromToken(t))); + this._disposable.add(this._persistence.onDidChange((tokens) => this._tokensObservable.set(tokens, undefined))); + } + + get tokens(): IAuthorizationToken[] { + return this._tokensObservable.get(); + } + + get sessions(): vscode.AuthenticationSession[] { + return this._sessionsObservable.get(); + } + + dispose() { + this._disposable.dispose(); + } + + update({ added, removed }: { added: IAuthorizationToken[]; removed: IAuthorizationToken[] }): void { + const currentTokens = this._tokensObservable.get() || []; + if (removed) { + // remove from the array + for (const token of removed) { + const index = currentTokens.findIndex(t => t.access_token === token.access_token); + if (index !== -1) { + currentTokens.splice(index, 1); + } + } + } + if (added) { + // add to the array + for (const token of added) { + const index = currentTokens.findIndex(t => t.access_token === token.access_token); + if (index === -1) { + currentTokens.push(token); + } else { + currentTokens[index] = token; + } + } + } + + if (added || removed) { + this._tokensObservable.set(currentTokens, undefined); + void this._persistence.set(currentTokens); + } + } + + private _getSessionFromToken(token: IAuthorizationTokenResponse): vscode.AuthenticationSession { + let claims: IAuthorizationJWTClaims | undefined; + if (token.id_token) { + try { + claims = getClaimsFromJWT(token.id_token); + } catch (e) { + // log + } + } + if (!claims) { + try { + claims = getClaimsFromJWT(token.access_token); + } catch (e) { + // log + } + } + const scopes = token.scope + ? token.scope.split(' ') + : claims?.scope + ? claims.scope.split(' ') + : []; + return { + id: stringHash(token.access_token, 0).toString(), + accessToken: token.access_token, + account: { + id: claims?.sub || 'unknown', + label: claims?.preferred_username || claims?.name || claims?.email || 'Account', + }, + scopes: scopes, + idToken: token.id_token + }; + } +} diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts index 8d7c29eac41c5..cccd365a0bf36 100644 --- a/src/vs/workbench/api/common/extHostMcp.ts +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -183,13 +183,7 @@ class McpHTTPHandle extends Disposable { private _mode: HttpModeT = { value: HttpMode.Unknown }; private readonly _cts = new CancellationTokenSource(); private readonly _abortCtrl = new AbortController(); - private _authMetadata?: { - issuer: string; - authorizationEndpoint: string; - tokenEndpoint: string; - registrationEndpoint: string; - scopesSupported: string[]; - }; + private _authMetadata?: IAuthorizationServerMetadata; constructor( private readonly _id: number, @@ -245,7 +239,6 @@ class McpHTTPHandle extends Disposable { headers['Authorization'] = `Bearer ${token}`; } } catch (e) { - // TODO log? this._log(LogLevel.Warning, `Error getting token from server metadata: ${String(e)}`); } } @@ -270,7 +263,7 @@ class McpHTTPHandle extends Disposable { headers['Authorization'] = `Bearer ${token}`; res = await doFetch(); } catch (e) { - // TODO log? + this._log(LogLevel.Warning, `Error getting token from server metadata: ${String(e)}`); } } } @@ -357,16 +350,14 @@ class McpHTTPHandle extends Disposable { const serverMetadataResponse = await this._getAuthorizationServerMetadata(serverMetadataUrl, addtionalHeaders); const serverMetadataWithDefaults = getMetadataWithDefaultValues(serverMetadataResponse); this._authMetadata = { + ...serverMetadataWithDefaults, // HACK: For now, just use the serverMetadataUrl as the issuer. I found an example, Entra, // that uses a placeholder for the tenant... https://login.microsoftonline.com/{tenant}/v2.0 // literally... it contains `{tenant}`... instead of `organizations`. This may change our // API a bit to instead pass in these other endpoints, but for now, just user the serverMetadataUrl // as the isser. issuer: serverMetadataUrl, - authorizationEndpoint: serverMetadataWithDefaults.authorization_endpoint, - tokenEndpoint: serverMetadataWithDefaults.token_endpoint, - registrationEndpoint: serverMetadataWithDefaults.registration_endpoint, - scopesSupported: scopesSupported ?? serverMetadataWithDefaults.scopes_supported ?? [], + scopes_supported: scopesSupported ?? serverMetadataWithDefaults.scopes_supported }; return; } catch (e) { @@ -375,13 +366,8 @@ 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)); - this._authMetadata = { - issuer: defaultMetadata.issuer, - authorizationEndpoint: defaultMetadata.authorization_endpoint, - tokenEndpoint: defaultMetadata.token_endpoint, - registrationEndpoint: defaultMetadata.registration_endpoint, - scopesSupported: scopesSupported ?? defaultMetadata.scopes_supported ?? [] - }; + defaultMetadata.scopes_supported = scopesSupported ?? defaultMetadata.scopes_supported ?? []; + this._authMetadata = defaultMetadata; } private async _getResourceMetadata(resourceMetadata: string): Promise { @@ -548,16 +534,27 @@ class McpHTTPHandle extends Disposable { */ private async _attachSSE(): Promise { const postEndpoint = new DeferredPromise(); + const headers: Record = { + ...Object.fromEntries(this._launch.headers), + 'Accept': 'text/event-stream', + }; + if (this._authMetadata) { + try { + const token = await this._proxy.$getTokenFromServerMetadata(this._id, this._authMetadata); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } catch (e) { + this._log(LogLevel.Warning, `Error getting token from server metadata: ${String(e)}`); + } + } let res: Response; try { res = await fetch(this._launch.uri.toString(true), { method: 'GET', signal: this._abortCtrl.signal, - headers: { - ...Object.fromEntries(this._launch.headers), - 'Accept': 'text/event-stream', - }, + headers, }); if (res.status >= 300) { this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: `${res.status} status connecting to ${this._launch.uri} as SSE: ${await this._getErrText(res)}` }); @@ -590,14 +587,25 @@ class McpHTTPHandle extends Disposable { */ private async _sendLegacySSE(url: string, message: string) { const asBytes = new TextEncoder().encode(message); + const headers: Record = { + ...Object.fromEntries(this._launch.headers), + 'Content-Type': 'application/json', + 'Content-Length': String(asBytes.length), + }; + if (this._authMetadata) { + try { + const token = await this._proxy.$getTokenFromServerMetadata(this._id, this._authMetadata); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } catch (e) { + this._log(LogLevel.Warning, `Error getting token from server metadata: ${String(e)}`); + } + } const res = await fetch(url, { method: 'POST', signal: this._abortCtrl.signal, - headers: { - ...Object.fromEntries(this._launch.headers), - 'Content-Type': 'application/json', - 'Content-Length': String(asBytes.length), - }, + headers, body: asBytes, }); diff --git a/src/vs/workbench/api/common/extHostUrls.ts b/src/vs/workbench/api/common/extHostUrls.ts index c75ce2d4ca1f9..8af3f016f0dd9 100644 --- a/src/vs/workbench/api/common/extHostUrls.ts +++ b/src/vs/workbench/api/common/extHostUrls.ts @@ -4,14 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; -import { MainContext, IMainContext, ExtHostUrlsShape, MainThreadUrlsShape } from './extHost.protocol.js'; +import { MainContext, ExtHostUrlsShape, MainThreadUrlsShape } from './extHost.protocol.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { toDisposable } from '../../../base/common/lifecycle.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { ExtensionIdentifierSet, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { IExtHostRpcService } from './extHostRpcService.js'; export class ExtHostUrls implements ExtHostUrlsShape { + declare _serviceBrand: undefined; + private static HandlePool = 0; private readonly _proxy: MainThreadUrlsShape; @@ -19,9 +23,9 @@ export class ExtHostUrls implements ExtHostUrlsShape { private handlers = new Map(); constructor( - mainContext: IMainContext + @IExtHostRpcService extHostRpc: IExtHostRpcService ) { - this._proxy = mainContext.getProxy(MainContext.MainThreadUrls); + this._proxy = extHostRpc.getProxy(MainContext.MainThreadUrls); } registerUriHandler(extension: IExtensionDescription, handler: vscode.UriHandler): vscode.Disposable { @@ -61,3 +65,6 @@ export class ExtHostUrls implements ExtHostUrlsShape { return URI.revive(await this._proxy.$createAppUri(uri)); } } + +export interface IExtHostUrlsService extends ExtHostUrls { } +export const IExtHostUrlsService = createDecorator('IExtHostUrlsService'); diff --git a/src/vs/workbench/api/common/extHostWindow.ts b/src/vs/workbench/api/common/extHostWindow.ts index 4303200183192..2919b1ecd3eb1 100644 --- a/src/vs/workbench/api/common/extHostWindow.ts +++ b/src/vs/workbench/api/common/extHostWindow.ts @@ -16,6 +16,8 @@ import { decodeBase64 } from '../../../base/common/buffer.js'; export class ExtHostWindow implements ExtHostWindowShape { + declare _serviceBrand: undefined; + private static InitialState: WindowState = { focused: true, active: true, diff --git a/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts b/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts index 918c097f5693f..7c18439e5c9d5 100644 --- a/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts +++ b/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts @@ -23,8 +23,8 @@ import { IAuthenticationExtensionsService, IAuthenticationService } from '../../ import { IExtensionService, nullExtensionDescription as extensionDescription } from '../../../services/extensions/common/extensions.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { TestRPCProtocol } from '../common/testRPCProtocol.js'; -import { TestEnvironmentService, TestQuickInputService, TestRemoteAgentService } from '../../../test/browser/workbenchTestServices.js'; -import { TestActivityService, TestExtensionService, TestProductService, TestStorageService } from '../../../test/common/workbenchTestServices.js'; +import { TestEnvironmentService, TestHostService, TestQuickInputService, TestRemoteAgentService } from '../../../test/browser/workbenchTestServices.js'; +import { TestActivityService, TestExtensionService, TestLoggerService, TestProductService, TestStorageService } from '../../../test/common/workbenchTestServices.js'; import type { AuthenticationProvider, AuthenticationSession } from 'vscode'; import { IBrowserWorkbenchEnvironmentService } from '../../../services/environment/browser/environmentService.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; @@ -32,6 +32,15 @@ import { AuthenticationAccessService, IAuthenticationAccessService } from '../.. import { AuthenticationUsageService, IAuthenticationUsageService } from '../../../services/authentication/browser/authenticationUsageService.js'; import { AuthenticationExtensionsService } from '../../../services/authentication/browser/authenticationExtensionsService.js'; import { ILogService, NullLogService } from '../../../../platform/log/common/log.js'; +import { IExtHostInitDataService } from '../../common/extHostInitDataService.js'; +import { ExtHostWindow } from '../../common/extHostWindow.js'; +import { MainThreadWindow } from '../../browser/mainThreadWindow.js'; +import { IHostService } from '../../../services/host/browser/host.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IUserActivityService, UserActivityService } from '../../../services/userActivity/common/userActivityService.js'; +import { ExtHostUrls } from '../../common/extHostUrls.js'; +import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js'; +import { TestSecretStorageService } from '../../../../platform/secrets/test/common/testSecretStorageService.js'; class AuthQuickPick { private listener: ((e: IQuickPickDidAcceptEvent) => any) | undefined; @@ -109,12 +118,17 @@ suite('ExtHostAuthentication', () => { instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IDialogService, new TestDialogService({ confirmed: true })); instantiationService.stub(IStorageService, new TestStorageService()); + instantiationService.stub(ISecretStorageService, new TestSecretStorageService()); instantiationService.stub(IQuickInputService, new AuthTestQuickInputService()); instantiationService.stub(IExtensionService, new TestExtensionService()); instantiationService.stub(IActivityService, new TestActivityService()); instantiationService.stub(IRemoteAgentService, new TestRemoteAgentService()); instantiationService.stub(INotificationService, new TestNotificationService()); + instantiationService.stub(IHostService, new TestHostService()); + // eslint-disable-next-line local/code-no-dangerous-type-assertions + instantiationService.stub(IOpenerService, {} as Partial); + instantiationService.stub(IUserActivityService, new UserActivityService(instantiationService)); instantiationService.stub(ITelemetryService, NullTelemetryService); instantiationService.stub(IBrowserWorkbenchEnvironmentService, TestEnvironmentService); instantiationService.stub(IProductService, TestProductService); @@ -125,7 +139,25 @@ suite('ExtHostAuthentication', () => { instantiationService.stub(IAuthenticationExtensionsService, instantiationService.createInstance(AuthenticationExtensionsService)); rpcProtocol.set(MainContext.MainThreadAuthentication, instantiationService.createInstance(MainThreadAuthentication, rpcProtocol)); - extHostAuthentication = new ExtHostAuthentication(rpcProtocol); + rpcProtocol.set(MainContext.MainThreadWindow, instantiationService.createInstance(MainThreadWindow, rpcProtocol)); + const initData: IExtHostInitDataService = { + environment: { + appUriScheme: 'test', + appName: 'Test' + } + } as any; + extHostAuthentication = new ExtHostAuthentication( + rpcProtocol, + { + environment: { + appUriScheme: 'test', + appName: 'Test' + } + } as any, + new ExtHostWindow(initData, rpcProtocol), + new ExtHostUrls(rpcProtocol), + new TestLoggerService(), + ); rpcProtocol.set(ExtHostContext.ExtHostAuthentication, extHostAuthentication); }); diff --git a/src/vs/workbench/services/authentication/browser/authenticationService.ts b/src/vs/workbench/services/authentication/browser/authenticationService.ts index 16c03601fae57..857b9d75335e6 100644 --- a/src/vs/workbench/services/authentication/browser/authenticationService.ts +++ b/src/vs/workbench/services/authentication/browser/authenticationService.ts @@ -12,7 +12,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { IProductService } from '../../../../platform/product/common/productService.js'; import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js'; import { IAuthenticationAccessService } from './authenticationAccessService.js'; -import { AuthenticationProviderInformation, AuthenticationSession, AuthenticationSessionAccount, AuthenticationSessionsChangeEvent, IAuthenticationCreateSessionOptions, IAuthenticationProvider, IAuthenticationService } from '../common/authentication.js'; +import { AuthenticationProviderInformation, AuthenticationSession, AuthenticationSessionAccount, AuthenticationSessionsChangeEvent, IAuthenticationCreateSessionOptions, IAuthenticationProvider, IAuthenticationProviderHostDelegate, IAuthenticationService } from '../common/authentication.js'; import { IBrowserWorkbenchEnvironmentService } from '../../environment/browser/environmentService.js'; import { ActivationKind, IExtensionService } from '../../extensions/common/extensions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -20,6 +20,7 @@ import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; import { ExtensionsRegistry } from '../../extensions/common/extensionsRegistry.js'; import { match } from '../../../../base/common/glob.js'; import { URI } from '../../../../base/common/uri.js'; +import { IAuthorizationServerMetadata } from '../../../../base/common/oauth.js'; export function getAuthenticationProviderActivationEvent(id: string): string { return `onAuthenticationRequest:${id}`; } @@ -97,6 +98,8 @@ export class AuthenticationService extends Disposable implements IAuthentication private _authenticationProviders: Map = new Map(); private _authenticationProviderDisposables: DisposableMap = this._register(new DisposableMap()); + private readonly _delegates: IAuthenticationProviderHostDelegate[] = []; + constructor( @IExtensionService private readonly _extensionService: IExtensionService, @IAuthenticationAccessService authenticationAccessService: IAuthenticationAccessService, @@ -258,7 +261,8 @@ export class AuthenticationService extends Disposable implements IAuthentication // Check if the issuer is in the list of supported issuers if (issuer) { const issuerStr = issuer.toString(true); - if (!authProvider.issuers?.some(i => match(i.toString(true), issuerStr))) { + // TODO: something is off here... + if (!authProvider.issuers?.some(i => i.toString(true) === issuerStr || match(i.toString(true), issuerStr))) { throw new Error(`The issuer '${issuerStr}' is not supported by the authentication provider '${id}'.`); } } @@ -289,10 +293,17 @@ export class AuthenticationService extends Disposable implements IAuthentication } } - // Not used yet but will be... async getOrActivateProviderIdForIssuer(issuer: URI): Promise { + for (const provider of this._authenticationProviders.values()) { + if (provider.issuers?.some(i => i.toString(true) === issuer.toString(true) || match(i.toString(true), issuer.toString(true)))) { + return provider.id; + } + } + const issuerStr = issuer.toString(true); const providers = this._declaredProviders + // Only consider providers that are not already registered since we already checked them + .filter(p => !this._authenticationProviders.has(p.id)) .filter(p => !!p.issuerGlobs?.some(i => match(i, issuerStr))); // TODO:@TylerLeonhardt fan out? for (const provider of providers) { @@ -305,6 +316,37 @@ export class AuthenticationService extends Disposable implements IAuthentication return undefined; } + async createDynamicAuthenticationProvider(serverMetadata: IAuthorizationServerMetadata): Promise { + const delegate = this._delegates[0]; + if (!delegate) { + this._logService.error('No authentication provider host delegate found'); + return undefined; + } + await delegate.create(serverMetadata); + const providerId = serverMetadata.issuer; + const provider = this._authenticationProviders.get(providerId); + if (provider) { + this._logService.debug(`Created dynamic authentication provider: ${providerId}`); + return provider; + } + this._logService.error(`Failed to create dynamic authentication provider: ${providerId}`); + return undefined; + } + + registerAuthenticationProviderHostDelegate(delegate: IAuthenticationProviderHostDelegate): IDisposable { + this._delegates.push(delegate); + this._delegates.sort((a, b) => b.priority - a.priority); + + return { + dispose: () => { + const index = this._delegates.indexOf(delegate); + if (index !== -1) { + this._delegates.splice(index, 1); + } + } + }; + } + private async tryActivateProvider(providerId: string, activateImmediate: boolean): Promise { await this._extensionService.activateByEvent(getAuthenticationProviderActivationEvent(providerId), activateImmediate ? ActivationKind.Immediate : ActivationKind.Normal); let provider = this._authenticationProviders.get(providerId); diff --git a/src/vs/workbench/services/authentication/common/authentication.ts b/src/vs/workbench/services/authentication/common/authentication.ts index bf2d0d588d021..21bbd5f3b934f 100644 --- a/src/vs/workbench/services/authentication/common/authentication.ts +++ b/src/vs/workbench/services/authentication/common/authentication.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { IAuthorizationServerMetadata } from '../../../../base/common/oauth.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -64,6 +66,12 @@ export interface AllowedExtension { trusted?: boolean; } +export interface IAuthenticationProviderHostDelegate { + /** Priority for this delegate, delegates are tested in descending priority order */ + readonly priority: number; + create(serverMetadata: IAuthorizationServerMetadata): Promise; +} + export const IAuthenticationService = createDecorator('IAuthenticationService'); export interface IAuthenticationService { @@ -174,6 +182,47 @@ export interface IAuthenticationService { * @param issuer The issuer url that this provider is responsible for */ getOrActivateProviderIdForIssuer(issuer: URI): Promise; + + /** + * Allows the ability register a delegate that will be used to start authentication providers + * @param delegate The delegate to register + */ + registerAuthenticationProviderHostDelegate(delegate: IAuthenticationProviderHostDelegate): IDisposable; + + /** + * Creates a dynamic authentication provider for the given server metadata + * @param serverMetadata The metadata for the server that is being authenticated against + */ + createDynamicAuthenticationProvider(serverMetadata: IAuthorizationServerMetadata): Promise; +} + +export function isAuthenticationSession(thing: unknown): thing is AuthenticationSession { + if (typeof thing !== 'object' || !thing) { + return false; + } + const maybe = thing as AuthenticationSession; + if (typeof maybe.id !== 'string') { + return false; + } + if (typeof maybe.accessToken !== 'string') { + return false; + } + if (typeof maybe.account !== 'object' || !maybe.account) { + return false; + } + if (typeof maybe.account.label !== 'string') { + return false; + } + if (typeof maybe.account.id !== 'string') { + return false; + } + if (!Array.isArray(maybe.scopes)) { + return false; + } + if (maybe.idToken && typeof maybe.idToken !== 'string') { + return false; + } + return true; } // TODO: Move this into MainThreadAuthentication