Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/services/authentication/browser/authenticationExtensionsService.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 { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
7
import { scopesMatch } from '../../../../base/common/oauth.js';
8
import * as nls from '../../../../nls.js';
9
import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js';
10
import { CommandsRegistry } from '../../../../platform/commands/common/commands.js';
11
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
12
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
13
import { Severity } from '../../../../platform/notification/common/notification.js';
14
import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js';
15
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
16
import { IActivityService, NumberBadge } from '../../activity/common/activity.js';
17
import { IAuthenticationAccessService } from './authenticationAccessService.js';
18
import { IAuthenticationUsageService } from './authenticationUsageService.js';
19
import { AuthenticationSession, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService, AuthenticationSessionAccount, IAuthenticationWWWAuthenticateRequest, isAuthenticationWWWAuthenticateRequest } from '../common/authentication.js';
20
import { Emitter } from '../../../../base/common/event.js';
21
import { IProductService } from '../../../../platform/product/common/productService.js';
22
import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';
23
24
// OAuth2 spec prohibits space in a scope, so use that to join them.
25
const SCOPESLIST_SEPARATOR = ' ';
26
27
interface SessionRequest {
28
disposables: IDisposable[];
29
requestingExtensionIds: string[];
30
}
31
32
interface SessionRequestInfo {
33
[scopesList: string]: SessionRequest;
34
}
35
36
// TODO@TylerLeonhardt: This should all go in MainThreadAuthentication
37
export class AuthenticationExtensionsService extends Disposable implements IAuthenticationExtensionsService {
38
declare readonly _serviceBrand: undefined;
39
private _signInRequestItems = new Map<string, SessionRequestInfo>();
40
private _sessionAccessRequestItems = new Map<string, { [extensionId: string]: { disposables: IDisposable[]; possibleSessions: AuthenticationSession[] } }>();
41
private readonly _accountBadgeDisposable = this._register(new MutableDisposable());
42
43
private _onDidAccountPreferenceChange: Emitter<{ providerId: string; extensionIds: string[] }> = this._register(new Emitter<{ providerId: string; extensionIds: string[] }>());
44
readonly onDidChangeAccountPreference = this._onDidAccountPreferenceChange.event;
45
46
private _inheritAuthAccountPreferenceParentToChildren: Record<string, string[]>;
47
private _inheritAuthAccountPreferenceChildToParent: { [extensionId: string]: string };
48
49
constructor(
50
@IActivityService private readonly activityService: IActivityService,
51
@IStorageService private readonly storageService: IStorageService,
52
@IDialogService private readonly dialogService: IDialogService,
53
@IQuickInputService private readonly quickInputService: IQuickInputService,
54
@IProductService private readonly _productService: IProductService,
55
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
56
@IAuthenticationUsageService private readonly _authenticationUsageService: IAuthenticationUsageService,
57
@IAuthenticationAccessService private readonly _authenticationAccessService: IAuthenticationAccessService
58
) {
59
super();
60
this._inheritAuthAccountPreferenceParentToChildren = this._productService.inheritAuthAccountPreference || {};
61
this._inheritAuthAccountPreferenceChildToParent = Object.entries(this._inheritAuthAccountPreferenceParentToChildren).reduce<{ [extensionId: string]: string }>((acc, [parent, children]) => {
62
children.forEach((child: string) => {
63
acc[child] = parent;
64
});
65
return acc;
66
}, {});
67
this.registerListeners();
68
}
69
70
private registerListeners() {
71
this._register(this._authenticationService.onDidChangeSessions(e => {
72
if (e.event.added?.length) {
73
this.updateNewSessionRequests(e.providerId, e.event.added);
74
}
75
if (e.event.removed?.length) {
76
this.updateAccessRequests(e.providerId, e.event.removed);
77
}
78
}));
79
80
this._register(this._authenticationService.onDidUnregisterAuthenticationProvider(e => {
81
const accessRequests = this._sessionAccessRequestItems.get(e.id) || {};
82
Object.keys(accessRequests).forEach(extensionId => {
83
this.removeAccessRequest(e.id, extensionId);
84
});
85
}));
86
}
87
88
updateNewSessionRequests(providerId: string, addedSessions: readonly AuthenticationSession[]): void {
89
const existingRequestsForProvider = this._signInRequestItems.get(providerId);
90
if (!existingRequestsForProvider) {
91
return;
92
}
93
94
Object.keys(existingRequestsForProvider).forEach(requestedScopes => {
95
// Parse the requested scopes from the stored key
96
const requestedScopesArray = requestedScopes.split(SCOPESLIST_SEPARATOR);
97
98
// Check if any added session has matching scopes (order-independent)
99
if (addedSessions.some(session => scopesMatch(session.scopes, requestedScopesArray))) {
100
const sessionRequest = existingRequestsForProvider[requestedScopes];
101
sessionRequest?.disposables.forEach(item => item.dispose());
102
103
delete existingRequestsForProvider[requestedScopes];
104
if (Object.keys(existingRequestsForProvider).length === 0) {
105
this._signInRequestItems.delete(providerId);
106
} else {
107
this._signInRequestItems.set(providerId, existingRequestsForProvider);
108
}
109
this.updateBadgeCount();
110
}
111
});
112
}
113
114
private updateAccessRequests(providerId: string, removedSessions: readonly AuthenticationSession[]): void {
115
const providerRequests = this._sessionAccessRequestItems.get(providerId);
116
if (providerRequests) {
117
Object.keys(providerRequests).forEach(extensionId => {
118
removedSessions.forEach(removed => {
119
const indexOfSession = providerRequests[extensionId].possibleSessions.findIndex(session => session.id === removed.id);
120
if (indexOfSession) {
121
providerRequests[extensionId].possibleSessions.splice(indexOfSession, 1);
122
}
123
});
124
125
if (!providerRequests[extensionId].possibleSessions.length) {
126
this.removeAccessRequest(providerId, extensionId);
127
}
128
});
129
}
130
}
131
132
private updateBadgeCount(): void {
133
this._accountBadgeDisposable.clear();
134
135
let numberOfRequests = 0;
136
this._signInRequestItems.forEach(providerRequests => {
137
Object.keys(providerRequests).forEach(request => {
138
numberOfRequests += providerRequests[request].requestingExtensionIds.length;
139
});
140
});
141
142
this._sessionAccessRequestItems.forEach(accessRequest => {
143
numberOfRequests += Object.keys(accessRequest).length;
144
});
145
146
if (numberOfRequests > 0) {
147
const badge = new NumberBadge(numberOfRequests, () => nls.localize('sign in', "Sign in requested"));
148
this._accountBadgeDisposable.value = this.activityService.showAccountsActivity({ badge });
149
}
150
}
151
152
private removeAccessRequest(providerId: string, extensionId: string): void {
153
const providerRequests = this._sessionAccessRequestItems.get(providerId) || {};
154
if (providerRequests[extensionId]) {
155
dispose(providerRequests[extensionId].disposables);
156
delete providerRequests[extensionId];
157
this.updateBadgeCount();
158
}
159
}
160
161
//#region Account/Session Preference
162
163
updateAccountPreference(extensionId: string, providerId: string, account: AuthenticationSessionAccount): void {
164
const realExtensionId = ExtensionIdentifier.toKey(extensionId);
165
const parentExtensionId = this._inheritAuthAccountPreferenceChildToParent[realExtensionId] ?? realExtensionId;
166
const key = this._getKey(parentExtensionId, providerId);
167
168
// Store the preference in the workspace and application storage. This allows new workspaces to
169
// have a preference set already to limit the number of prompts that are shown... but also allows
170
// a specific workspace to override the global preference.
171
this.storageService.store(key, account.label, StorageScope.WORKSPACE, StorageTarget.MACHINE);
172
this.storageService.store(key, account.label, StorageScope.APPLICATION, StorageTarget.MACHINE);
173
174
const childrenExtensions = this._inheritAuthAccountPreferenceParentToChildren[parentExtensionId];
175
const extensionIds = childrenExtensions ? [parentExtensionId, ...childrenExtensions] : [parentExtensionId];
176
this._onDidAccountPreferenceChange.fire({ extensionIds, providerId });
177
}
178
179
getAccountPreference(extensionId: string, providerId: string): string | undefined {
180
const realExtensionId = ExtensionIdentifier.toKey(extensionId);
181
const key = this._getKey(this._inheritAuthAccountPreferenceChildToParent[realExtensionId] ?? realExtensionId, providerId);
182
183
// If a preference is set in the workspace, use that. Otherwise, use the global preference.
184
return this.storageService.get(key, StorageScope.WORKSPACE) ?? this.storageService.get(key, StorageScope.APPLICATION);
185
}
186
187
removeAccountPreference(extensionId: string, providerId: string): void {
188
const realExtensionId = ExtensionIdentifier.toKey(extensionId);
189
const key = this._getKey(this._inheritAuthAccountPreferenceChildToParent[realExtensionId] ?? realExtensionId, providerId);
190
191
// This won't affect any other workspaces that have a preference set, but it will remove the preference
192
// for this workspace and the global preference. This is only paired with a call to updateSessionPreference...
193
// so we really don't _need_ to remove them as they are about to be overridden anyway... but it's more correct
194
// to remove them first... and in case this gets called from somewhere else in the future.
195
this.storageService.remove(key, StorageScope.WORKSPACE);
196
this.storageService.remove(key, StorageScope.APPLICATION);
197
}
198
199
private _getKey(extensionId: string, providerId: string): string {
200
return `${extensionId}-${providerId}`;
201
}
202
203
// TODO@TylerLeonhardt: Remove all of this after a couple iterations
204
205
updateSessionPreference(providerId: string, extensionId: string, session: AuthenticationSession): void {
206
const realExtensionId = ExtensionIdentifier.toKey(extensionId);
207
// The 3 parts of this key are important:
208
// * Extension id: The extension that has a preference
209
// * Provider id: The provider that the preference is for
210
// * The scopes: The subset of sessions that the preference applies to
211
const key = `${realExtensionId}-${providerId}-${session.scopes.join(SCOPESLIST_SEPARATOR)}`;
212
213
// Store the preference in the workspace and application storage. This allows new workspaces to
214
// have a preference set already to limit the number of prompts that are shown... but also allows
215
// a specific workspace to override the global preference.
216
this.storageService.store(key, session.id, StorageScope.WORKSPACE, StorageTarget.MACHINE);
217
this.storageService.store(key, session.id, StorageScope.APPLICATION, StorageTarget.MACHINE);
218
}
219
220
getSessionPreference(providerId: string, extensionId: string, scopes: string[]): string | undefined {
221
const realExtensionId = ExtensionIdentifier.toKey(extensionId);
222
// The 3 parts of this key are important:
223
// * Extension id: The extension that has a preference
224
// * Provider id: The provider that the preference is for
225
// * The scopes: The subset of sessions that the preference applies to
226
const key = `${realExtensionId}-${providerId}-${scopes.join(SCOPESLIST_SEPARATOR)}`;
227
228
// If a preference is set in the workspace, use that. Otherwise, use the global preference.
229
return this.storageService.get(key, StorageScope.WORKSPACE) ?? this.storageService.get(key, StorageScope.APPLICATION);
230
}
231
232
removeSessionPreference(providerId: string, extensionId: string, scopes: string[]): void {
233
const realExtensionId = ExtensionIdentifier.toKey(extensionId);
234
// The 3 parts of this key are important:
235
// * Extension id: The extension that has a preference
236
// * Provider id: The provider that the preference is for
237
// * The scopes: The subset of sessions that the preference applies to
238
const key = `${realExtensionId}-${providerId}-${scopes.join(SCOPESLIST_SEPARATOR)}`;
239
240
// This won't affect any other workspaces that have a preference set, but it will remove the preference
241
// for this workspace and the global preference. This is only paired with a call to updateSessionPreference...
242
// so we really don't _need_ to remove them as they are about to be overridden anyway... but it's more correct
243
// to remove them first... and in case this gets called from somewhere else in the future.
244
this.storageService.remove(key, StorageScope.WORKSPACE);
245
this.storageService.remove(key, StorageScope.APPLICATION);
246
}
247
248
private _updateAccountAndSessionPreferences(providerId: string, extensionId: string, session: AuthenticationSession): void {
249
this.updateAccountPreference(extensionId, providerId, session.account);
250
this.updateSessionPreference(providerId, extensionId, session);
251
}
252
253
//#endregion
254
255
private async showGetSessionPrompt(provider: IAuthenticationProvider, accountName: string, extensionId: string, extensionName: string): Promise<boolean> {
256
enum SessionPromptChoice {
257
Allow = 0,
258
Deny = 1,
259
Cancel = 2
260
}
261
const { result } = await this.dialogService.prompt<SessionPromptChoice>({
262
type: Severity.Info,
263
message: nls.localize('confirmAuthenticationAccess', "The extension '{0}' wants to access the {1} account '{2}'.", extensionName, provider.label, accountName),
264
buttons: [
265
{
266
label: nls.localize({ key: 'allow', comment: ['&& denotes a mnemonic'] }, "&&Allow"),
267
run: () => SessionPromptChoice.Allow
268
},
269
{
270
label: nls.localize({ key: 'deny', comment: ['&& denotes a mnemonic'] }, "&&Deny"),
271
run: () => SessionPromptChoice.Deny
272
}
273
],
274
cancelButton: {
275
run: () => SessionPromptChoice.Cancel
276
}
277
});
278
279
if (result !== SessionPromptChoice.Cancel) {
280
this._authenticationAccessService.updateAllowedExtensions(provider.id, accountName, [{ id: extensionId, name: extensionName, allowed: result === SessionPromptChoice.Allow }]);
281
this.removeAccessRequest(provider.id, extensionId);
282
}
283
284
return result === SessionPromptChoice.Allow;
285
}
286
287
/**
288
* This function should be used only when there are sessions to disambiguate.
289
*/
290
async selectSession(providerId: string, extensionId: string, extensionName: string, scopeListOrRequest: ReadonlyArray<string> | IAuthenticationWWWAuthenticateRequest, availableSessions: AuthenticationSession[]): Promise<AuthenticationSession> {
291
const allAccounts = await this._authenticationService.getAccounts(providerId);
292
if (!allAccounts.length) {
293
throw new Error('No accounts available');
294
}
295
const disposables = new DisposableStore();
296
const quickPick = disposables.add(this.quickInputService.createQuickPick<{ label: string; session?: AuthenticationSession; account?: AuthenticationSessionAccount }>());
297
quickPick.ignoreFocusOut = true;
298
const accountsWithSessions = new Set<string>();
299
const items: { label: string; session?: AuthenticationSession; account?: AuthenticationSessionAccount }[] = availableSessions
300
// Only grab the first account
301
.filter(session => !accountsWithSessions.has(session.account.label) && accountsWithSessions.add(session.account.label))
302
.map(session => {
303
return {
304
label: session.account.label,
305
session: session
306
};
307
});
308
309
// Add the additional accounts that have been logged into the provider but are
310
// don't have a session yet.
311
allAccounts.forEach(account => {
312
if (!accountsWithSessions.has(account.label)) {
313
items.push({ label: account.label, account });
314
}
315
});
316
items.push({ label: nls.localize('useOtherAccount', "Sign in to another account") });
317
quickPick.items = items;
318
quickPick.title = nls.localize(
319
{
320
key: 'selectAccount',
321
comment: ['The placeholder {0} is the name of an extension. {1} is the name of the type of account, such as Microsoft or GitHub.']
322
},
323
"The extension '{0}' wants to access a {1} account",
324
extensionName,
325
this._authenticationService.getProvider(providerId).label
326
);
327
quickPick.placeholder = nls.localize('getSessionPlateholder', "Select an account for '{0}' to use or Esc to cancel", extensionName);
328
329
return await new Promise((resolve, reject) => {
330
disposables.add(quickPick.onDidAccept(async _ => {
331
quickPick.dispose();
332
let session = quickPick.selectedItems[0].session;
333
if (!session) {
334
const account = quickPick.selectedItems[0].account;
335
try {
336
session = await this._authenticationService.createSession(providerId, scopeListOrRequest, { account });
337
} catch (e) {
338
reject(e);
339
return;
340
}
341
}
342
const accountName = session.account.label;
343
344
this._authenticationAccessService.updateAllowedExtensions(providerId, accountName, [{ id: extensionId, name: extensionName, allowed: true }]);
345
this._updateAccountAndSessionPreferences(providerId, extensionId, session);
346
this.removeAccessRequest(providerId, extensionId);
347
348
resolve(session);
349
}));
350
351
disposables.add(quickPick.onDidHide(_ => {
352
if (!quickPick.selectedItems[0]) {
353
reject('User did not consent to account access');
354
}
355
disposables.dispose();
356
}));
357
358
quickPick.show();
359
});
360
}
361
362
private async completeSessionAccessRequest(provider: IAuthenticationProvider, extensionId: string, extensionName: string, scopeListOrRequest: ReadonlyArray<string> | IAuthenticationWWWAuthenticateRequest): Promise<void> {
363
const providerRequests = this._sessionAccessRequestItems.get(provider.id) || {};
364
const existingRequest = providerRequests[extensionId];
365
if (!existingRequest) {
366
return;
367
}
368
369
if (!provider) {
370
return;
371
}
372
const possibleSessions = existingRequest.possibleSessions;
373
374
let session: AuthenticationSession | undefined;
375
if (provider.supportsMultipleAccounts) {
376
try {
377
session = await this.selectSession(provider.id, extensionId, extensionName, scopeListOrRequest, possibleSessions);
378
} catch (_) {
379
// ignore cancel
380
}
381
} else {
382
const approved = await this.showGetSessionPrompt(provider, possibleSessions[0].account.label, extensionId, extensionName);
383
if (approved) {
384
session = possibleSessions[0];
385
}
386
}
387
388
if (session) {
389
this._authenticationUsageService.addAccountUsage(provider.id, session.account.label, session.scopes, extensionId, extensionName);
390
}
391
}
392
393
requestSessionAccess(providerId: string, extensionId: string, extensionName: string, scopeListOrRequest: ReadonlyArray<string> | IAuthenticationWWWAuthenticateRequest, possibleSessions: AuthenticationSession[]): void {
394
const providerRequests = this._sessionAccessRequestItems.get(providerId) || {};
395
const hasExistingRequest = providerRequests[extensionId];
396
if (hasExistingRequest) {
397
return;
398
}
399
400
const provider = this._authenticationService.getProvider(providerId);
401
const menuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, {
402
group: '3_accessRequests',
403
command: {
404
id: `${providerId}${extensionId}Access`,
405
title: nls.localize({
406
key: 'accessRequest',
407
comment: [`The placeholder {0} will be replaced with an authentication provider''s label. {1} will be replaced with an extension name. (1) is to indicate that this menu item contributes to a badge count`]
408
},
409
"Grant access to {0} for {1}... (1)",
410
provider.label,
411
extensionName)
412
}
413
});
414
415
const accessCommand = CommandsRegistry.registerCommand({
416
id: `${providerId}${extensionId}Access`,
417
handler: async (accessor) => {
418
this.completeSessionAccessRequest(provider, extensionId, extensionName, scopeListOrRequest);
419
}
420
});
421
422
providerRequests[extensionId] = { possibleSessions, disposables: [menuItem, accessCommand] };
423
this._sessionAccessRequestItems.set(providerId, providerRequests);
424
this.updateBadgeCount();
425
}
426
427
async requestNewSession(providerId: string, scopeListOrRequest: ReadonlyArray<string> | IAuthenticationWWWAuthenticateRequest, extensionId: string, extensionName: string): Promise<void> {
428
if (!this._authenticationService.isAuthenticationProviderRegistered(providerId)) {
429
// Activate has already been called for the authentication provider, but it cannot block on registering itself
430
// since this is sync and returns a disposable. So, wait for registration event to fire that indicates the
431
// provider is now in the map.
432
await new Promise<void>((resolve, _) => {
433
const dispose = this._authenticationService.onDidRegisterAuthenticationProvider(e => {
434
if (e.id === providerId) {
435
dispose.dispose();
436
resolve();
437
}
438
});
439
});
440
}
441
442
let provider: IAuthenticationProvider;
443
try {
444
provider = this._authenticationService.getProvider(providerId);
445
} catch (_e) {
446
return;
447
}
448
449
const providerRequests = this._signInRequestItems.get(providerId);
450
const signInRequestKey = isAuthenticationWWWAuthenticateRequest(scopeListOrRequest)
451
? `${scopeListOrRequest.wwwAuthenticate}:${scopeListOrRequest.scopes?.join(SCOPESLIST_SEPARATOR) ?? ''}`
452
: `${scopeListOrRequest.join(SCOPESLIST_SEPARATOR)}`;
453
const extensionHasExistingRequest = providerRequests
454
&& providerRequests[signInRequestKey]
455
&& providerRequests[signInRequestKey].requestingExtensionIds.includes(extensionId);
456
457
if (extensionHasExistingRequest) {
458
return;
459
}
460
461
// Construct a commandId that won't clash with others generated here, nor likely with an extension's command
462
const commandId = `${providerId}:${extensionId}:signIn${Object.keys(providerRequests || []).length}`;
463
const menuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, {
464
group: '2_signInRequests',
465
command: {
466
id: commandId,
467
title: nls.localize({
468
key: 'signInRequest',
469
comment: [`The placeholder {0} will be replaced with an authentication provider's label. {1} will be replaced with an extension name. (1) is to indicate that this menu item contributes to a badge count.`]
470
},
471
"Sign in with {0} to use {1} (1)",
472
provider.label,
473
extensionName)
474
}
475
});
476
477
const signInCommand = CommandsRegistry.registerCommand({
478
id: commandId,
479
handler: async (accessor) => {
480
const authenticationService = accessor.get(IAuthenticationService);
481
const session = await authenticationService.createSession(providerId, scopeListOrRequest);
482
483
this._authenticationAccessService.updateAllowedExtensions(providerId, session.account.label, [{ id: extensionId, name: extensionName, allowed: true }]);
484
this._updateAccountAndSessionPreferences(providerId, extensionId, session);
485
}
486
});
487
488
489
if (providerRequests) {
490
const existingRequest = providerRequests[signInRequestKey] || { disposables: [], requestingExtensionIds: [] };
491
492
providerRequests[signInRequestKey] = {
493
disposables: [...existingRequest.disposables, menuItem, signInCommand],
494
requestingExtensionIds: [...existingRequest.requestingExtensionIds, extensionId]
495
};
496
this._signInRequestItems.set(providerId, providerRequests);
497
} else {
498
this._signInRequestItems.set(providerId, {
499
[signInRequestKey]: {
500
disposables: [menuItem, signInCommand],
501
requestingExtensionIds: [extensionId]
502
}
503
});
504
}
505
506
this.updateBadgeCount();
507
}
508
}
509
510
registerSingleton(IAuthenticationExtensionsService, AuthenticationExtensionsService, InstantiationType.Delayed);
511
512