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