Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/api/node/extHostAuthentication.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as nls from '../../../nls.js';
7
import type * as vscode from 'vscode';
8
import { URL } from 'url';
9
import { ExtHostAuthentication, DynamicAuthProvider, IExtHostAuthentication } from '../common/extHostAuthentication.js';
10
import { IExtHostRpcService } from '../common/extHostRpcService.js';
11
import { IExtHostInitDataService } from '../common/extHostInitDataService.js';
12
import { IExtHostWindow } from '../common/extHostWindow.js';
13
import { IExtHostUrlsService } from '../common/extHostUrls.js';
14
import { ILoggerService, ILogService } from '../../../platform/log/common/log.js';
15
import { MainThreadAuthenticationShape } from '../common/extHost.protocol.js';
16
import { IAuthorizationServerMetadata, IAuthorizationProtectedResourceMetadata, IAuthorizationTokenResponse, IAuthorizationDeviceResponse, isAuthorizationDeviceResponse, isAuthorizationTokenResponse, IAuthorizationDeviceTokenErrorResponse, AuthorizationErrorType, AuthorizationDeviceCodeErrorType } from '../../../base/common/oauth.js';
17
import { Emitter } from '../../../base/common/event.js';
18
import { raceCancellationError } from '../../../base/common/async.js';
19
import { IExtHostProgress } from '../common/extHostProgress.js';
20
import { IProgressStep } from '../../../platform/progress/common/progress.js';
21
import { CancellationError, isCancellationError } from '../../../base/common/errors.js';
22
import { URI } from '../../../base/common/uri.js';
23
import { LoopbackAuthServer } from './loopbackServer.js';
24
25
export class NodeDynamicAuthProvider extends DynamicAuthProvider {
26
27
constructor(
28
extHostWindow: IExtHostWindow,
29
extHostUrls: IExtHostUrlsService,
30
initData: IExtHostInitDataService,
31
extHostProgress: IExtHostProgress,
32
loggerService: ILoggerService,
33
proxy: MainThreadAuthenticationShape,
34
authorizationServer: URI,
35
serverMetadata: IAuthorizationServerMetadata,
36
resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined,
37
clientId: string,
38
clientSecret: string | undefined,
39
onDidDynamicAuthProviderTokensChange: Emitter<{ authProviderId: string; clientId: string; tokens: any[] }>,
40
initialTokens: any[]
41
) {
42
super(
43
extHostWindow,
44
extHostUrls,
45
initData,
46
extHostProgress,
47
loggerService,
48
proxy,
49
authorizationServer,
50
serverMetadata,
51
resourceMetadata,
52
clientId,
53
clientSecret,
54
onDidDynamicAuthProviderTokensChange,
55
initialTokens
56
);
57
58
// Prepend Node-specific flows to the existing flows
59
if (!initData.remote.isRemote && serverMetadata.authorization_endpoint) {
60
// If we are not in a remote environment, we can use the loopback server for authentication
61
this._createFlows.unshift({
62
label: nls.localize('loopback', "Loopback Server"),
63
handler: (scopes, progress, token) => this._createWithLoopbackServer(scopes, progress, token)
64
});
65
}
66
67
// Add device code flow to the end since it's not as streamlined
68
if (serverMetadata.device_authorization_endpoint) {
69
this._createFlows.push({
70
label: nls.localize('device code', "Device Code"),
71
handler: (scopes, progress, token) => this._createWithDeviceCode(scopes, progress, token)
72
});
73
}
74
}
75
76
private async _createWithLoopbackServer(scopes: string[], progress: vscode.Progress<IProgressStep>, token: vscode.CancellationToken): Promise<IAuthorizationTokenResponse> {
77
if (!this._serverMetadata.authorization_endpoint) {
78
throw new Error('Authorization Endpoint required');
79
}
80
if (!this._serverMetadata.token_endpoint) {
81
throw new Error('Token endpoint not available in server metadata');
82
}
83
84
// Generate PKCE code verifier (random string) and code challenge (SHA-256 hash of verifier)
85
const codeVerifier = this.generateRandomString(64);
86
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
87
88
// Generate a random state value to prevent CSRF
89
const nonce = this.generateRandomString(32);
90
const callbackUri = URI.parse(`${this._initData.environment.appUriScheme}://dynamicauthprovider/${this.authorizationServer.authority}/redirect?nonce=${nonce}`);
91
let appUri: URI;
92
try {
93
appUri = await this._extHostUrls.createAppUri(callbackUri);
94
} catch (error) {
95
throw new Error(`Failed to create external URI: ${error}`);
96
}
97
98
// Prepare the authorization request URL
99
const authorizationUrl = new URL(this._serverMetadata.authorization_endpoint);
100
authorizationUrl.searchParams.append('client_id', this._clientId);
101
authorizationUrl.searchParams.append('response_type', 'code');
102
authorizationUrl.searchParams.append('code_challenge', codeChallenge);
103
authorizationUrl.searchParams.append('code_challenge_method', 'S256');
104
const scopeString = scopes.join(' ');
105
if (scopeString) {
106
authorizationUrl.searchParams.append('scope', scopeString);
107
}
108
if (this._resourceMetadata?.resource) {
109
// If a resource is specified, include it in the request
110
authorizationUrl.searchParams.append('resource', this._resourceMetadata.resource);
111
}
112
113
// Create and start the loopback server
114
const server = new LoopbackAuthServer(
115
this._logger,
116
appUri,
117
this._initData.environment.appName
118
);
119
try {
120
await server.start();
121
} catch (err) {
122
throw new Error(`Failed to start loopback server: ${err}`);
123
}
124
125
// Update the authorization URL with the actual redirect URI
126
authorizationUrl.searchParams.set('redirect_uri', server.redirectUri);
127
authorizationUrl.searchParams.set('state', server.state);
128
129
const promise = server.waitForOAuthResponse();
130
// Set up a Uri Handler but it's just to redirect not to handle the code
131
void this._proxy.$waitForUriHandler(appUri);
132
133
try {
134
// Open the browser for user authorization
135
this._logger.info(`Opening authorization URL for scopes: ${scopeString}`);
136
this._logger.trace(`Authorization URL: ${authorizationUrl.toString()}`);
137
const opened = await this._extHostWindow.openUri(authorizationUrl.toString(), {});
138
if (!opened) {
139
throw new CancellationError();
140
}
141
progress.report({
142
message: nls.localize('completeAuth', "Complete the authentication in the browser window that has opened."),
143
});
144
145
// Wait for the authorization code via the loopback server
146
let code: string | undefined;
147
try {
148
const response = await raceCancellationError(promise, token);
149
code = response.code;
150
} catch (err) {
151
if (isCancellationError(err)) {
152
this._logger.info('Authorization code request was cancelled by the user.');
153
throw err;
154
}
155
this._logger.error(`Failed to receive authorization code: ${err}`);
156
throw new Error(`Failed to receive authorization code: ${err}`);
157
}
158
this._logger.info(`Authorization code received for scopes: ${scopeString}`);
159
160
// Exchange the authorization code for tokens
161
const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, server.redirectUri);
162
return tokenResponse;
163
} finally {
164
// Clean up the server
165
setTimeout(() => {
166
void server.stop();
167
}, 5000);
168
}
169
}
170
171
private async _createWithDeviceCode(scopes: string[], progress: vscode.Progress<IProgressStep>, token: vscode.CancellationToken): Promise<IAuthorizationTokenResponse> {
172
if (!this._serverMetadata.token_endpoint) {
173
throw new Error('Token endpoint not available in server metadata');
174
}
175
if (!this._serverMetadata.device_authorization_endpoint) {
176
throw new Error('Device authorization endpoint not available in server metadata');
177
}
178
179
const deviceAuthUrl = this._serverMetadata.device_authorization_endpoint;
180
const scopeString = scopes.join(' ');
181
this._logger.info(`Starting device code flow for scopes: ${scopeString}`);
182
183
// Step 1: Request device and user codes
184
const deviceCodeRequest = new URLSearchParams();
185
deviceCodeRequest.append('client_id', this._clientId);
186
if (scopeString) {
187
deviceCodeRequest.append('scope', scopeString);
188
}
189
if (this._resourceMetadata?.resource) {
190
// If a resource is specified, include it in the request
191
deviceCodeRequest.append('resource', this._resourceMetadata.resource);
192
}
193
194
let deviceCodeResponse: Response;
195
try {
196
deviceCodeResponse = await fetch(deviceAuthUrl, {
197
method: 'POST',
198
headers: {
199
'Content-Type': 'application/x-www-form-urlencoded',
200
'Accept': 'application/json'
201
},
202
body: deviceCodeRequest.toString()
203
});
204
} catch (error) {
205
this._logger.error(`Failed to request device code: ${error}`);
206
throw new Error(`Failed to request device code: ${error}`);
207
}
208
209
if (!deviceCodeResponse.ok) {
210
const text = await deviceCodeResponse.text();
211
throw new Error(`Device code request failed: ${deviceCodeResponse.status} ${deviceCodeResponse.statusText} - ${text}`);
212
}
213
214
const deviceCodeData: IAuthorizationDeviceResponse = await deviceCodeResponse.json();
215
if (!isAuthorizationDeviceResponse(deviceCodeData)) {
216
this._logger.error('Invalid device code response received from server');
217
throw new Error('Invalid device code response received from server');
218
}
219
this._logger.info(`Device code received: ${deviceCodeData.user_code}`);
220
221
// Step 2: Show the device code modal
222
const userConfirmed = await this._proxy.$showDeviceCodeModal(
223
deviceCodeData.user_code,
224
deviceCodeData.verification_uri
225
);
226
227
if (!userConfirmed) {
228
throw new CancellationError();
229
}
230
231
// Step 3: Poll for token
232
progress.report({
233
message: nls.localize('waitingForAuth', "Open [{0}]({0}) in a new tab and paste your one-time code: {1}", deviceCodeData.verification_uri, deviceCodeData.user_code)
234
});
235
236
const pollInterval = (deviceCodeData.interval || 5) * 1000; // Convert to milliseconds
237
const expiresAt = Date.now() + (deviceCodeData.expires_in * 1000);
238
239
while (Date.now() < expiresAt) {
240
if (token.isCancellationRequested) {
241
throw new CancellationError();
242
}
243
244
// Wait for the specified interval
245
await new Promise(resolve => setTimeout(resolve, pollInterval));
246
247
if (token.isCancellationRequested) {
248
throw new CancellationError();
249
}
250
251
// Poll the token endpoint
252
const tokenRequest = new URLSearchParams();
253
tokenRequest.append('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
254
tokenRequest.append('device_code', deviceCodeData.device_code);
255
tokenRequest.append('client_id', this._clientId);
256
257
// Add resource indicator if available (RFC 8707)
258
if (this._resourceMetadata?.resource) {
259
tokenRequest.append('resource', this._resourceMetadata.resource);
260
}
261
262
try {
263
const tokenResponse = await fetch(this._serverMetadata.token_endpoint, {
264
method: 'POST',
265
headers: {
266
'Content-Type': 'application/x-www-form-urlencoded',
267
'Accept': 'application/json'
268
},
269
body: tokenRequest.toString()
270
});
271
272
if (tokenResponse.ok) {
273
const tokenData: IAuthorizationTokenResponse = await tokenResponse.json();
274
if (!isAuthorizationTokenResponse(tokenData)) {
275
this._logger.error('Invalid token response received from server');
276
throw new Error('Invalid token response received from server');
277
}
278
this._logger.info(`Device code flow completed successfully for scopes: ${scopeString}`);
279
return tokenData;
280
} else {
281
let errorData: IAuthorizationDeviceTokenErrorResponse;
282
try {
283
errorData = await tokenResponse.json();
284
} catch (e) {
285
this._logger.error(`Failed to parse error response: ${e}`);
286
throw new Error(`Token request failed with status ${tokenResponse.status}: ${tokenResponse.statusText}`);
287
}
288
289
// Handle known error cases
290
if (errorData.error === AuthorizationDeviceCodeErrorType.AuthorizationPending) {
291
// User hasn't completed authorization yet, continue polling
292
continue;
293
} else if (errorData.error === AuthorizationDeviceCodeErrorType.SlowDown) {
294
// Server is asking us to slow down
295
await new Promise(resolve => setTimeout(resolve, pollInterval));
296
continue;
297
} else if (errorData.error === AuthorizationDeviceCodeErrorType.ExpiredToken) {
298
throw new Error('Device code expired. Please try again.');
299
} else if (errorData.error === AuthorizationDeviceCodeErrorType.AccessDenied) {
300
throw new CancellationError();
301
} else if (errorData.error === AuthorizationErrorType.InvalidClient) {
302
this._logger.warn(`Client ID (${this._clientId}) was invalid, generated a new one.`);
303
await this._generateNewClientId();
304
throw new Error(`Client ID was invalid, generated a new one. Please try again.`);
305
} else {
306
throw new Error(`Token request failed: ${errorData.error_description || errorData.error || 'Unknown error'}`);
307
}
308
}
309
} catch (error) {
310
if (isCancellationError(error)) {
311
throw error;
312
}
313
throw new Error(`Error polling for token: ${error}`);
314
}
315
}
316
317
throw new Error('Device code flow timed out. Please try again.');
318
}
319
}
320
321
export class NodeExtHostAuthentication extends ExtHostAuthentication implements IExtHostAuthentication {
322
323
protected override readonly _dynamicAuthProviderCtor = NodeDynamicAuthProvider;
324
325
constructor(
326
extHostRpc: IExtHostRpcService,
327
initData: IExtHostInitDataService,
328
extHostWindow: IExtHostWindow,
329
extHostUrls: IExtHostUrlsService,
330
extHostProgress: IExtHostProgress,
331
extHostLoggerService: ILoggerService,
332
extHostLogService: ILogService
333
) {
334
super(extHostRpc, initData, extHostWindow, extHostUrls, extHostProgress, extHostLoggerService, extHostLogService);
335
}
336
}
337
338