Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/api/common/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 type * as vscode from 'vscode';
7
import * as nls from '../../../nls.js';
8
import { Emitter, Event } from '../../../base/common/event.js';
9
import { MainContext, MainThreadAuthenticationShape, ExtHostAuthenticationShape } from './extHost.protocol.js';
10
import { Disposable, ProgressLocation } from './extHostTypes.js';
11
import { IExtensionDescription, ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js';
12
import { INTERNAL_AUTH_PROVIDER_PREFIX, isAuthenticationWWWAuthenticateRequest } from '../../services/authentication/common/authentication.js';
13
import { createDecorator } from '../../../platform/instantiation/common/instantiation.js';
14
import { IExtHostRpcService } from './extHostRpcService.js';
15
import { URI, UriComponents } from '../../../base/common/uri.js';
16
import { AuthorizationErrorType, fetchDynamicRegistration, getClaimsFromJWT, IAuthorizationJWTClaims, IAuthorizationProtectedResourceMetadata, IAuthorizationServerMetadata, IAuthorizationTokenResponse, isAuthorizationErrorResponse, isAuthorizationTokenResponse } from '../../../base/common/oauth.js';
17
import { IExtHostWindow } from './extHostWindow.js';
18
import { IExtHostInitDataService } from './extHostInitDataService.js';
19
import { ILogger, ILoggerService, ILogService } from '../../../platform/log/common/log.js';
20
import { autorun, derivedOpts, IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js';
21
import { stringHash } from '../../../base/common/hash.js';
22
import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
23
import { IExtHostUrlsService } from './extHostUrls.js';
24
import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js';
25
import { equals as arraysEqual } from '../../../base/common/arrays.js';
26
import { IExtHostProgress } from './extHostProgress.js';
27
import { IProgressStep } from '../../../platform/progress/common/progress.js';
28
import { CancellationError, isCancellationError } from '../../../base/common/errors.js';
29
import { raceCancellationError, SequencerByKey } from '../../../base/common/async.js';
30
31
export interface IExtHostAuthentication extends ExtHostAuthentication { }
32
export const IExtHostAuthentication = createDecorator<IExtHostAuthentication>('IExtHostAuthentication');
33
34
interface ProviderWithMetadata {
35
label: string;
36
provider: vscode.AuthenticationProvider;
37
disposable?: vscode.Disposable;
38
options: vscode.AuthenticationProviderOptions;
39
}
40
41
export class ExtHostAuthentication implements ExtHostAuthenticationShape {
42
43
declare _serviceBrand: undefined;
44
45
protected readonly _dynamicAuthProviderCtor = DynamicAuthProvider;
46
47
private _proxy: MainThreadAuthenticationShape;
48
private _authenticationProviders: Map<string, ProviderWithMetadata> = new Map<string, ProviderWithMetadata>();
49
private _providerOperations = new SequencerByKey<string>();
50
51
private _onDidChangeSessions = new Emitter<vscode.AuthenticationSessionsChangeEvent & { extensionIdFilter?: string[] }>();
52
private _getSessionTaskSingler = new TaskSingler<vscode.AuthenticationSession | undefined>();
53
54
private _onDidDynamicAuthProviderTokensChange = new Emitter<{ authProviderId: string; clientId: string; tokens: IAuthorizationToken[] }>();
55
56
constructor(
57
@IExtHostRpcService extHostRpc: IExtHostRpcService,
58
@IExtHostInitDataService private readonly _initData: IExtHostInitDataService,
59
@IExtHostWindow private readonly _extHostWindow: IExtHostWindow,
60
@IExtHostUrlsService private readonly _extHostUrls: IExtHostUrlsService,
61
@IExtHostProgress private readonly _extHostProgress: IExtHostProgress,
62
@ILoggerService private readonly _extHostLoggerService: ILoggerService,
63
@ILogService private readonly _logService: ILogService,
64
) {
65
this._proxy = extHostRpc.getProxy(MainContext.MainThreadAuthentication);
66
}
67
68
/**
69
* This sets up an event that will fire when the auth sessions change with a built-in filter for the extensionId
70
* if a session change only affects a specific extension.
71
* @param extensionId The extension that is interested in the event.
72
* @returns An event with a built-in filter for the extensionId
73
*/
74
getExtensionScopedSessionsEvent(extensionId: string): Event<vscode.AuthenticationSessionsChangeEvent> {
75
const normalizedExtensionId = extensionId.toLowerCase();
76
return Event.chain(this._onDidChangeSessions.event, ($) => $
77
.filter(e => !e.extensionIdFilter || e.extensionIdFilter.includes(normalizedExtensionId))
78
.map(e => ({ provider: e.provider }))
79
);
80
}
81
82
async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationWWWAuthenticateRequest, options: vscode.AuthenticationGetSessionOptions & ({ createIfNone: true } | { forceNewSession: true } | { forceNewSession: vscode.AuthenticationForceNewSessionOptions })): Promise<vscode.AuthenticationSession>;
83
async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationWWWAuthenticateRequest, options: vscode.AuthenticationGetSessionOptions & { forceNewSession: true }): Promise<vscode.AuthenticationSession>;
84
async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationWWWAuthenticateRequest, options: vscode.AuthenticationGetSessionOptions & { forceNewSession: vscode.AuthenticationForceNewSessionOptions }): Promise<vscode.AuthenticationSession>;
85
async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationWWWAuthenticateRequest, options: vscode.AuthenticationGetSessionOptions): Promise<vscode.AuthenticationSession | undefined>;
86
async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationWWWAuthenticateRequest, options: vscode.AuthenticationGetSessionOptions = {}): Promise<vscode.AuthenticationSession | undefined> {
87
const extensionId = ExtensionIdentifier.toKey(requestingExtension.identifier);
88
const keys: (keyof vscode.AuthenticationGetSessionOptions)[] = Object.keys(options) as (keyof vscode.AuthenticationGetSessionOptions)[];
89
const optionsStr = keys.sort().map(key => `${key}:${!!options[key]}`).join(', ');
90
91
// old shape, remove next milestone
92
if (
93
'challenge' in scopesOrRequest
94
&& typeof scopesOrRequest.challenge === 'string'
95
&& !scopesOrRequest.wwwAuthenticate
96
) {
97
scopesOrRequest = {
98
wwwAuthenticate: scopesOrRequest.challenge,
99
scopes: scopesOrRequest.scopes
100
};
101
}
102
103
let singlerKey: string;
104
if (isAuthenticationWWWAuthenticateRequest(scopesOrRequest)) {
105
const challenge = scopesOrRequest as vscode.AuthenticationWWWAuthenticateRequest;
106
const challengeStr = challenge.wwwAuthenticate;
107
const scopesStr = challenge.scopes ? [...challenge.scopes].sort().join(' ') : '';
108
singlerKey = `${extensionId} ${providerId} challenge:${challengeStr} ${scopesStr} ${optionsStr}`;
109
} else {
110
const sortedScopes = [...scopesOrRequest].sort().join(' ');
111
singlerKey = `${extensionId} ${providerId} ${sortedScopes} ${optionsStr}`;
112
}
113
114
return await this._getSessionTaskSingler.getOrCreate(singlerKey, async () => {
115
const extensionName = requestingExtension.displayName || requestingExtension.name;
116
return this._proxy.$getSession(providerId, scopesOrRequest, extensionId, extensionName, options);
117
});
118
}
119
120
async getAccounts(providerId: string) {
121
return await this._proxy.$getAccounts(providerId);
122
}
123
124
registerAuthenticationProvider(id: string, label: string, provider: vscode.AuthenticationProvider, options?: vscode.AuthenticationProviderOptions): vscode.Disposable {
125
// register
126
void this._providerOperations.queue(id, async () => {
127
// This use to be synchronous, but that wasn't an accurate representation because the main thread
128
// may have unregistered the provider in the meantime. I don't see how this could really be done
129
// synchronously, so we just say first one wins.
130
if (this._authenticationProviders.get(id)) {
131
this._logService.error(`An authentication provider with id '${id}' is already registered. The existing provider will not be replaced.`);
132
return;
133
}
134
const listener = provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(id, e));
135
this._authenticationProviders.set(id, { label, provider, disposable: listener, options: options ?? { supportsMultipleAccounts: false } });
136
await this._proxy.$registerAuthenticationProvider(id, label, options?.supportsMultipleAccounts ?? false, options?.supportedAuthorizationServers, options?.supportsChallenges);
137
});
138
139
// unregister
140
return new Disposable(() => {
141
void this._providerOperations.queue(id, async () => {
142
const providerData = this._authenticationProviders.get(id);
143
if (providerData) {
144
providerData.disposable?.dispose();
145
this._authenticationProviders.delete(id);
146
await this._proxy.$unregisterAuthenticationProvider(id);
147
}
148
});
149
});
150
}
151
152
$createSession(providerId: string, scopes: string[], options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {
153
return this._providerOperations.queue(providerId, async () => {
154
const providerData = this._authenticationProviders.get(providerId);
155
if (providerData) {
156
options.authorizationServer = URI.revive(options.authorizationServer);
157
return await providerData.provider.createSession(scopes, options);
158
}
159
160
throw new Error(`Unable to find authentication provider with handle: ${providerId}`);
161
});
162
}
163
164
$removeSession(providerId: string, sessionId: string): Promise<void> {
165
return this._providerOperations.queue(providerId, async () => {
166
const providerData = this._authenticationProviders.get(providerId);
167
if (providerData) {
168
return await providerData.provider.removeSession(sessionId);
169
}
170
171
throw new Error(`Unable to find authentication provider with handle: ${providerId}`);
172
});
173
}
174
175
$getSessions(providerId: string, scopes: ReadonlyArray<string> | undefined, options: vscode.AuthenticationProviderSessionOptions): Promise<ReadonlyArray<vscode.AuthenticationSession>> {
176
return this._providerOperations.queue(providerId, async () => {
177
const providerData = this._authenticationProviders.get(providerId);
178
if (providerData) {
179
options.authorizationServer = URI.revive(options.authorizationServer);
180
return await providerData.provider.getSessions(scopes, options);
181
}
182
183
throw new Error(`Unable to find authentication provider with handle: ${providerId}`);
184
});
185
}
186
187
$getSessionsFromChallenges(providerId: string, constraint: vscode.AuthenticationConstraint, options: vscode.AuthenticationProviderSessionOptions): Promise<ReadonlyArray<vscode.AuthenticationSession>> {
188
return this._providerOperations.queue(providerId, async () => {
189
const providerData = this._authenticationProviders.get(providerId);
190
if (providerData) {
191
const provider = providerData.provider;
192
// Check if provider supports challenges
193
if (typeof provider.getSessionsFromChallenges === 'function') {
194
options.authorizationServer = URI.revive(options.authorizationServer);
195
return await provider.getSessionsFromChallenges(constraint, options);
196
}
197
throw new Error(`Authentication provider with handle: ${providerId} does not support getSessionsFromChallenges`);
198
}
199
200
throw new Error(`Unable to find authentication provider with handle: ${providerId}`);
201
});
202
}
203
204
$createSessionFromChallenges(providerId: string, constraint: vscode.AuthenticationConstraint, options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {
205
return this._providerOperations.queue(providerId, async () => {
206
const providerData = this._authenticationProviders.get(providerId);
207
if (providerData) {
208
const provider = providerData.provider;
209
// Check if provider supports challenges
210
if (typeof provider.createSessionFromChallenges === 'function') {
211
options.authorizationServer = URI.revive(options.authorizationServer);
212
return await provider.createSessionFromChallenges(constraint, options);
213
}
214
throw new Error(`Authentication provider with handle: ${providerId} does not support createSessionFromChallenges`);
215
}
216
217
throw new Error(`Unable to find authentication provider with handle: ${providerId}`);
218
});
219
}
220
221
$onDidChangeAuthenticationSessions(id: string, label: string, extensionIdFilter?: string[]) {
222
// Don't fire events for the internal auth providers
223
if (!id.startsWith(INTERNAL_AUTH_PROVIDER_PREFIX)) {
224
this._onDidChangeSessions.fire({ provider: { id, label }, extensionIdFilter });
225
}
226
return Promise.resolve();
227
}
228
229
$onDidUnregisterAuthenticationProvider(id: string): Promise<void> {
230
return this._providerOperations.queue(id, async () => {
231
const providerData = this._authenticationProviders.get(id);
232
if (providerData) {
233
providerData.disposable?.dispose();
234
this._authenticationProviders.delete(id);
235
}
236
});
237
}
238
239
async $registerDynamicAuthProvider(
240
authorizationServerComponents: UriComponents,
241
serverMetadata: IAuthorizationServerMetadata,
242
resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined,
243
clientId: string | undefined,
244
clientSecret: string | undefined,
245
initialTokens: IAuthorizationToken[] | undefined
246
): Promise<string> {
247
if (!clientId) {
248
const authorizationServer = URI.revive(authorizationServerComponents);
249
if (serverMetadata.registration_endpoint) {
250
try {
251
const registration = await fetchDynamicRegistration(serverMetadata, this._initData.environment.appName, resourceMetadata?.scopes_supported);
252
clientId = registration.client_id;
253
clientSecret = registration.client_secret;
254
} catch (err) {
255
this._logService.warn(`Dynamic registration failed for ${authorizationServer.toString()}: ${err.message}. Prompting user for client ID and client secret...`);
256
}
257
}
258
// Still no client id so dynamic client registration was either not supported or failed
259
if (!clientId) {
260
this._logService.info(`Prompting user for client registration details for ${authorizationServer.toString()}`);
261
const clientDetails = await this._proxy.$promptForClientRegistration(authorizationServer.toString());
262
if (!clientDetails) {
263
throw new Error('User did not provide client details');
264
}
265
clientId = clientDetails.clientId;
266
clientSecret = clientDetails.clientSecret;
267
this._logService.info(`User provided client registration for ${authorizationServer.toString()}`);
268
if (clientSecret) {
269
this._logService.trace(`User provided client secret for ${authorizationServer.toString()}`);
270
} else {
271
this._logService.trace(`User did not provide client secret for ${authorizationServer.toString()}`);
272
}
273
}
274
}
275
const provider = new this._dynamicAuthProviderCtor(
276
this._extHostWindow,
277
this._extHostUrls,
278
this._initData,
279
this._extHostProgress,
280
this._extHostLoggerService,
281
this._proxy,
282
URI.revive(authorizationServerComponents),
283
serverMetadata,
284
resourceMetadata,
285
clientId,
286
clientSecret,
287
this._onDidDynamicAuthProviderTokensChange,
288
initialTokens || []
289
);
290
291
// Use the sequencer to ensure dynamic provider registration is serialized
292
await this._providerOperations.queue(provider.id, async () => {
293
this._authenticationProviders.set(
294
provider.id,
295
{
296
label: provider.label,
297
provider,
298
disposable: Disposable.from(
299
provider,
300
provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(provider.id, e)),
301
provider.onDidChangeClientId(() => this._proxy.$sendDidChangeDynamicProviderInfo({
302
providerId: provider.id,
303
clientId: provider.clientId,
304
clientSecret: provider.clientSecret
305
}))
306
),
307
options: { supportsMultipleAccounts: false }
308
}
309
);
310
await this._proxy.$registerDynamicAuthenticationProvider(provider.id, provider.label, provider.authorizationServer, provider.clientId, provider.clientSecret);
311
});
312
313
return provider.id;
314
}
315
316
async $onDidChangeDynamicAuthProviderTokens(authProviderId: string, clientId: string, tokens: IAuthorizationToken[]): Promise<void> {
317
this._onDidDynamicAuthProviderTokensChange.fire({ authProviderId, clientId, tokens });
318
}
319
}
320
321
class TaskSingler<T> {
322
private _inFlightPromises = new Map<string, Promise<T>>();
323
getOrCreate(key: string, promiseFactory: () => Promise<T>) {
324
const inFlight = this._inFlightPromises.get(key);
325
if (inFlight) {
326
return inFlight;
327
}
328
329
const promise = promiseFactory().finally(() => this._inFlightPromises.delete(key));
330
this._inFlightPromises.set(key, promise);
331
332
return promise;
333
}
334
}
335
336
export class DynamicAuthProvider implements vscode.AuthenticationProvider {
337
readonly id: string;
338
readonly label: string;
339
340
private _onDidChangeSessions = new Emitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
341
readonly onDidChangeSessions = this._onDidChangeSessions.event;
342
343
private readonly _onDidChangeClientId = new Emitter<void>();
344
readonly onDidChangeClientId = this._onDidChangeClientId.event;
345
346
private readonly _tokenStore: TokenStore;
347
348
protected readonly _createFlows: Array<{
349
label: string;
350
handler: (scopes: string[], progress: vscode.Progress<{ message: string }>, token: vscode.CancellationToken) => Promise<IAuthorizationTokenResponse>;
351
}>;
352
353
protected readonly _logger: ILogger;
354
private readonly _disposable: DisposableStore;
355
356
constructor(
357
@IExtHostWindow protected readonly _extHostWindow: IExtHostWindow,
358
@IExtHostUrlsService protected readonly _extHostUrls: IExtHostUrlsService,
359
@IExtHostInitDataService protected readonly _initData: IExtHostInitDataService,
360
@IExtHostProgress private readonly _extHostProgress: IExtHostProgress,
361
@ILoggerService loggerService: ILoggerService,
362
protected readonly _proxy: MainThreadAuthenticationShape,
363
readonly authorizationServer: URI,
364
protected readonly _serverMetadata: IAuthorizationServerMetadata,
365
protected readonly _resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined,
366
protected _clientId: string,
367
protected _clientSecret: string | undefined,
368
onDidDynamicAuthProviderTokensChange: Emitter<{ authProviderId: string; clientId: string; tokens: IAuthorizationToken[] }>,
369
initialTokens: IAuthorizationToken[],
370
) {
371
const stringifiedServer = authorizationServer.toString(true);
372
// Auth Provider Id is a combination of the authorization server and the resource, if provided.
373
this.id = _resourceMetadata?.resource
374
? stringifiedServer + ' ' + _resourceMetadata?.resource
375
: stringifiedServer;
376
// Auth Provider label is just the resource name if provided, otherwise the authority of the authorization server.
377
this.label = _resourceMetadata?.resource_name ?? this.authorizationServer.authority;
378
379
this._logger = loggerService.createLogger(this.id, { name: this.label });
380
this._disposable = new DisposableStore();
381
this._disposable.add(this._onDidChangeSessions);
382
const scopedEvent = Event.chain(onDidDynamicAuthProviderTokensChange.event, $ => $
383
.filter(e => e.authProviderId === this.id && e.clientId === _clientId)
384
.map(e => e.tokens)
385
);
386
this._tokenStore = this._disposable.add(new TokenStore(
387
{
388
onDidChange: scopedEvent,
389
set: (tokens) => _proxy.$setSessionsForDynamicAuthProvider(this.id, this.clientId, tokens),
390
},
391
initialTokens,
392
this._logger
393
));
394
this._disposable.add(this._tokenStore.onDidChangeSessions(e => this._onDidChangeSessions.fire(e)));
395
// Will be extended later to support other flows
396
this._createFlows = [];
397
if (_serverMetadata.authorization_endpoint) {
398
this._createFlows.push({
399
label: nls.localize('url handler', "URL Handler"),
400
handler: (scopes, progress, token) => this._createWithUrlHandler(scopes, progress, token)
401
});
402
}
403
}
404
405
get clientId(): string {
406
return this._clientId;
407
}
408
409
get clientSecret(): string | undefined {
410
return this._clientSecret;
411
}
412
413
async getSessions(scopes: readonly string[] | undefined, _options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession[]> {
414
this._logger.info(`Getting sessions for scopes: ${scopes?.join(' ') ?? 'all'}`);
415
if (!scopes) {
416
return this._tokenStore.sessions;
417
}
418
// The oauth spec says tthat order doesn't matter so we sort the scopes for easy comparison
419
// https://datatracker.ietf.org/doc/html/rfc6749#section-3.3
420
// TODO@TylerLeonhardt: Do this for all scope handling in the auth APIs
421
const sortedScopes = [...scopes].sort();
422
const scopeStr = scopes.join(' ');
423
let sessions = this._tokenStore.sessions.filter(session => arraysEqual([...session.scopes].sort(), sortedScopes));
424
this._logger.info(`Found ${sessions.length} sessions for scopes: ${scopeStr}`);
425
if (sessions.length) {
426
const newTokens: IAuthorizationToken[] = [];
427
const removedTokens: IAuthorizationToken[] = [];
428
const tokenMap = new Map<string, IAuthorizationToken>(this._tokenStore.tokens.map(token => [token.access_token, token]));
429
for (const session of sessions) {
430
const token = tokenMap.get(session.accessToken);
431
if (token && token.expires_in) {
432
const now = Date.now();
433
const expiresInMS = token.expires_in * 1000;
434
// Check if the token is about to expire in 5 minutes or if it is expired
435
if (now > token.created_at + expiresInMS - (5 * 60 * 1000)) {
436
this._logger.info(`Token for session ${session.id} is about to expire, refreshing...`);
437
removedTokens.push(token);
438
if (!token.refresh_token) {
439
// No refresh token available, cannot refresh
440
this._logger.warn(`No refresh token available for scopes ${session.scopes.join(' ')}. Throwing away token.`);
441
continue;
442
}
443
try {
444
const newToken = await this.exchangeRefreshTokenForToken(token.refresh_token);
445
// TODO@TylerLeonhardt: When the core scope handling doesn't care about order, this check should be
446
// updated to not care about order
447
if (newToken.scope !== scopeStr) {
448
this._logger.warn(`Token scopes '${newToken.scope}' do not match requested scopes '${scopeStr}'. Overwriting token with what was requested...`);
449
newToken.scope = scopeStr;
450
}
451
this._logger.info(`Successfully created a new token for scopes ${session.scopes.join(' ')}.`);
452
newTokens.push(newToken);
453
} catch (err) {
454
this._logger.error(`Failed to refresh token: ${err}`);
455
}
456
457
}
458
}
459
}
460
if (newTokens.length || removedTokens.length) {
461
this._tokenStore.update({ added: newTokens, removed: removedTokens });
462
// Since we updated the tokens, we need to re-filter the sessions
463
// to get the latest state
464
sessions = this._tokenStore.sessions.filter(session => arraysEqual([...session.scopes].sort(), sortedScopes));
465
}
466
this._logger.info(`Found ${sessions.length} sessions for scopes: ${scopeStr}`);
467
return sessions;
468
}
469
return [];
470
}
471
472
async createSession(scopes: string[], _options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {
473
this._logger.info(`Creating session for scopes: ${scopes.join(' ')}`);
474
let token: IAuthorizationTokenResponse | undefined;
475
for (let i = 0; i < this._createFlows.length; i++) {
476
const { handler } = this._createFlows[i];
477
try {
478
token = await this._extHostProgress.withProgressFromSource(
479
{ label: this.label, id: this.id },
480
{
481
location: ProgressLocation.Notification,
482
title: nls.localize('authenticatingTo', "Authenticating to '{0}'", this.label),
483
cancellable: true
484
},
485
(progress, token) => handler(scopes, progress, token));
486
if (token) {
487
break;
488
}
489
} catch (err) {
490
const nextMode = this._createFlows[i + 1]?.label;
491
if (!nextMode) {
492
break; // No more flows to try
493
}
494
const message = isCancellationError(err)
495
? nls.localize('userCanceledContinue', "Having trouble authenticating to '{0}'? Would you like to try a different way? ({1})", this.label, nextMode)
496
: nls.localize('continueWith', "You have not yet finished authenticating to '{0}'. Would you like to try a different way? ({1})", this.label, nextMode);
497
498
const result = await this._proxy.$showContinueNotification(message);
499
if (!result) {
500
throw new CancellationError();
501
}
502
this._logger.error(`Failed to create token via flow '${nextMode}': ${err}`);
503
}
504
}
505
if (!token) {
506
throw new Error('Failed to create authentication token');
507
}
508
if (token.scope !== scopes.join(' ')) {
509
this._logger.warn(`Token scopes '${token.scope}' do not match requested scopes '${scopes.join(' ')}'. Overwriting token with what was requested...`);
510
token.scope = scopes.join(' ');
511
}
512
513
// Store session for later retrieval
514
this._tokenStore.update({ added: [{ ...token, created_at: Date.now() }], removed: [] });
515
const session = this._tokenStore.sessions.find(t => t.accessToken === token.access_token)!;
516
this._logger.info(`Created session for scopes: ${token.scope}`);
517
return session;
518
}
519
520
async removeSession(sessionId: string): Promise<void> {
521
this._logger.info(`Removing session with id: ${sessionId}`);
522
const session = this._tokenStore.sessions.find(session => session.id === sessionId);
523
if (!session) {
524
this._logger.error(`Session with id ${sessionId} not found`);
525
return;
526
}
527
const token = this._tokenStore.tokens.find(token => token.access_token === session.accessToken);
528
if (!token) {
529
this._logger.error(`Failed to retrieve token for removed session: ${session.id}`);
530
return;
531
}
532
this._tokenStore.update({ added: [], removed: [token] });
533
this._logger.info(`Removed token for session: ${session.id} with scopes: ${session.scopes.join(' ')}`);
534
}
535
536
dispose(): void {
537
this._disposable.dispose();
538
}
539
540
private async _createWithUrlHandler(scopes: string[], progress: vscode.Progress<IProgressStep>, token: vscode.CancellationToken): Promise<IAuthorizationTokenResponse> {
541
if (!this._serverMetadata.authorization_endpoint) {
542
throw new Error('Authorization Endpoint required');
543
}
544
if (!this._serverMetadata.token_endpoint) {
545
throw new Error('Token endpoint not available in server metadata');
546
}
547
548
// Generate PKCE code verifier (random string) and code challenge (SHA-256 hash of verifier)
549
const codeVerifier = this.generateRandomString(64);
550
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
551
552
// Generate a random state value to prevent CSRF
553
const nonce = this.generateRandomString(32);
554
const callbackUri = URI.parse(`${this._initData.environment.appUriScheme}://dynamicauthprovider/${this.authorizationServer.authority}/authorize?nonce=${nonce}`);
555
let state: URI;
556
try {
557
state = await this._extHostUrls.createAppUri(callbackUri);
558
} catch (error) {
559
throw new Error(`Failed to create external URI: ${error}`);
560
}
561
562
// Prepare the authorization request URL
563
const authorizationUrl = new URL(this._serverMetadata.authorization_endpoint);
564
authorizationUrl.searchParams.append('client_id', this._clientId);
565
authorizationUrl.searchParams.append('response_type', 'code');
566
authorizationUrl.searchParams.append('state', state.toString());
567
authorizationUrl.searchParams.append('code_challenge', codeChallenge);
568
authorizationUrl.searchParams.append('code_challenge_method', 'S256');
569
const scopeString = scopes.join(' ');
570
if (scopeString) {
571
// If non-empty scopes are provided, include scope parameter in the request
572
authorizationUrl.searchParams.append('scope', scopeString);
573
}
574
if (this._resourceMetadata?.resource) {
575
// If a resource is specified, include it in the request
576
authorizationUrl.searchParams.append('resource', this._resourceMetadata.resource);
577
}
578
579
// Use a redirect URI that matches what was registered during dynamic registration
580
const redirectUri = 'https://vscode.dev/redirect';
581
authorizationUrl.searchParams.append('redirect_uri', redirectUri);
582
583
const promise = this.waitForAuthorizationCode(callbackUri);
584
585
// Open the browser for user authorization
586
this._logger.info(`Opening authorization URL for scopes: ${scopeString}`);
587
this._logger.trace(`Authorization URL: ${authorizationUrl.toString()}`);
588
const opened = await this._extHostWindow.openUri(authorizationUrl.toString(), {});
589
if (!opened) {
590
throw new CancellationError();
591
}
592
progress.report({
593
message: nls.localize('completeAuth', "Complete the authentication in the browser window that has opened."),
594
});
595
596
// Wait for the authorization code via a redirect
597
let code: string | undefined;
598
try {
599
const response = await raceCancellationError(promise, token);
600
code = response.code;
601
} catch (err) {
602
if (isCancellationError(err)) {
603
this._logger.info('Authorization code request was cancelled by the user.');
604
throw err;
605
}
606
this._logger.error(`Failed to receive authorization code: ${err}`);
607
throw new Error(`Failed to receive authorization code: ${err}`);
608
}
609
this._logger.info(`Authorization code received for scopes: ${scopeString}`);
610
611
// Exchange the authorization code for tokens
612
const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, redirectUri);
613
return tokenResponse;
614
}
615
616
protected generateRandomString(length: number): string {
617
const array = new Uint8Array(length);
618
crypto.getRandomValues(array);
619
return Array.from(array)
620
.map(b => b.toString(16).padStart(2, '0'))
621
.join('')
622
.substring(0, length);
623
}
624
625
protected async generateCodeChallenge(codeVerifier: string): Promise<string> {
626
const encoder = new TextEncoder();
627
const data = encoder.encode(codeVerifier);
628
const digest = await crypto.subtle.digest('SHA-256', data);
629
630
// Base64url encode the digest
631
return encodeBase64(VSBuffer.wrap(new Uint8Array(digest)), false, false)
632
.replace(/\+/g, '-')
633
.replace(/\//g, '_')
634
.replace(/=+$/, '');
635
}
636
637
private async waitForAuthorizationCode(expectedState: URI): Promise<{ code: string }> {
638
const result = await this._proxy.$waitForUriHandler(expectedState);
639
// Extract the code parameter directly from the query string. NOTE, URLSearchParams does not work here because
640
// it will decode the query string and we need to keep it encoded.
641
const codeMatch = /[?&]code=([^&]+)/.exec(result.query || '');
642
if (!codeMatch || codeMatch.length < 2) {
643
// No code parameter found in the query string
644
throw new Error('Authentication failed: No authorization code received');
645
}
646
return { code: codeMatch[1] };
647
}
648
649
protected async exchangeCodeForToken(code: string, codeVerifier: string, redirectUri: string): Promise<IAuthorizationTokenResponse> {
650
if (!this._serverMetadata.token_endpoint) {
651
throw new Error('Token endpoint not available in server metadata');
652
}
653
654
const tokenRequest = new URLSearchParams();
655
tokenRequest.append('client_id', this._clientId);
656
tokenRequest.append('grant_type', 'authorization_code');
657
tokenRequest.append('code', code);
658
tokenRequest.append('redirect_uri', redirectUri);
659
tokenRequest.append('code_verifier', codeVerifier);
660
661
// Add resource indicator if available (RFC 8707)
662
if (this._resourceMetadata?.resource) {
663
tokenRequest.append('resource', this._resourceMetadata.resource);
664
}
665
666
// Add client secret if available
667
if (this._clientSecret) {
668
tokenRequest.append('client_secret', this._clientSecret);
669
}
670
671
this._logger.info('Exchanging authorization code for token...');
672
this._logger.trace(`Url: ${this._serverMetadata.token_endpoint}`);
673
this._logger.trace(`Token request body: ${tokenRequest.toString()}`);
674
let response: Response;
675
try {
676
response = await fetch(this._serverMetadata.token_endpoint, {
677
method: 'POST',
678
headers: {
679
'Content-Type': 'application/x-www-form-urlencoded',
680
'Accept': 'application/json'
681
},
682
body: tokenRequest.toString()
683
});
684
} catch (err) {
685
this._logger.error(`Failed to exchange authorization code for token: ${err}`);
686
throw new Error(`Failed to exchange authorization code for token: ${err}`);
687
}
688
689
if (!response.ok) {
690
const text = await response.text();
691
throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${text}`);
692
}
693
694
const result = await response.json();
695
if (isAuthorizationTokenResponse(result)) {
696
this._logger.info(`Successfully exchanged authorization code for token.`);
697
return result;
698
} else if (isAuthorizationErrorResponse(result) && result.error === AuthorizationErrorType.InvalidClient) {
699
this._logger.warn(`Client ID (${this._clientId}) was invalid, generated a new one.`);
700
await this._generateNewClientId();
701
throw new Error(`Client ID was invalid, generated a new one. Please try again.`);
702
}
703
throw new Error(`Invalid authorization token response: ${JSON.stringify(result)}`);
704
}
705
706
protected async exchangeRefreshTokenForToken(refreshToken: string): Promise<IAuthorizationToken> {
707
if (!this._serverMetadata.token_endpoint) {
708
throw new Error('Token endpoint not available in server metadata');
709
}
710
711
const tokenRequest = new URLSearchParams();
712
tokenRequest.append('client_id', this._clientId);
713
tokenRequest.append('grant_type', 'refresh_token');
714
tokenRequest.append('refresh_token', refreshToken);
715
716
// Add resource indicator if available (RFC 8707)
717
if (this._resourceMetadata?.resource) {
718
tokenRequest.append('resource', this._resourceMetadata.resource);
719
}
720
721
// Add client secret if available
722
if (this._clientSecret) {
723
tokenRequest.append('client_secret', this._clientSecret);
724
}
725
726
const response = await fetch(this._serverMetadata.token_endpoint, {
727
method: 'POST',
728
headers: {
729
'Content-Type': 'application/x-www-form-urlencoded',
730
'Accept': 'application/json'
731
},
732
body: tokenRequest.toString()
733
});
734
735
const result = await response.json();
736
if (isAuthorizationTokenResponse(result)) {
737
return {
738
...result,
739
created_at: Date.now(),
740
};
741
} else if (isAuthorizationErrorResponse(result) && result.error === AuthorizationErrorType.InvalidClient) {
742
this._logger.warn(`Client ID (${this._clientId}) was invalid, generated a new one.`);
743
await this._generateNewClientId();
744
throw new Error(`Client ID was invalid, generated a new one. Please try again.`);
745
}
746
throw new Error(`Invalid authorization token response: ${JSON.stringify(result)}`);
747
}
748
749
protected async _generateNewClientId(): Promise<void> {
750
try {
751
const registration = await fetchDynamicRegistration(this._serverMetadata, this._initData.environment.appName, this._resourceMetadata?.scopes_supported);
752
this._clientId = registration.client_id;
753
this._clientSecret = registration.client_secret;
754
this._onDidChangeClientId.fire();
755
} catch (err) {
756
// When DCR fails, try to prompt the user for a client ID and client secret
757
this._logger.info(`Dynamic registration failed for ${this.authorizationServer.toString()}: ${err}. Prompting user for client ID and client secret.`);
758
759
try {
760
const clientDetails = await this._proxy.$promptForClientRegistration(this.authorizationServer.toString());
761
if (!clientDetails) {
762
throw new Error('User did not provide client details');
763
}
764
this._clientId = clientDetails.clientId;
765
this._clientSecret = clientDetails.clientSecret;
766
this._logger.info(`User provided client ID for ${this.authorizationServer.toString()}`);
767
if (clientDetails.clientSecret) {
768
this._logger.info(`User provided client secret for ${this.authorizationServer.toString()}`);
769
} else {
770
this._logger.info(`User did not provide client secret for ${this.authorizationServer.toString()} (optional)`);
771
}
772
773
this._onDidChangeClientId.fire();
774
} catch (promptErr) {
775
this._logger.error(`Failed to fetch new client ID and user did not provide one: ${err}`);
776
throw new Error(`Failed to fetch new client ID and user did not provide one: ${err}`);
777
}
778
}
779
}
780
}
781
782
type IAuthorizationToken = IAuthorizationTokenResponse & {
783
/**
784
* The time when the token was created, in milliseconds since the epoch.
785
*/
786
created_at: number;
787
};
788
789
class TokenStore implements Disposable {
790
private readonly _tokensObservable: ISettableObservable<IAuthorizationToken[]>;
791
private readonly _sessionsObservable: IObservable<vscode.AuthenticationSession[]>;
792
793
private readonly _onDidChangeSessions = new Emitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
794
readonly onDidChangeSessions = this._onDidChangeSessions.event;
795
796
private readonly _disposable: DisposableStore;
797
798
constructor(
799
private readonly _persistence: { onDidChange: Event<IAuthorizationToken[]>; set: (tokens: IAuthorizationToken[]) => void },
800
initialTokens: IAuthorizationToken[],
801
private readonly _logger: ILogger
802
) {
803
this._disposable = new DisposableStore();
804
this._tokensObservable = observableValue<IAuthorizationToken[]>('tokens', initialTokens);
805
this._sessionsObservable = derivedOpts(
806
{ equalsFn: (a, b) => arraysEqual(a, b, (a, b) => a.accessToken === b.accessToken) },
807
(reader) => this._tokensObservable.read(reader).map(t => this._getSessionFromToken(t))
808
);
809
this._disposable.add(this._registerChangeEventAutorun());
810
this._disposable.add(this._persistence.onDidChange((tokens) => this._tokensObservable.set(tokens, undefined)));
811
}
812
813
get tokens(): IAuthorizationToken[] {
814
return this._tokensObservable.get();
815
}
816
817
get sessions(): vscode.AuthenticationSession[] {
818
return this._sessionsObservable.get();
819
}
820
821
dispose() {
822
this._disposable.dispose();
823
}
824
825
update({ added, removed }: { added: IAuthorizationToken[]; removed: IAuthorizationToken[] }): void {
826
this._logger.trace(`Updating tokens: added ${added.length}, removed ${removed.length}`);
827
const currentTokens = [...this._tokensObservable.get()];
828
for (const token of removed) {
829
const index = currentTokens.findIndex(t => t.access_token === token.access_token);
830
if (index !== -1) {
831
currentTokens.splice(index, 1);
832
}
833
}
834
for (const token of added) {
835
const index = currentTokens.findIndex(t => t.access_token === token.access_token);
836
if (index === -1) {
837
currentTokens.push(token);
838
} else {
839
currentTokens[index] = token;
840
}
841
}
842
if (added.length || removed.length) {
843
this._tokensObservable.set(currentTokens, undefined);
844
void this._persistence.set(currentTokens);
845
}
846
this._logger.trace(`Tokens updated: ${currentTokens.length} tokens stored.`);
847
}
848
849
private _registerChangeEventAutorun(): IDisposable {
850
let previousSessions: vscode.AuthenticationSession[] = [];
851
return autorun((reader) => {
852
this._logger.trace('Checking for session changes...');
853
const currentSessions = this._sessionsObservable.read(reader);
854
if (previousSessions === currentSessions) {
855
this._logger.trace('No session changes detected.');
856
return;
857
}
858
859
if (!currentSessions || currentSessions.length === 0) {
860
// If currentSessions is undefined, all previous sessions are considered removed
861
this._logger.trace('All sessions removed.');
862
if (previousSessions.length > 0) {
863
this._onDidChangeSessions.fire({
864
added: [],
865
removed: previousSessions,
866
changed: []
867
});
868
previousSessions = [];
869
}
870
return;
871
}
872
873
const added: vscode.AuthenticationSession[] = [];
874
const removed: vscode.AuthenticationSession[] = [];
875
876
// Find added sessions
877
for (const current of currentSessions) {
878
const exists = previousSessions.some(prev => prev.accessToken === current.accessToken);
879
if (!exists) {
880
added.push(current);
881
}
882
}
883
884
// Find removed sessions
885
for (const prev of previousSessions) {
886
const exists = currentSessions.some(current => current.accessToken === prev.accessToken);
887
if (!exists) {
888
removed.push(prev);
889
}
890
}
891
892
// Fire the event if there are any changes
893
if (added.length > 0 || removed.length > 0) {
894
this._logger.trace(`Sessions changed: added ${added.length}, removed ${removed.length}`);
895
this._onDidChangeSessions.fire({ added, removed, changed: [] });
896
}
897
898
// Update previous sessions reference
899
previousSessions = currentSessions;
900
});
901
}
902
903
private _getSessionFromToken(token: IAuthorizationTokenResponse): vscode.AuthenticationSession {
904
let claims: IAuthorizationJWTClaims | undefined;
905
if (token.id_token) {
906
try {
907
claims = getClaimsFromJWT(token.id_token);
908
} catch (e) {
909
// log
910
}
911
}
912
if (!claims) {
913
try {
914
claims = getClaimsFromJWT(token.access_token);
915
} catch (e) {
916
// log
917
}
918
}
919
const scopes = token.scope
920
? token.scope.split(' ')
921
: claims?.scope
922
? claims.scope.split(' ')
923
: [];
924
return {
925
id: stringHash(token.access_token, 0).toString(),
926
accessToken: token.access_token,
927
account: {
928
id: claims?.sub || 'unknown',
929
// TODO: Don't say MCP...
930
label: claims?.preferred_username || claims?.name || claims?.email || 'MCP',
931
},
932
scopes: scopes,
933
idToken: token.id_token
934
};
935
}
936
}
937
938