Path: blob/main/src/vs/workbench/api/node/extHostAuthentication.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as nls from '../../../nls.js';6import type * as vscode from 'vscode';7import { URL } from 'url';8import { ExtHostAuthentication, DynamicAuthProvider, IExtHostAuthentication } from '../common/extHostAuthentication.js';9import { IExtHostRpcService } from '../common/extHostRpcService.js';10import { IExtHostInitDataService } from '../common/extHostInitDataService.js';11import { IExtHostWindow } from '../common/extHostWindow.js';12import { IExtHostUrlsService } from '../common/extHostUrls.js';13import { ILoggerService, ILogService } from '../../../platform/log/common/log.js';14import { MainThreadAuthenticationShape } from '../common/extHost.protocol.js';15import { IAuthorizationServerMetadata, IAuthorizationProtectedResourceMetadata, IAuthorizationTokenResponse, IAuthorizationDeviceResponse, isAuthorizationDeviceResponse, isAuthorizationTokenResponse, IAuthorizationDeviceTokenErrorResponse, AuthorizationErrorType, AuthorizationDeviceCodeErrorType } from '../../../base/common/oauth.js';16import { Emitter } from '../../../base/common/event.js';17import { raceCancellationError } from '../../../base/common/async.js';18import { IExtHostProgress } from '../common/extHostProgress.js';19import { IProgressStep } from '../../../platform/progress/common/progress.js';20import { CancellationError, isCancellationError } from '../../../base/common/errors.js';21import { URI } from '../../../base/common/uri.js';22import { LoopbackAuthServer } from './loopbackServer.js';2324export class NodeDynamicAuthProvider extends DynamicAuthProvider {2526constructor(27extHostWindow: IExtHostWindow,28extHostUrls: IExtHostUrlsService,29initData: IExtHostInitDataService,30extHostProgress: IExtHostProgress,31loggerService: ILoggerService,32proxy: MainThreadAuthenticationShape,33authorizationServer: URI,34serverMetadata: IAuthorizationServerMetadata,35resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined,36clientId: string,37clientSecret: string | undefined,38onDidDynamicAuthProviderTokensChange: Emitter<{ authProviderId: string; clientId: string; tokens: any[] }>,39initialTokens: any[]40) {41super(42extHostWindow,43extHostUrls,44initData,45extHostProgress,46loggerService,47proxy,48authorizationServer,49serverMetadata,50resourceMetadata,51clientId,52clientSecret,53onDidDynamicAuthProviderTokensChange,54initialTokens55);5657// Prepend Node-specific flows to the existing flows58if (!initData.remote.isRemote && serverMetadata.authorization_endpoint) {59// If we are not in a remote environment, we can use the loopback server for authentication60this._createFlows.unshift({61label: nls.localize('loopback', "Loopback Server"),62handler: (scopes, progress, token) => this._createWithLoopbackServer(scopes, progress, token)63});64}6566// Add device code flow to the end since it's not as streamlined67if (serverMetadata.device_authorization_endpoint) {68this._createFlows.push({69label: nls.localize('device code', "Device Code"),70handler: (scopes, progress, token) => this._createWithDeviceCode(scopes, progress, token)71});72}73}7475private async _createWithLoopbackServer(scopes: string[], progress: vscode.Progress<IProgressStep>, token: vscode.CancellationToken): Promise<IAuthorizationTokenResponse> {76if (!this._serverMetadata.authorization_endpoint) {77throw new Error('Authorization Endpoint required');78}79if (!this._serverMetadata.token_endpoint) {80throw new Error('Token endpoint not available in server metadata');81}8283// Generate PKCE code verifier (random string) and code challenge (SHA-256 hash of verifier)84const codeVerifier = this.generateRandomString(64);85const codeChallenge = await this.generateCodeChallenge(codeVerifier);8687// Generate a random state value to prevent CSRF88const nonce = this.generateRandomString(32);89const callbackUri = URI.parse(`${this._initData.environment.appUriScheme}://dynamicauthprovider/${this.authorizationServer.authority}/redirect?nonce=${nonce}`);90let appUri: URI;91try {92appUri = await this._extHostUrls.createAppUri(callbackUri);93} catch (error) {94throw new Error(`Failed to create external URI: ${error}`);95}9697// Prepare the authorization request URL98const authorizationUrl = new URL(this._serverMetadata.authorization_endpoint);99authorizationUrl.searchParams.append('client_id', this._clientId);100authorizationUrl.searchParams.append('response_type', 'code');101authorizationUrl.searchParams.append('code_challenge', codeChallenge);102authorizationUrl.searchParams.append('code_challenge_method', 'S256');103const scopeString = scopes.join(' ');104if (scopeString) {105authorizationUrl.searchParams.append('scope', scopeString);106}107if (this._resourceMetadata?.resource) {108// If a resource is specified, include it in the request109authorizationUrl.searchParams.append('resource', this._resourceMetadata.resource);110}111112// Create and start the loopback server113const server = new LoopbackAuthServer(114this._logger,115appUri,116this._initData.environment.appName117);118try {119await server.start();120} catch (err) {121throw new Error(`Failed to start loopback server: ${err}`);122}123124// Update the authorization URL with the actual redirect URI125authorizationUrl.searchParams.set('redirect_uri', server.redirectUri);126authorizationUrl.searchParams.set('state', server.state);127128const promise = server.waitForOAuthResponse();129// Set up a Uri Handler but it's just to redirect not to handle the code130void this._proxy.$waitForUriHandler(appUri);131132try {133// Open the browser for user authorization134this._logger.info(`Opening authorization URL for scopes: ${scopeString}`);135this._logger.trace(`Authorization URL: ${authorizationUrl.toString()}`);136const opened = await this._extHostWindow.openUri(authorizationUrl.toString(), {});137if (!opened) {138throw new CancellationError();139}140progress.report({141message: nls.localize('completeAuth', "Complete the authentication in the browser window that has opened."),142});143144// Wait for the authorization code via the loopback server145let code: string | undefined;146try {147const response = await raceCancellationError(promise, token);148code = response.code;149} catch (err) {150if (isCancellationError(err)) {151this._logger.info('Authorization code request was cancelled by the user.');152throw err;153}154this._logger.error(`Failed to receive authorization code: ${err}`);155throw new Error(`Failed to receive authorization code: ${err}`);156}157this._logger.info(`Authorization code received for scopes: ${scopeString}`);158159// Exchange the authorization code for tokens160const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, server.redirectUri);161return tokenResponse;162} finally {163// Clean up the server164setTimeout(() => {165void server.stop();166}, 5000);167}168}169170private async _createWithDeviceCode(scopes: string[], progress: vscode.Progress<IProgressStep>, token: vscode.CancellationToken): Promise<IAuthorizationTokenResponse> {171if (!this._serverMetadata.token_endpoint) {172throw new Error('Token endpoint not available in server metadata');173}174if (!this._serverMetadata.device_authorization_endpoint) {175throw new Error('Device authorization endpoint not available in server metadata');176}177178const deviceAuthUrl = this._serverMetadata.device_authorization_endpoint;179const scopeString = scopes.join(' ');180this._logger.info(`Starting device code flow for scopes: ${scopeString}`);181182// Step 1: Request device and user codes183const deviceCodeRequest = new URLSearchParams();184deviceCodeRequest.append('client_id', this._clientId);185if (scopeString) {186deviceCodeRequest.append('scope', scopeString);187}188if (this._resourceMetadata?.resource) {189// If a resource is specified, include it in the request190deviceCodeRequest.append('resource', this._resourceMetadata.resource);191}192193let deviceCodeResponse: Response;194try {195deviceCodeResponse = await fetch(deviceAuthUrl, {196method: 'POST',197headers: {198'Content-Type': 'application/x-www-form-urlencoded',199'Accept': 'application/json'200},201body: deviceCodeRequest.toString()202});203} catch (error) {204this._logger.error(`Failed to request device code: ${error}`);205throw new Error(`Failed to request device code: ${error}`);206}207208if (!deviceCodeResponse.ok) {209const text = await deviceCodeResponse.text();210throw new Error(`Device code request failed: ${deviceCodeResponse.status} ${deviceCodeResponse.statusText} - ${text}`);211}212213const deviceCodeData: IAuthorizationDeviceResponse = await deviceCodeResponse.json();214if (!isAuthorizationDeviceResponse(deviceCodeData)) {215this._logger.error('Invalid device code response received from server');216throw new Error('Invalid device code response received from server');217}218this._logger.info(`Device code received: ${deviceCodeData.user_code}`);219220// Step 2: Show the device code modal221const userConfirmed = await this._proxy.$showDeviceCodeModal(222deviceCodeData.user_code,223deviceCodeData.verification_uri224);225226if (!userConfirmed) {227throw new CancellationError();228}229230// Step 3: Poll for token231progress.report({232message: nls.localize('waitingForAuth', "Open [{0}]({0}) in a new tab and paste your one-time code: {1}", deviceCodeData.verification_uri, deviceCodeData.user_code)233});234235const pollInterval = (deviceCodeData.interval || 5) * 1000; // Convert to milliseconds236const expiresAt = Date.now() + (deviceCodeData.expires_in * 1000);237238while (Date.now() < expiresAt) {239if (token.isCancellationRequested) {240throw new CancellationError();241}242243// Wait for the specified interval244await new Promise(resolve => setTimeout(resolve, pollInterval));245246if (token.isCancellationRequested) {247throw new CancellationError();248}249250// Poll the token endpoint251const tokenRequest = new URLSearchParams();252tokenRequest.append('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');253tokenRequest.append('device_code', deviceCodeData.device_code);254tokenRequest.append('client_id', this._clientId);255256// Add resource indicator if available (RFC 8707)257if (this._resourceMetadata?.resource) {258tokenRequest.append('resource', this._resourceMetadata.resource);259}260261try {262const tokenResponse = await fetch(this._serverMetadata.token_endpoint, {263method: 'POST',264headers: {265'Content-Type': 'application/x-www-form-urlencoded',266'Accept': 'application/json'267},268body: tokenRequest.toString()269});270271if (tokenResponse.ok) {272const tokenData: IAuthorizationTokenResponse = await tokenResponse.json();273if (!isAuthorizationTokenResponse(tokenData)) {274this._logger.error('Invalid token response received from server');275throw new Error('Invalid token response received from server');276}277this._logger.info(`Device code flow completed successfully for scopes: ${scopeString}`);278return tokenData;279} else {280let errorData: IAuthorizationDeviceTokenErrorResponse;281try {282errorData = await tokenResponse.json();283} catch (e) {284this._logger.error(`Failed to parse error response: ${e}`);285throw new Error(`Token request failed with status ${tokenResponse.status}: ${tokenResponse.statusText}`);286}287288// Handle known error cases289if (errorData.error === AuthorizationDeviceCodeErrorType.AuthorizationPending) {290// User hasn't completed authorization yet, continue polling291continue;292} else if (errorData.error === AuthorizationDeviceCodeErrorType.SlowDown) {293// Server is asking us to slow down294await new Promise(resolve => setTimeout(resolve, pollInterval));295continue;296} else if (errorData.error === AuthorizationDeviceCodeErrorType.ExpiredToken) {297throw new Error('Device code expired. Please try again.');298} else if (errorData.error === AuthorizationDeviceCodeErrorType.AccessDenied) {299throw new CancellationError();300} else if (errorData.error === AuthorizationErrorType.InvalidClient) {301this._logger.warn(`Client ID (${this._clientId}) was invalid, generated a new one.`);302await this._generateNewClientId();303throw new Error(`Client ID was invalid, generated a new one. Please try again.`);304} else {305throw new Error(`Token request failed: ${errorData.error_description || errorData.error || 'Unknown error'}`);306}307}308} catch (error) {309if (isCancellationError(error)) {310throw error;311}312throw new Error(`Error polling for token: ${error}`);313}314}315316throw new Error('Device code flow timed out. Please try again.');317}318}319320export class NodeExtHostAuthentication extends ExtHostAuthentication implements IExtHostAuthentication {321322protected override readonly _dynamicAuthProviderCtor = NodeDynamicAuthProvider;323324constructor(325extHostRpc: IExtHostRpcService,326initData: IExtHostInitDataService,327extHostWindow: IExtHostWindow,328extHostUrls: IExtHostUrlsService,329extHostProgress: IExtHostProgress,330extHostLoggerService: ILoggerService,331extHostLogService: ILogService332) {333super(extHostRpc, initData, extHostWindow, extHostUrls, extHostProgress, extHostLoggerService, extHostLogService);334}335}336337338