diff --git a/src/vs/base/common/oauth.ts b/src/vs/base/common/oauth.ts index 1e36725ee86..795b4281e6a 100644 --- a/src/vs/base/common/oauth.ts +++ b/src/vs/base/common/oauth.ts @@ -97,6 +97,11 @@ export interface IAuthorizationServerMetadata { */ token_endpoint?: string; + /** + * OPTIONAL. URL of the authorization server's device code endpoint. + */ + device_authorization_endpoint?: string; + /** * OPTIONAL. URL of the authorization server's JWK Set document containing signing keys. */ @@ -350,6 +355,70 @@ export interface IAuthorizationTokenErrorResponse { error_uri?: string; } +/** + * Response from the device authorization endpoint as per RFC 8628 section 3.2. + */ +export interface IAuthorizationDeviceResponse { + /** + * REQUIRED. The device verification code. + */ + device_code: string; + + /** + * REQUIRED. The end-user verification code. + */ + user_code: string; + + /** + * REQUIRED. The end-user verification URI on the authorization server. + */ + verification_uri: string; + + /** + * OPTIONAL. A verification URI that includes the user_code, designed for non-textual transmission. + */ + verification_uri_complete?: string; + + /** + * REQUIRED. The lifetime in seconds of the device_code and user_code. + */ + expires_in: number; + + /** + * OPTIONAL. The minimum amount of time in seconds that the client should wait between polling requests. + * If no value is provided, clients must use 5 as the default. + */ + interval?: number; +} + +/** + * Error response from the token endpoint when using device authorization grant. + * As defined in RFC 8628 section 3.5. + */ +export interface IAuthorizationDeviceTokenErrorResponse { + /** + * REQUIRED. Error code as specified in OAuth 2.0 or in RFC 8628 section 3.5. + * Standard OAuth 2.0 error codes plus: + * - "authorization_pending": The authorization request is still pending as the end user hasn't completed the user interaction steps + * - "slow_down": A variant of "authorization_pending", polling should continue but interval must be increased by 5 seconds + * - "access_denied": The authorization request was denied + * - "expired_token": The "device_code" has expired and the device authorization session has concluded + */ + error: 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'unauthorized_client' | + 'unsupported_grant_type' | 'invalid_scope' | 'authorization_pending' | + 'slow_down' | 'access_denied' | 'expired_token' | 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. @@ -537,6 +606,22 @@ export function isDynamicClientRegistrationResponse(obj: unknown): obj is IAutho return response.client_id !== undefined && response.client_name !== undefined; } +export function isAuthorizationDeviceResponse(obj: unknown): obj is IAuthorizationDeviceResponse { + if (typeof obj !== 'object' || obj === null) { + return false; + } + const response = obj as IAuthorizationDeviceResponse; + return response.device_code !== undefined && response.user_code !== undefined && response.verification_uri !== undefined && response.expires_in !== undefined; +} + +export function isAuthorizationDeviceTokenErrorResponse(obj: unknown): obj is IAuthorizationDeviceTokenErrorResponse { + if (typeof obj !== 'object' || obj === null) { + return false; + } + const response = obj as IAuthorizationDeviceTokenErrorResponse; + return response.error !== undefined && response.error_description !== undefined; +} + //#endregion export function getDefaultMetadataForUrl(issuer: URL): IRequiredAuthorizationServerMetadata & IRequiredAuthorizationServerMetadata { @@ -561,7 +646,15 @@ export function getMetadataWithDefaultValues(metadata: IAuthorizationServerMetad }; } -export async function fetchDynamicRegistration(registrationEndpoint: string, clientName: string, additionalRedirectUris: string[] = []): Promise { +/** + * Default port for the authorization flow. We try to use this port so that + * the redirect URI does not change when running on localhost. This is useful + * for servers that only allow exact matches on the redirect URI. The spec + * says that the port should not matter, but some servers do not follow + * the spec and require an exact match. + */ +export const DEFAULT_AUTH_FLOW_PORT = 33418; +export async function fetchDynamicRegistration(registrationEndpoint: string, clientName: string): Promise { const response = await fetch(registrationEndpoint, { method: 'POST', headers: { @@ -570,12 +663,19 @@ export async function fetchDynamicRegistration(registrationEndpoint: string, cli body: JSON.stringify({ client_name: clientName, client_uri: 'https://code.visualstudio.com', - grant_types: ['authorization_code', 'refresh_token'], + grant_types: ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:device_code'], response_types: ['code'], redirect_uris: [ 'https://insiders.vscode.dev/redirect', 'https://vscode.dev/redirect', - ...additionalRedirectUris + 'http://localhost/', + '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://localhost:${DEFAULT_AUTH_FLOW_PORT}/`, + `http://127.0.0.1:${DEFAULT_AUTH_FLOW_PORT}/` ], token_endpoint_auth_method: 'none' }) diff --git a/src/vs/base/test/common/oauth.test.ts b/src/vs/base/test/common/oauth.test.ts index de985a164b0..c4c8174bcb9 100644 --- a/src/vs/base/test/common/oauth.test.ts +++ b/src/vs/base/test/common/oauth.test.ts @@ -10,6 +10,8 @@ import { getDefaultMetadataForUrl, getMetadataWithDefaultValues, isAuthorizationAuthorizeResponse, + isAuthorizationDeviceResponse, + isAuthorizationDeviceTokenErrorResponse, isAuthorizationDynamicClientRegistrationResponse, isAuthorizationProtectedResourceMetadata, isAuthorizationServerMetadata, @@ -18,7 +20,8 @@ import { parseWWWAuthenticateHeader, fetchDynamicRegistration, IAuthorizationJWTClaims, - IAuthorizationServerMetadata + IAuthorizationServerMetadata, + DEFAULT_AUTH_FLOW_PORT } from '../../common/oauth.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; import { encodeBase64, VSBuffer } from '../../common/buffer.js'; @@ -115,6 +118,81 @@ suite('OAuth', () => { assert.strictEqual(isDynamicClientRegistrationResponse({ client_name: 'missing-id' }), false); assert.strictEqual(isDynamicClientRegistrationResponse('not an object'), false); }); + + test('isAuthorizationDeviceResponse should correctly identify device authorization response', () => { + // Valid response + assert.strictEqual(isAuthorizationDeviceResponse({ + device_code: 'device-code-123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://example.com/verify', + expires_in: 1800 + }), true); + + // Valid response with optional fields + assert.strictEqual(isAuthorizationDeviceResponse({ + device_code: 'device-code-123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://example.com/verify', + verification_uri_complete: 'https://example.com/verify?user_code=ABCD-EFGH', + expires_in: 1800, + interval: 5 + }), true); + + // Invalid cases + assert.strictEqual(isAuthorizationDeviceResponse(null), false); + assert.strictEqual(isAuthorizationDeviceResponse(undefined), false); + assert.strictEqual(isAuthorizationDeviceResponse({}), false); + assert.strictEqual(isAuthorizationDeviceResponse({ device_code: 'missing-others' }), false); + assert.strictEqual(isAuthorizationDeviceResponse({ user_code: 'missing-others' }), false); + assert.strictEqual(isAuthorizationDeviceResponse({ verification_uri: 'missing-others' }), false); + assert.strictEqual(isAuthorizationDeviceResponse({ expires_in: 1800 }), false); + assert.strictEqual(isAuthorizationDeviceResponse({ + device_code: 'device-code-123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://example.com/verify' + // Missing expires_in + }), false); + assert.strictEqual(isAuthorizationDeviceResponse('not an object'), false); + }); + + test('isAuthorizationDeviceTokenErrorResponse should correctly identify device token error response', () => { + // Valid error response + assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({ + error: 'authorization_pending', + error_description: 'The authorization request is still pending' + }), true); + + // Valid error response with different error codes + assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({ + error: 'slow_down', + error_description: 'Polling too fast' + }), true); + + assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({ + error: 'access_denied', + error_description: 'The user denied the request' + }), true); + + assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({ + error: 'expired_token', + error_description: 'The device code has expired' + }), true); + + // Valid response with optional error_uri + assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({ + error: 'invalid_request', + error_description: 'The request is missing a required parameter', + error_uri: 'https://example.com/error' + }), true); + + // Invalid cases + assert.strictEqual(isAuthorizationDeviceTokenErrorResponse(null), false); + assert.strictEqual(isAuthorizationDeviceTokenErrorResponse(undefined), false); + assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({}), false); + assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({ error: 'missing-description' }), false); + assert.strictEqual(isAuthorizationDeviceTokenErrorResponse({ error_description: 'missing-error' }), false); + assert.strictEqual(isAuthorizationDeviceTokenErrorResponse('not an object'), false); + }); }); suite('Utility Functions', () => { @@ -257,8 +335,7 @@ suite('OAuth', () => { const result = await fetchDynamicRegistration( 'https://auth.example.com/register', - 'Test Client', - ['https://custom-redirect.com/callback'] + 'Test Client' ); // Verify fetch was called correctly @@ -272,12 +349,15 @@ suite('OAuth', () => { const requestBody = JSON.parse(options.body as string); assert.strictEqual(requestBody.client_name, 'Test Client'); assert.strictEqual(requestBody.client_uri, 'https://code.visualstudio.com'); - assert.deepStrictEqual(requestBody.grant_types, ['authorization_code', 'refresh_token']); + assert.deepStrictEqual(requestBody.grant_types, ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:device_code']); assert.deepStrictEqual(requestBody.response_types, ['code']); assert.deepStrictEqual(requestBody.redirect_uris, [ 'https://insiders.vscode.dev/redirect', 'https://vscode.dev/redirect', - 'https://custom-redirect.com/callback' + 'http://localhost/', + 'http://127.0.0.1/', + `http://localhost:${DEFAULT_AUTH_FLOW_PORT}/`, + `http://127.0.0.1:${DEFAULT_AUTH_FLOW_PORT}/` ]); // Verify response is processed correctly diff --git a/src/vs/workbench/api/browser/mainThreadAuthentication.ts b/src/vs/workbench/api/browser/mainThreadAuthentication.ts index 6a5d9cf08c3..8ac2c44907d 100644 --- a/src/vs/workbench/api/browser/mainThreadAuthentication.ts +++ b/src/vs/workbench/api/browser/mainThreadAuthentication.ts @@ -26,6 +26,7 @@ import { IURLService } from '../../../platform/url/common/url.js'; import { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; import { IAuthorizationTokenResponse } from '../../../base/common/oauth.js'; import { IDynamicAuthenticationProviderStorageService } from '../../services/authentication/common/dynamicAuthenticationProviderStorage.js'; +import { IClipboardService } from '../../../platform/clipboard/common/clipboardService.js'; export interface AuthenticationInteractiveOptions { detail?: string; @@ -94,6 +95,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu @ILogService private readonly logService: ILogService, @IURLService private readonly urlService: IURLService, @IDynamicAuthenticationProviderStorageService private readonly dynamicAuthProviderStorageService: IDynamicAuthenticationProviderStorageService, + @IClipboardService private readonly clipboardService: IClipboardService ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAuthentication); @@ -188,6 +190,28 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu return await deferredPromise.p; } + $showContinueNotification(message: string): Promise { + const yes = nls.localize('yes', "Yes"); + const no = nls.localize('no', "No"); + const deferredPromise = new DeferredPromise(); + let result = false; + const handle = this.notificationService.prompt( + Severity.Warning, + message, + [{ + label: yes, + run: () => result = true + }, { + label: no, + run: () => result = false + }]); + const disposable = handle.onDidClose(() => { + deferredPromise.complete(result); + disposable.dispose(); + }); + return deferredPromise.p; + } + async $registerDynamicAuthenticationProvider(id: string, label: string, issuer: UriComponents, clientId: string): Promise { await this.$registerAuthenticationProvider(id, label, false, [issuer]); this.dynamicAuthProviderStorageService.storeClientId(id, clientId, label, URI.revive(issuer).toString()); @@ -459,4 +483,30 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu } //#endregion + + async $showDeviceCodeModal(userCode: string, verificationUri: string): Promise { + const { result } = await this.dialogService.prompt({ + type: Severity.Info, + message: nls.localize('deviceCodeTitle', "Device Code Authentication"), + detail: nls.localize('deviceCodeDetail', "Your code: {0}\n\nTo complete authentication, navigate to {1} and enter the code above.", userCode, verificationUri), + buttons: [ + { + label: nls.localize('copyAndContinue', "Copy & Continue"), + run: () => true + } + ], + cancelButton: true + }); + + if (result) { + // Open verification URI + try { + await this.clipboardService.writeText(userCode); + return await this.openerService.open(URI.parse(verificationUri)); + } catch (error) { + this.notificationService.error(nls.localize('failedToOpenUri', "Failed to open {0}", verificationUri)); + } + } + return false; + } } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index a22a562ec65..27f89b1f8f3 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -78,7 +78,7 @@ import { ExtHostNotebookKernels } from './extHostNotebookKernels.js'; import { ExtHostNotebookRenderers } from './extHostNotebookRenderers.js'; import { IExtHostOutputService } from './extHostOutput.js'; import { ExtHostProfileContentHandlers } from './extHostProfileContentHandler.js'; -import { ExtHostProgress } from './extHostProgress.js'; +import { IExtHostProgress } from './extHostProgress.js'; import { ExtHostQuickDiff } from './extHostQuickDiff.js'; import { createExtHostQuickOpen } from './extHostQuickOpen.js'; import { IExtHostRpcService } from './extHostRpcService.js'; @@ -147,6 +147,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostSecretState = accessor.get(IExtHostSecretState); const extHostEditorTabs = accessor.get(IExtHostEditorTabs); const extHostManagedSockets = accessor.get(IExtHostManagedSockets); + const extHostProgress = accessor.get(IExtHostProgress); const extHostAuthentication = accessor.get(IExtHostAuthentication); const extHostLanguageModels = accessor.get(IExtHostLanguageModels); const extHostMcp = accessor.get(IExtHostMpcService); @@ -165,6 +166,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I rpcProtocol.set(ExtHostContext.ExtHostTelemetry, extHostTelemetry); rpcProtocol.set(ExtHostContext.ExtHostEditorTabs, extHostEditorTabs); rpcProtocol.set(ExtHostContext.ExtHostManagedSockets, extHostManagedSockets); + rpcProtocol.set(ExtHostContext.ExtHostProgress, extHostProgress); rpcProtocol.set(ExtHostContext.ExtHostAuthentication, extHostAuthentication); rpcProtocol.set(ExtHostContext.ExtHostChatProvider, extHostLanguageModels); @@ -204,7 +206,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostQuickDiff = rpcProtocol.set(ExtHostContext.ExtHostQuickDiff, new ExtHostQuickDiff(rpcProtocol, uriTransformer)); const extHostShare = rpcProtocol.set(ExtHostContext.ExtHostShare, new ExtHostShare(rpcProtocol, uriTransformer)); const extHostComment = rpcProtocol.set(ExtHostContext.ExtHostComments, createExtHostComments(rpcProtocol, extHostCommands, extHostDocuments)); - const extHostProgress = rpcProtocol.set(ExtHostContext.ExtHostProgress, new ExtHostProgress(rpcProtocol.getProxy(MainContext.MainThreadProgress))); const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHostLabelService, new ExtHostLabelService(rpcProtocol)); const extHostTheming = rpcProtocol.set(ExtHostContext.ExtHostTheming, new ExtHostTheming(rpcProtocol)); const extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol, extHostCommands)); diff --git a/src/vs/workbench/api/common/extHost.common.services.ts b/src/vs/workbench/api/common/extHost.common.services.ts index f21a2916fc0..23a60212db1 100644 --- a/src/vs/workbench/api/common/extHost.common.services.ts +++ b/src/vs/workbench/api/common/extHost.common.services.ts @@ -27,18 +27,18 @@ import { ILoggerService } from '../../../platform/log/common/log.js'; import { ExtHostVariableResolverProviderService, IExtHostVariableResolverProvider } from './extHostVariableResolverService.js'; import { ExtHostLocalizationService, IExtHostLocalizationService } from './extHostLocalizationService.js'; import { ExtHostManagedSockets, IExtHostManagedSockets } from './extHostManagedSockets.js'; -import { ExtHostAuthentication, IExtHostAuthentication } from './extHostAuthentication.js'; import { ExtHostLanguageModels, IExtHostLanguageModels } from './extHostLanguageModels.js'; import { IExtHostTerminalShellIntegration, ExtHostTerminalShellIntegration } from './extHostTerminalShellIntegration.js'; import { ExtHostTesting, IExtHostTesting } from './extHostTesting.js'; import { ExtHostMcpService, IExtHostMpcService } from './extHostMcp.js'; import { ExtHostUrls, IExtHostUrlsService } from './extHostUrls.js'; +import { ExtHostProgress, IExtHostProgress } from './extHostProgress.js'; registerSingleton(IExtHostLocalizationService, ExtHostLocalizationService, InstantiationType.Delayed); registerSingleton(ILoggerService, ExtHostLoggerService, InstantiationType.Delayed); registerSingleton(IExtHostApiDeprecationService, ExtHostApiDeprecationService, InstantiationType.Delayed); registerSingleton(IExtHostCommands, ExtHostCommands, InstantiationType.Eager); -registerSingleton(IExtHostAuthentication, ExtHostAuthentication, InstantiationType.Eager); +registerSingleton(IExtHostProgress, ExtHostProgress, InstantiationType.Eager); registerSingleton(IExtHostLanguageModels, ExtHostLanguageModels, InstantiationType.Eager); registerSingleton(IExtHostConfiguration, ExtHostConfiguration, InstantiationType.Eager); registerSingleton(IExtHostConsumerFileSystem, ExtHostConsumerFileSystem, InstantiationType.Eager); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index cae7e4becbf..4fa20ba1d32 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -190,6 +190,8 @@ export interface MainThreadAuthenticationShape extends IDisposable { $getAccounts(providerId: string): Promise>; $removeSession(providerId: string, sessionId: string): Promise; $waitForUriHandler(expectedUri: UriComponents): Promise; + $showContinueNotification(message: string): Promise; + $showDeviceCodeModal(userCode: string, verificationUri: string): Promise; $registerDynamicAuthenticationProvider(id: string, label: string, issuer: UriComponents, clientId: string): Promise; $setSessionsForDynamicAuthProvider(authProviderId: string, clientId: string, sessions: (IAuthorizationTokenResponse & { created_at: number })[]): Promise; } diff --git a/src/vs/workbench/api/common/extHostAuthentication.ts b/src/vs/workbench/api/common/extHostAuthentication.ts index 145b93d9d08..e468fe44095 100644 --- a/src/vs/workbench/api/common/extHostAuthentication.ts +++ b/src/vs/workbench/api/common/extHostAuthentication.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; +import * as nls from '../../../nls.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { MainContext, MainThreadAuthenticationShape, ExtHostAuthenticationShape } from './extHost.protocol.js'; -import { Disposable } from './extHostTypes.js'; +import { Disposable, ProgressLocation } from './extHostTypes.js'; import { IExtensionDescription, ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; @@ -22,6 +23,10 @@ import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js' import { IExtHostUrlsService } from './extHostUrls.js'; import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; import { equals as arraysEqual } from '../../../base/common/arrays.js'; +import { IExtHostProgress } from './extHostProgress.js'; +import { IProgressStep } from '../../../platform/progress/common/progress.js'; +import { CancellationError, isCancellationError } from '../../../base/common/errors.js'; +import { raceCancellationError } from '../../../base/common/async.js'; export interface IExtHostAuthentication extends ExtHostAuthentication { } export const IExtHostAuthentication = createDecorator('IExtHostAuthentication'); @@ -37,6 +42,8 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { declare _serviceBrand: undefined; + protected readonly _dynamicAuthProviderCtor = DynamicAuthProvider; + private _proxy: MainThreadAuthenticationShape; private _authenticationProviders: Map = new Map(); @@ -50,7 +57,8 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { @IExtHostInitDataService private readonly _initData: IExtHostInitDataService, @IExtHostWindow private readonly _extHostWindow: IExtHostWindow, @IExtHostUrlsService private readonly _extHostUrls: IExtHostUrlsService, - @ILoggerService private readonly _extHostLoggerService: ILoggerService, + @IExtHostProgress private readonly _extHostProgress: IExtHostProgress, + @ILoggerService private readonly _extHostLoggerService: ILoggerService ) { this._proxy = extHostRpc.getProxy(MainContext.MainThreadAuthentication); } @@ -178,10 +186,11 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { throw new Error(`Dynamic registration failed: ${err.message}`); } } - const provider = new DynamicAuthProvider( + const provider = new this._dynamicAuthProviderCtor( this._extHostWindow, this._extHostUrls, this._initData, + this._extHostProgress, this._extHostLoggerService, this._proxy, serverMetadata, @@ -234,19 +243,23 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { private readonly _tokenStore: TokenStore; - private readonly _createFlows: Array<(scopes: string[]) => Promise>; + protected readonly _createFlows: Array<{ + label: string; + handler: (scopes: string[], progress: vscode.Progress<{ message: string }>, token: vscode.CancellationToken) => Promise; + }>; - private readonly _logger: ILogger; + protected readonly _logger: ILogger; private readonly _disposable: DisposableStore; constructor( - @IExtHostWindow private readonly _extHostWindow: IExtHostWindow, + @IExtHostWindow protected readonly _extHostWindow: IExtHostWindow, @IExtHostUrlsService private readonly _extHostUrls: IExtHostUrlsService, @IExtHostInitDataService private readonly _initData: IExtHostInitDataService, + @IExtHostProgress private readonly _extHostProgress: IExtHostProgress, @ILoggerService loggerService: ILoggerService, - private readonly _proxy: MainThreadAuthenticationShape, - private readonly _serverMetadata: IAuthorizationServerMetadata, - private readonly _resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined, + protected readonly _proxy: MainThreadAuthenticationShape, + protected readonly _serverMetadata: IAuthorizationServerMetadata, + protected readonly _resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined, readonly clientId: string, onDidDynamicAuthProviderTokensChange: Emitter<{ authProviderId: string; clientId: string; tokens: IAuthorizationToken[] }>, initialTokens: IAuthorizationToken[], @@ -274,7 +287,10 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { )); this._disposable.add(this._tokenStore.onDidChangeSessions(e => this._onDidChangeSessions.fire(e))); // Will be extended later to support other flows - this._createFlows = [scopes => this._createWithUrlHandler(scopes)]; + this._createFlows = [{ + label: nls.localize('url handler', "URL Handler"), + handler: (scopes, progress, token) => this._createWithUrlHandler(scopes, progress, token) + }]; } async getSessions(scopes: readonly string[] | undefined, _options: vscode.AuthenticationProviderSessionOptions): Promise { @@ -328,14 +344,34 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { async createSession(scopes: string[], _options: vscode.AuthenticationProviderSessionOptions): Promise { this._logger.info(`Creating session for scopes: ${scopes.join(' ')}`); let token: IAuthorizationTokenResponse | undefined; - for (const createFlow of this._createFlows) { + for (let i = 0; i < this._createFlows.length; i++) { + const { handler } = this._createFlows[i]; try { - token = await createFlow(scopes); + token = await this._extHostProgress.withProgressFromSource( + { label: this.label, id: this.id }, + { + location: ProgressLocation.Notification, + title: nls.localize('authenticatingTo', "Authenticating to '{0}'", this.label), + cancellable: true + }, + (progress, token) => handler(scopes, progress, token)); if (token) { break; } } catch (err) { - this._logger.error(`Failed to create token: ${err}`); + const nextMode = this._createFlows[i + 1]?.label; + if (!nextMode) { + break; // No more flows to try + } + const message = isCancellationError(err) + ? nls.localize('userCanceledContinue', "Having trouble authenticating to '{0}'? Would you like to try a different way? ({1})", this.label, nextMode) + : nls.localize('continueWith', "You have not yet finished authenticating to '{0}'. Would you like to try a different way? ({1})", this.label, nextMode); + + const result = await this._proxy.$showContinueNotification(message); + if (!result) { + throw new CancellationError(); + } + this._logger.error(`Failed to create token via flow '${nextMode}': ${err}`); } } if (!token) { @@ -369,7 +405,7 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { this._disposable.dispose(); } - private async _createWithUrlHandler(scopes: string[]): Promise { + private async _createWithUrlHandler(scopes: string[], progress: vscode.Progress, token: vscode.CancellationToken): 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); @@ -408,15 +444,28 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { // Open the browser for user authorization this._logger.info(`Opening authorization URL for scopes: ${scopeString}`); this._logger.trace(`Authorization URL: ${authorizationUrl.toString()}`); - await this._extHostWindow.openUri(authorizationUrl.toString(), {}); + const opened = await this._extHostWindow.openUri(authorizationUrl.toString(), {}); + if (!opened) { + throw new CancellationError(); + } + progress.report({ + message: nls.localize('completeAuth', "Complete the authentication in the browser window that has opened."), + }); // Wait for the authorization code via a redirect - const { code } = await promise; - this._logger.info(`Authorization code received for scopes: ${scopeString}`); - - if (!code) { - throw new Error('Authentication failed: No authorization code received'); + let code: string | undefined; + try { + const response = await raceCancellationError(promise, token); + code = response.code; + } catch (err) { + if (isCancellationError(err)) { + this._logger.info('Authorization code request was cancelled by the user.'); + throw err; + } + this._logger.error(`Failed to receive authorization code: ${err}`); + throw new Error(`Failed to receive authorization code: ${err}`); } + this._logger.info(`Authorization code received for scopes: ${scopeString}`); // Exchange the authorization code for tokens const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, redirectUri); diff --git a/src/vs/workbench/api/common/extHostProgress.ts b/src/vs/workbench/api/common/extHostProgress.ts index 0914c2e7916..a1b882d626e 100644 --- a/src/vs/workbench/api/common/extHostProgress.ts +++ b/src/vs/workbench/api/common/extHostProgress.ts @@ -4,22 +4,30 @@ *--------------------------------------------------------------------------------------------*/ import { ProgressOptions } from 'vscode'; -import { MainThreadProgressShape, ExtHostProgressShape } from './extHost.protocol.js'; +import { MainThreadProgressShape, ExtHostProgressShape, MainContext } from './extHost.protocol.js'; import { ProgressLocation } from './extHostTypeConverters.js'; import { Progress, IProgressStep } from '../../../platform/progress/common/progress.js'; import { CancellationTokenSource, CancellationToken } from '../../../base/common/cancellation.js'; import { throttle } from '../../../base/common/decorators.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { onUnexpectedExternalError } from '../../../base/common/errors.js'; +import { INotificationSource } from '../../../platform/notification/common/notification.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { IExtHostRpcService } from './extHostRpcService.js'; + +export interface IExtHostProgress extends ExtHostProgress { } +export const IExtHostProgress = createDecorator('IExtHostProgress'); export class ExtHostProgress implements ExtHostProgressShape { + declare readonly _serviceBrand: undefined; + private _proxy: MainThreadProgressShape; private _handles: number = 0; private _mapHandleToCancellationSource: Map = new Map(); - constructor(proxy: MainThreadProgressShape) { - this._proxy = proxy; + constructor(@IExtHostRpcService extHostRpc: IExtHostRpcService) { + this._proxy = extHostRpc.getProxy(MainContext.MainThreadProgress); } async withProgress(extension: IExtensionDescription, options: ProgressOptions, task: (progress: Progress, token: CancellationToken) => Thenable): Promise { @@ -31,6 +39,14 @@ export class ExtHostProgress implements ExtHostProgressShape { return this._withProgress(handle, task, !!cancellable); } + async withProgressFromSource(source: string | INotificationSource, options: ProgressOptions, task: (progress: Progress, token: CancellationToken) => Thenable): Promise { + const handle = this._handles++; + const { title, location, cancellable } = options; + + this._proxy.$startProgress(handle, { location: ProgressLocation.from(location), title, source, cancellable }, undefined).catch(onUnexpectedExternalError); + return this._withProgress(handle, task, !!cancellable); + } + private _withProgress(handle: number, task: (progress: Progress, token: CancellationToken) => Thenable, cancellable: boolean): Thenable { let source: CancellationTokenSource | undefined; if (cancellable) { diff --git a/src/vs/workbench/api/node/extHost.node.services.ts b/src/vs/workbench/api/node/extHost.node.services.ts index db7afe10529..55acd8bd9c1 100644 --- a/src/vs/workbench/api/node/extHost.node.services.ts +++ b/src/vs/workbench/api/node/extHost.node.services.ts @@ -29,6 +29,8 @@ import { SignService } from '../../../platform/sign/node/signService.js'; import { ExtHostTelemetry, IExtHostTelemetry } from '../common/extHostTelemetry.js'; import { IExtHostMpcService } from '../common/extHostMcp.js'; import { NodeExtHostMpcService } from './extHostMcpNode.js'; +import { IExtHostAuthentication } from '../common/extHostAuthentication.js'; +import { NodeExtHostAuthentication } from './extHostAuthentication.js'; // ######################################################################### // ### ### @@ -43,6 +45,7 @@ registerSingleton(ISignService, SignService, InstantiationType.Delayed); registerSingleton(IExtensionStoragePaths, ExtensionStoragePaths, InstantiationType.Eager); registerSingleton(IExtHostTelemetry, new SyncDescriptor(ExtHostTelemetry, [false], true)); +registerSingleton(IExtHostAuthentication, NodeExtHostAuthentication, InstantiationType.Eager); registerSingleton(IExtHostDebugService, ExtHostDebugService, InstantiationType.Eager); registerSingleton(IExtHostSearch, NativeExtHostSearch, InstantiationType.Eager); registerSingleton(IExtHostTask, ExtHostTask, InstantiationType.Eager); diff --git a/src/vs/workbench/api/node/extHostAuthentication.ts b/src/vs/workbench/api/node/extHostAuthentication.ts new file mode 100644 index 00000000000..4b0cdf86b09 --- /dev/null +++ b/src/vs/workbench/api/node/extHostAuthentication.ts @@ -0,0 +1,594 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from '../../../nls.js'; +import type * as vscode from 'vscode'; +import * as http from 'http'; +import { randomBytes } from 'crypto'; +import { URL } from 'url'; +import { ExtHostAuthentication, DynamicAuthProvider, IExtHostAuthentication } from '../common/extHostAuthentication.js'; +import { IExtHostRpcService } from '../common/extHostRpcService.js'; +import { IExtHostInitDataService } from '../common/extHostInitDataService.js'; +import { IExtHostWindow } from '../common/extHostWindow.js'; +import { IExtHostUrlsService } from '../common/extHostUrls.js'; +import { ILogger, ILoggerService } from '../../../platform/log/common/log.js'; +import { MainThreadAuthenticationShape } from '../common/extHost.protocol.js'; +import { IAuthorizationServerMetadata, IAuthorizationProtectedResourceMetadata, IAuthorizationTokenResponse, DEFAULT_AUTH_FLOW_PORT, IAuthorizationDeviceResponse, isAuthorizationDeviceResponse, isAuthorizationTokenResponse, IAuthorizationDeviceTokenErrorResponse } from '../../../base/common/oauth.js'; +import { Emitter } from '../../../base/common/event.js'; +import { DeferredPromise, raceCancellationError } from '../../../base/common/async.js'; +import { IExtHostProgress } from '../common/extHostProgress.js'; +import { IProgressStep } from '../../../platform/progress/common/progress.js'; +import { CancellationError, isCancellationError } from '../../../base/common/errors.js'; + +interface IOAuthResult { + code: string; + state: string; +} + +interface ILoopbackServer { + /** + * The state parameter used in the OAuth flow. + */ + readonly state: string; + + /** + * Starts the server. + * @throws If the server fails to start. + * @throws If the server is already started. + */ + start(): Promise; + + /** + * Stops the server. + * @throws If the server is not started. + * @throws If the server fails to stop. + */ + stop(): Promise; + + /** + * Returns a promise that resolves to the result of the OAuth flow. + */ + waitForOAuthResponse(): Promise; +} + +class LoopbackAuthServer implements ILoopbackServer { + private readonly _server: http.Server; + private readonly _resultPromise: Promise; + + private _state = randomBytes(16).toString('base64'); + private _port: number | undefined; + + constructor(private readonly _logger: ILogger) { + const deferredPromise = new DeferredPromise(); + this._resultPromise = deferredPromise.p; + + this._server = http.createServer((req, res) => { + const reqUrl = new URL(req.url!, `http://${req.headers.host}`); + switch (reqUrl.pathname) { + case '/': { + const code = reqUrl.searchParams.get('code') ?? undefined; + const state = reqUrl.searchParams.get('state') ?? undefined; + const error = reqUrl.searchParams.get('error') ?? undefined; + if (error) { + res.writeHead(302, { location: `/?error=${reqUrl.searchParams.get('error_description')}` }); + res.end(); + deferredPromise.error(new Error(error)); + break; + } + if (!code || !state) { + res.writeHead(400); + res.end(); + break; + } + if (this.state !== state) { + res.writeHead(302, { location: `/?error=${encodeURIComponent('State does not match.')}` }); + res.end(); + deferredPromise.error(new Error('State does not match.')); + break; + } + deferredPromise.complete({ code, state }); + res.writeHead(302, { location: '/success' }); + res.end(); + break; + } + // Serve the static files + case '/success': + this._sendSuccessPage(res); + break; + default: + res.writeHead(404); + res.end(); + break; + } + }); + } + + get state(): string { return this._state; } + get redirectUri(): string { + if (this._port === undefined) { + throw new Error('Server is not started yet'); + } + return `http://127.0.0.1:${this._port}/`; + } + + private _sendSuccessPage(res: http.ServerResponse): void { + const html = getHtml(); + res.writeHead(200, { + 'Content-Type': 'text/html', + 'Content-Length': Buffer.byteLength(html, 'utf8') + }); + res.end(html); + } + + start(): Promise { + const deferredPromise = new DeferredPromise(); + if (this._server.listening) { + throw new Error('Server is already started'); + } + const portTimeout = setTimeout(() => { + deferredPromise.error(new Error('Timeout waiting for port')); + }, 5000); + this._server.on('listening', () => { + const address = this._server.address(); + if (typeof address === 'string') { + this._port = parseInt(address); + } else if (address instanceof Object) { + this._port = address.port; + } else { + throw new Error('Unable to determine port'); + } + + clearTimeout(portTimeout); + deferredPromise.complete(); + }); + this._server.on('error', err => { + if ('code' in err && err.code === 'EADDRINUSE') { + this._logger.error('Address in use, retrying with a different port...'); + setTimeout(() => { + this._server.close(); + // Best effort to use a specific port, but fallback to a random one if it is in use + this._server.listen(0, '127.0.0.1'); + }, 1000); + return; + } + clearTimeout(portTimeout); + deferredPromise.error(new Error(`Error listening to server: ${err}`)); + }); + this._server.on('close', () => { + deferredPromise.error(new Error('Closed')); + }); + // Best effort to use a specific port, but fallback to a random one if it is in use + this._server.listen(DEFAULT_AUTH_FLOW_PORT, '127.0.0.1'); + return deferredPromise.p; + } + + stop(): Promise { + const deferredPromise = new DeferredPromise(); + if (!this._server.listening) { + deferredPromise.complete(); + return deferredPromise.p; + } + this._server.close((err) => { + if (err) { + deferredPromise.error(err); + } else { + deferredPromise.complete(); + } + }); + // If the server is not closed within 5 seconds, reject the promise + setTimeout(() => { + if (!deferredPromise.isResolved) { + deferredPromise.error(new Error('Timeout waiting for server to close')); + } + }, 5000); + return deferredPromise.p; + } + + waitForOAuthResponse(): Promise { + return this._resultPromise; + } +} + +export class NodeDynamicAuthProvider extends DynamicAuthProvider { + + constructor( + extHostWindow: IExtHostWindow, + extHostUrls: IExtHostUrlsService, + initData: IExtHostInitDataService, + extHostProgress: IExtHostProgress, + loggerService: ILoggerService, + proxy: MainThreadAuthenticationShape, + serverMetadata: IAuthorizationServerMetadata, + resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined, + clientId: string, + onDidDynamicAuthProviderTokensChange: Emitter<{ authProviderId: string; clientId: string; tokens: any[] }>, + initialTokens: any[] + ) { + super( + extHostWindow, + extHostUrls, + initData, + extHostProgress, + loggerService, + proxy, + serverMetadata, + resourceMetadata, + clientId, + onDidDynamicAuthProviderTokensChange, + initialTokens + ); + + // Prepend Node-specific flows to the existing flows + if (!initData.remote.isRemote) { + // If we are not in a remote environment, we can use the loopback server for authentication + this._createFlows.push({ + label: nls.localize('loopback', "Loopback Server"), + handler: (scopes, progress, token) => this._createWithLoopbackServer(scopes, progress, token) + }); + } + + // Add device code flow support (works in all environments) + this._createFlows.push({ + label: nls.localize('device code', "Device Code"), + handler: (scopes, progress, token) => this._createWithDeviceCode(scopes, progress, token) + }); + } + + private async _createWithLoopbackServer(scopes: string[], progress: vscode.Progress, token: vscode.CancellationToken): 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); + + // Prepare the authorization request URL + const scopeString = scopes.join(' '); + 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', scopeString); + authorizationUrl.searchParams.append('code_challenge', codeChallenge); + authorizationUrl.searchParams.append('code_challenge_method', 'S256'); + if (this._resourceMetadata?.resource) { + // If a resource is specified, include it in the request + authorizationUrl.searchParams.append('resource', this._resourceMetadata.resource); + } + + // Create and start the loopback server + const server = new LoopbackAuthServer(this._logger); + try { + await server.start(); + } catch (err) { + throw new Error(`Failed to start loopback server: ${err}`); + } + + // Update the authorization URL with the actual redirect URI + authorizationUrl.searchParams.set('redirect_uri', server.redirectUri); + authorizationUrl.searchParams.set('state', server.state); + + const promise = server.waitForOAuthResponse(); + + try { + // Open the browser for user authorization + this._logger.info(`Opening authorization URL for scopes: ${scopeString}`); + this._logger.trace(`Authorization URL: ${authorizationUrl.toString()}`); + const opened = await this._extHostWindow.openUri(authorizationUrl.toString(), {}); + if (!opened) { + throw new CancellationError(); + } + progress.report({ + message: nls.localize('completeAuth', "Complete the authentication in the browser window that has opened."), + }); + + // Wait for the authorization code via the loopback server + let code: string | undefined; + try { + const response = await raceCancellationError(promise, token); + code = response.code; + } catch (err) { + if (isCancellationError(err)) { + this._logger.info('Authorization code request was cancelled by the user.'); + throw err; + } + this._logger.error(`Failed to receive authorization code: ${err}`); + throw new Error(`Failed to receive authorization code: ${err}`); + } + this._logger.info(`Authorization code received for scopes: ${scopeString}`); + + // Exchange the authorization code for tokens + const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, server.redirectUri); + return tokenResponse; + } finally { + // Clean up the server + setTimeout(() => { + void server.stop(); + }, 5000); + } + } + + private async _createWithDeviceCode(scopes: string[], progress: vscode.Progress, token: vscode.CancellationToken): Promise { + if (!this._serverMetadata.token_endpoint) { + throw new Error('Token endpoint not available in server metadata'); + } + if (!this._serverMetadata.device_authorization_endpoint) { + throw new Error('Device authorization endpoint not available in server metadata'); + } + + const deviceAuthUrl = this._serverMetadata.device_authorization_endpoint; + const scopeString = scopes.join(' '); + this._logger.info(`Starting device code flow for scopes: ${scopeString}`); + + // Step 1: Request device and user codes + const deviceCodeRequest = new URLSearchParams(); + deviceCodeRequest.append('client_id', this.clientId); + deviceCodeRequest.append('scope', scopeString); + + let deviceCodeResponse: Response; + try { + deviceCodeResponse = await fetch(deviceAuthUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + body: deviceCodeRequest.toString() + }); + } catch (error) { + this._logger.error(`Failed to request device code: ${error}`); + throw new Error(`Failed to request device code: ${error}`); + } + + if (!deviceCodeResponse.ok) { + const text = await deviceCodeResponse.text(); + throw new Error(`Device code request failed: ${deviceCodeResponse.status} ${deviceCodeResponse.statusText} - ${text}`); + } + + const deviceCodeData: IAuthorizationDeviceResponse = await deviceCodeResponse.json(); + if (!isAuthorizationDeviceResponse(deviceCodeData)) { + this._logger.error('Invalid device code response received from server'); + throw new Error('Invalid device code response received from server'); + } + this._logger.info(`Device code received: ${deviceCodeData.user_code}`); + + // Step 2: Show the device code modal + const userConfirmed = await this._proxy.$showDeviceCodeModal( + deviceCodeData.user_code, + deviceCodeData.verification_uri + ); + + if (!userConfirmed) { + throw new CancellationError(); + } + + // Step 3: Poll for token + progress.report({ + message: nls.localize('waitingForAuth', "Open [{0}]({0}) in a new tab and paste your one-time code: {1}", deviceCodeData.verification_uri, deviceCodeData.user_code) + }); + + const pollInterval = (deviceCodeData.interval || 5) * 1000; // Convert to milliseconds + const expiresAt = Date.now() + (deviceCodeData.expires_in * 1000); + + while (Date.now() < expiresAt) { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + + // Wait for the specified interval + await new Promise(resolve => setTimeout(resolve, pollInterval)); + + if (token.isCancellationRequested) { + throw new CancellationError(); + } + + // Poll the token endpoint + const tokenRequest = new URLSearchParams(); + tokenRequest.append('grant_type', 'urn:ietf:params:oauth:grant-type:device_code'); + tokenRequest.append('device_code', deviceCodeData.device_code); + tokenRequest.append('client_id', this.clientId); + + try { + const tokenResponse = await fetch(this._serverMetadata.token_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + body: tokenRequest.toString() + }); + + if (tokenResponse.ok) { + const tokenData: IAuthorizationTokenResponse = await tokenResponse.json(); + if (!isAuthorizationTokenResponse(tokenData)) { + this._logger.error('Invalid token response received from server'); + throw new Error('Invalid token response received from server'); + } + this._logger.info(`Device code flow completed successfully for scopes: ${scopeString}`); + return tokenData; + } else { + let errorData: IAuthorizationDeviceTokenErrorResponse; + try { + errorData = await tokenResponse.json(); + } catch (e) { + this._logger.error(`Failed to parse error response: ${e}`); + throw new Error(`Token request failed with status ${tokenResponse.status}: ${tokenResponse.statusText}`); + } + + // Handle known error cases + if (errorData.error === 'authorization_pending') { + // User hasn't completed authorization yet, continue polling + continue; + } else if (errorData.error === 'slow_down') { + // Server is asking us to slow down + await new Promise(resolve => setTimeout(resolve, pollInterval)); + continue; + } else if (errorData.error === 'expired_token') { + throw new Error('Device code expired. Please try again.'); + } else if (errorData.error === 'access_denied') { + throw new CancellationError(); + } else { + throw new Error(`Token request failed: ${errorData.error_description || errorData.error || 'Unknown error'}`); + } + } + } catch (error) { + if (isCancellationError(error)) { + throw error; + } + this._logger.error(`Error polling for token: ${error}`); + // Continue polling on network errors + continue; + } + } + + throw new Error('Device code flow timed out. Please try again.'); + } +} + +export class NodeExtHostAuthentication extends ExtHostAuthentication implements IExtHostAuthentication { + + protected override readonly _dynamicAuthProviderCtor = NodeDynamicAuthProvider; + + constructor( + extHostRpc: IExtHostRpcService, + initData: IExtHostInitDataService, + extHostWindow: IExtHostWindow, + extHostUrls: IExtHostUrlsService, + extHostProgress: IExtHostProgress, + extHostLoggerService: ILoggerService, + ) { + super(extHostRpc, initData, extHostWindow, extHostUrls, extHostProgress, extHostLoggerService); + } +} + +function getHtml() { + return ` + + + + + GitHub Authentication - Sign In + + + + + + + Visual Studio Code + +
+
+ You are signed in now and can close this page. +
+
+ An error occurred while signing in: +
+
+
+ + +`; +} diff --git a/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts b/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts index 7c01008994d..21668a51aa0 100644 --- a/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts +++ b/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts @@ -43,6 +43,7 @@ import { ISecretStorageService } from '../../../../platform/secrets/common/secre import { TestSecretStorageService } from '../../../../platform/secrets/test/common/testSecretStorageService.js'; import { IDynamicAuthenticationProviderStorageService } from '../../../services/authentication/common/dynamicAuthenticationProviderStorage.js'; import { DynamicAuthenticationProviderStorageService } from '../../../services/authentication/browser/dynamicAuthenticationProviderStorageService.js'; +import { ExtHostProgress } from '../../common/extHostProgress.js'; class AuthQuickPick { private listener: ((e: IQuickPickDidAcceptEvent) => any) | undefined; @@ -159,6 +160,7 @@ suite('ExtHostAuthentication', () => { } as any, new ExtHostWindow(initData, rpcProtocol), new ExtHostUrls(rpcProtocol), + new ExtHostProgress(rpcProtocol), new TestLoggerService(), ); rpcProtocol.set(ExtHostContext.ExtHostAuthentication, extHostAuthentication); diff --git a/src/vs/workbench/api/worker/extHost.worker.services.ts b/src/vs/workbench/api/worker/extHost.worker.services.ts index 797ebc4a065..d6055bcf0f6 100644 --- a/src/vs/workbench/api/worker/extHost.worker.services.ts +++ b/src/vs/workbench/api/worker/extHost.worker.services.ts @@ -6,6 +6,7 @@ import { SyncDescriptor } from '../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; +import { ExtHostAuthentication, IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { IExtHostExtensionService } from '../common/extHostExtensionService.js'; import { ExtHostLogService } from '../common/extHostLogService.js'; import { ExtensionStoragePaths, IExtensionStoragePaths } from '../common/extHostStoragePaths.js'; @@ -19,6 +20,7 @@ import { ExtHostExtensionService } from './extHostExtensionService.js'; // ######################################################################### registerSingleton(ILogService, new SyncDescriptor(ExtHostLogService, [true], true)); +registerSingleton(IExtHostAuthentication, ExtHostAuthentication, InstantiationType.Eager); registerSingleton(IExtHostExtensionService, ExtHostExtensionService, InstantiationType.Eager); registerSingleton(IExtensionStoragePaths, ExtensionStoragePaths, InstantiationType.Eager); registerSingleton(IExtHostTelemetry, new SyncDescriptor(ExtHostTelemetry, [true], true));