Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/microsoft-authentication/src/node/authProvider.ts
3320 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
import { AccountInfo, AuthenticationResult, ClientAuthError, ClientAuthErrorCodes, ServerError, SilentFlowRequest } from '@azure/msal-node';
6
import { AuthenticationChallenge, AuthenticationConstraint, AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationProviderSessionOptions, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, EventEmitter, ExtensionContext, ExtensionKind, l10n, LogOutputChannel, Uri, window } from 'vscode';
7
import { Environment } from '@azure/ms-rest-azure-env';
8
import { CachedPublicClientApplicationManager } from './publicClientCache';
9
import { UriEventHandler } from '../UriEventHandler';
10
import { ICachedPublicClientApplication, ICachedPublicClientApplicationManager } from '../common/publicClientCache';
11
import { MicrosoftAccountType, MicrosoftAuthenticationTelemetryReporter } from '../common/telemetryReporter';
12
import { ScopeData } from '../common/scopeData';
13
import { EventBufferer } from '../common/event';
14
import { BetterTokenStorage } from '../betterSecretStorage';
15
import { IStoredSession } from '../AADHelper';
16
import { ExtensionHost, getMsalFlows } from './flows';
17
import { base64Decode } from './buffer';
18
import { Config } from '../common/config';
19
import { DEFAULT_REDIRECT_URI } from '../common/env';
20
21
const MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad';
22
const MSA_PASSTHRU_TID = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a';
23
24
export class MsalAuthProvider implements AuthenticationProvider {
25
26
private readonly _disposables: { dispose(): void }[];
27
private readonly _eventBufferer = new EventBufferer();
28
29
/**
30
* Event to signal a change in authentication sessions for this provider.
31
*/
32
private readonly _onDidChangeSessionsEmitter = new EventEmitter<AuthenticationProviderAuthenticationSessionsChangeEvent>();
33
34
/**
35
* Event to signal a change in authentication sessions for this provider.
36
*
37
* NOTE: This event is handled differently in the Microsoft auth provider than "typical" auth providers. Normally,
38
* this event would fire when the provider's sessions change... which are tied to a specific list of scopes. However,
39
* since Microsoft identity doesn't care too much about scopes (you can mint a new token from an existing token),
40
* we just fire this event whenever the account list changes... so essentially there is one session per account.
41
*
42
* This is not quite how the API should be used... but this event really is just for signaling that the account list
43
* has changed.
44
*/
45
onDidChangeSessions = this._onDidChangeSessionsEmitter.event;
46
47
private constructor(
48
private readonly _context: ExtensionContext,
49
private readonly _telemetryReporter: MicrosoftAuthenticationTelemetryReporter,
50
private readonly _logger: LogOutputChannel,
51
private readonly _uriHandler: UriEventHandler,
52
private readonly _publicClientManager: ICachedPublicClientApplicationManager,
53
private readonly _env: Environment = Environment.AzureCloud
54
) {
55
this._disposables = _context.subscriptions;
56
const accountChangeEvent = this._eventBufferer.wrapEvent(
57
this._publicClientManager.onDidAccountsChange,
58
(last, newEvent) => {
59
if (!last) {
60
return newEvent;
61
}
62
const mergedEvent = {
63
added: [...(last.added ?? []), ...(newEvent.added ?? [])],
64
deleted: [...(last.deleted ?? []), ...(newEvent.deleted ?? [])],
65
changed: [...(last.changed ?? []), ...(newEvent.changed ?? [])]
66
};
67
68
const dedupedEvent = {
69
added: Array.from(new Map(mergedEvent.added.map(item => [item.username, item])).values()),
70
deleted: Array.from(new Map(mergedEvent.deleted.map(item => [item.username, item])).values()),
71
changed: Array.from(new Map(mergedEvent.changed.map(item => [item.username, item])).values())
72
};
73
74
return dedupedEvent;
75
},
76
{ added: new Array<AccountInfo>(), deleted: new Array<AccountInfo>(), changed: new Array<AccountInfo>() }
77
)(e => this._handleAccountChange(e));
78
this._disposables.push(
79
this._onDidChangeSessionsEmitter,
80
accountChangeEvent
81
);
82
}
83
84
static async create(
85
context: ExtensionContext,
86
telemetryReporter: MicrosoftAuthenticationTelemetryReporter,
87
logger: LogOutputChannel,
88
uriHandler: UriEventHandler,
89
env: Environment = Environment.AzureCloud
90
): Promise<MsalAuthProvider> {
91
const publicClientManager = await CachedPublicClientApplicationManager.create(context.secrets, logger, telemetryReporter, env);
92
context.subscriptions.push(publicClientManager);
93
const authProvider = new MsalAuthProvider(context, telemetryReporter, logger, uriHandler, publicClientManager, env);
94
await authProvider.initialize();
95
return authProvider;
96
}
97
98
/**
99
* Migrate sessions from the old secret storage to MSAL.
100
* TODO: MSAL Migration. Remove this when we remove the old flow.
101
*/
102
private async _migrateSessions() {
103
const betterSecretStorage = new BetterTokenStorage<IStoredSession>('microsoft.login.keylist', this._context);
104
const sessions = await betterSecretStorage.getAll(item => {
105
item.endpoint ||= Environment.AzureCloud.activeDirectoryEndpointUrl;
106
return item.endpoint === this._env.activeDirectoryEndpointUrl;
107
});
108
this._context.globalState.update('msalMigration', true);
109
110
const clientTenantMap = new Map<string, { clientId: string; tenant: string; refreshTokens: string[] }>();
111
112
for (const session of sessions) {
113
const scopeData = new ScopeData(session.scope.split(' '));
114
const key = `${scopeData.clientId}:${scopeData.tenant}`;
115
if (!clientTenantMap.has(key)) {
116
clientTenantMap.set(key, { clientId: scopeData.clientId, tenant: scopeData.tenant, refreshTokens: [] });
117
}
118
clientTenantMap.get(key)!.refreshTokens.push(session.refreshToken);
119
}
120
121
for (const { clientId, tenant, refreshTokens } of clientTenantMap.values()) {
122
await this._publicClientManager.getOrCreate(clientId, { refreshTokensToMigrate: refreshTokens, tenant });
123
}
124
}
125
126
private async initialize(): Promise<void> {
127
if (!this._context.globalState.get('msalMigration', false)) {
128
await this._migrateSessions();
129
}
130
131
// Send telemetry for existing accounts
132
for (const cachedPca of this._publicClientManager.getAll()) {
133
for (const account of cachedPca.accounts) {
134
const tid = account.tenantId;
135
const type = tid === MSA_TID || tid === MSA_PASSTHRU_TID ? MicrosoftAccountType.MSA : MicrosoftAccountType.AAD;
136
this._telemetryReporter.sendAccountEvent([], type);
137
}
138
}
139
}
140
141
/**
142
* See {@link onDidChangeSessions} for more information on how this is used.
143
* @param param0 Event that contains the added and removed accounts
144
*/
145
private _handleAccountChange({ added, changed, deleted }: { added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }) {
146
this._logger.debug(`[_handleAccountChange] added: ${added.length}, changed: ${changed.length}, deleted: ${deleted.length}`);
147
this._onDidChangeSessionsEmitter.fire({
148
added: added.map(this.sessionFromAccountInfo),
149
changed: changed.map(this.sessionFromAccountInfo),
150
removed: deleted.map(this.sessionFromAccountInfo)
151
});
152
}
153
154
//#region AuthenticationProvider methods
155
156
async getSessions(scopes: string[] | undefined, options: AuthenticationGetSessionOptions = {}): Promise<AuthenticationSession[]> {
157
const askingForAll = scopes === undefined;
158
const scopeData = new ScopeData(scopes, undefined, options?.authorizationServer);
159
// Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead.
160
this._logger.info('[getSessions]', askingForAll ? '[all]' : `[${scopeData.scopeStr}]`, 'starting');
161
162
// This branch only gets called by Core for sign out purposes and initial population of the account menu. Since we are
163
// living in a world where a "session" from Core's perspective is an account, we return 1 session per account.
164
// See the large comment on `onDidChangeSessions` for more information.
165
if (askingForAll) {
166
const allSessionsForAccounts = new Map<string, AuthenticationSession>();
167
for (const cachedPca of this._publicClientManager.getAll()) {
168
for (const account of cachedPca.accounts) {
169
if (allSessionsForAccounts.has(account.homeAccountId)) {
170
continue;
171
}
172
allSessionsForAccounts.set(account.homeAccountId, this.sessionFromAccountInfo(account));
173
}
174
}
175
const allSessions = Array.from(allSessionsForAccounts.values());
176
this._logger.info('[getSessions] [all]', `returned ${allSessions.length} session(s)`);
177
return allSessions;
178
}
179
180
const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);
181
const sessions = await this.getAllSessionsForPca(cachedPca, scopeData, options?.account);
182
this._logger.info(`[getSessions] [${scopeData.scopeStr}] returned ${sessions.length} session(s)`);
183
return sessions;
184
185
}
186
187
async createSession(scopes: readonly string[], options: AuthenticationProviderSessionOptions): Promise<AuthenticationSession> {
188
const scopeData = new ScopeData(scopes, undefined, options.authorizationServer);
189
// Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead.
190
191
this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting');
192
const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);
193
194
// Used for showing a friendlier message to the user when the explicitly cancel a flow.
195
let userCancelled: boolean | undefined;
196
const yes = l10n.t('Yes');
197
const no = l10n.t('No');
198
const promptToContinue = async (mode: string) => {
199
if (userCancelled === undefined) {
200
// We haven't had a failure yet so wait to prompt
201
return;
202
}
203
const message = userCancelled
204
? l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode)
205
: l10n.t('You have not yet finished authorizing this extension to use your Microsoft Account. Would you like to try a different way? ({0})', mode);
206
const result = await window.showWarningMessage(message, yes, no);
207
if (result !== yes) {
208
throw new CancellationError();
209
}
210
};
211
212
const isNodeEnvironment = typeof process !== 'undefined' && typeof process?.versions?.node === 'string';
213
const flows = getMsalFlows({
214
extensionHost: isNodeEnvironment
215
? this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote
216
: ExtensionHost.WebWorker,
217
isBrokerSupported: cachedPca.isBrokerAvailable
218
});
219
220
const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();
221
let lastError: Error | undefined;
222
for (const flow of flows) {
223
if (flow !== flows[0]) {
224
try {
225
await promptToContinue(flow.label);
226
} finally {
227
this._telemetryReporter.sendLoginFailedEvent();
228
}
229
}
230
try {
231
const result = await flow.trigger({
232
cachedPca,
233
authority,
234
scopes: scopeData.scopesToSend,
235
loginHint: options.account?.label,
236
windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined,
237
logger: this._logger,
238
uriHandler: this._uriHandler
239
});
240
241
const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes);
242
this._telemetryReporter.sendLoginEvent(session.scopes);
243
this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'returned session');
244
return session;
245
} catch (e) {
246
lastError = e;
247
if (e instanceof ServerError || (e as ClientAuthError)?.errorCode === ClientAuthErrorCodes.userCanceled) {
248
this._telemetryReporter.sendLoginFailedEvent();
249
throw e;
250
}
251
// Continue to next flow
252
if (e instanceof CancellationError) {
253
userCancelled = true;
254
}
255
}
256
}
257
258
this._telemetryReporter.sendLoginFailedEvent();
259
throw lastError ?? new Error('No auth flow succeeded');
260
}
261
262
async removeSession(sessionId: string): Promise<void> {
263
this._logger.info('[removeSession]', sessionId, 'starting');
264
const promises = new Array<Promise<void>>();
265
for (const cachedPca of this._publicClientManager.getAll()) {
266
const accounts = cachedPca.accounts;
267
for (const account of accounts) {
268
if (account.homeAccountId === sessionId) {
269
this._telemetryReporter.sendLogoutEvent();
270
promises.push(cachedPca.removeAccount(account));
271
this._logger.info(`[removeSession] [${sessionId}] [${cachedPca.clientId}] removing session...`);
272
}
273
}
274
}
275
if (!promises.length) {
276
this._logger.info('[removeSession]', sessionId, 'session not found');
277
return;
278
}
279
const results = await Promise.allSettled(promises);
280
for (const result of results) {
281
if (result.status === 'rejected') {
282
this._telemetryReporter.sendLogoutFailedEvent();
283
this._logger.error('[removeSession]', sessionId, 'error removing session', result.reason);
284
}
285
}
286
287
this._logger.info('[removeSession]', sessionId, `attempted to remove ${promises.length} sessions`);
288
}
289
290
async getSessionsFromChallenges(constraint: AuthenticationConstraint, options: AuthenticationProviderSessionOptions): Promise<readonly AuthenticationSession[]> {
291
this._logger.info('[getSessionsFromChallenges]', 'starting with', constraint.challenges.length, 'challenges');
292
293
// Use scopes from constraint if provided, otherwise extract from challenges
294
const scopes = constraint.scopes?.length ? [...constraint.scopes] : this.extractScopesFromChallenges(constraint.challenges);
295
const claims = this.extractClaimsFromChallenges(constraint.challenges);
296
if (!claims) {
297
throw new Error('No claims found in authentication challenges');
298
}
299
const scopeData = new ScopeData(scopes, claims, options?.authorizationServer);
300
this._logger.info('[getSessionsFromChallenges]', `[${scopeData.scopeStr}]`, 'with claims:', scopeData.claims);
301
302
const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);
303
const sessions = await this.getAllSessionsForPca(cachedPca, scopeData, options?.account);
304
305
this._logger.info('[getSessionsFromChallenges]', 'returning', sessions.length, 'sessions');
306
return sessions;
307
}
308
309
async createSessionFromChallenges(constraint: AuthenticationConstraint, options: AuthenticationProviderSessionOptions): Promise<AuthenticationSession> {
310
this._logger.info('[createSessionFromChallenges]', 'starting with', constraint.challenges.length, 'challenges');
311
312
// Use scopes from constraint if provided, otherwise extract from challenges
313
const scopes = constraint.scopes?.length ? [...constraint.scopes] : this.extractScopesFromChallenges(constraint.challenges);
314
const claims = this.extractClaimsFromChallenges(constraint.challenges);
315
316
// Use scopes if available, otherwise fall back to default scopes
317
const effectiveScopes = scopes.length > 0 ? scopes : ['https://graph.microsoft.com/User.Read'];
318
319
const scopeData = new ScopeData(effectiveScopes, claims, options.authorizationServer);
320
this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'starting with claims:', claims);
321
322
const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);
323
324
// Used for showing a friendlier message to the user when the explicitly cancel a flow.
325
let userCancelled: boolean | undefined;
326
const yes = l10n.t('Yes');
327
const no = l10n.t('No');
328
const promptToContinue = async (mode: string) => {
329
if (userCancelled === undefined) {
330
// We haven't had a failure yet so wait to prompt
331
return;
332
}
333
const message = userCancelled
334
? l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode)
335
: l10n.t('You have not yet finished authorizing this extension to use your Microsoft Account. Would you like to try a different way? ({0})', mode);
336
const result = await window.showWarningMessage(message, yes, no);
337
if (result !== yes) {
338
throw new CancellationError();
339
}
340
};
341
342
const isNodeEnvironment = typeof process !== 'undefined' && typeof process?.versions?.node === 'string';
343
const flows = getMsalFlows({
344
extensionHost: isNodeEnvironment
345
? this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote
346
: ExtensionHost.WebWorker,
347
isBrokerSupported: cachedPca.isBrokerAvailable
348
});
349
350
const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();
351
let lastError: Error | undefined;
352
for (const flow of flows) {
353
if (flow !== flows[0]) {
354
try {
355
await promptToContinue(flow.label);
356
} finally {
357
this._telemetryReporter.sendLoginFailedEvent();
358
}
359
}
360
try {
361
// Create the authentication request with claims if provided
362
const authRequest = {
363
cachedPca,
364
authority,
365
scopes: scopeData.scopesToSend,
366
loginHint: options.account?.label,
367
windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined,
368
logger: this._logger,
369
uriHandler: this._uriHandler,
370
claims: scopeData.claims
371
};
372
373
const result = await flow.trigger(authRequest);
374
375
const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes);
376
this._telemetryReporter.sendLoginEvent(session.scopes);
377
this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'returned session');
378
return session;
379
} catch (e) {
380
lastError = e as Error;
381
if (e instanceof ClientAuthError && e.errorCode === ClientAuthErrorCodes.userCanceled) {
382
this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'user cancelled');
383
userCancelled = true;
384
continue;
385
}
386
this._logger.error('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'error', e);
387
throw e;
388
}
389
}
390
391
this._telemetryReporter.sendLoginFailedEvent();
392
throw lastError ?? new Error('No auth flow succeeded');
393
}
394
395
private extractScopesFromChallenges(challenges: readonly AuthenticationChallenge[]): string[] {
396
const scopes: string[] = [];
397
for (const challenge of challenges) {
398
if (challenge.scheme.toLowerCase() === 'bearer' && challenge.params.scope) {
399
scopes.push(...challenge.params.scope.split(' '));
400
}
401
}
402
return scopes;
403
}
404
405
private extractClaimsFromChallenges(challenges: readonly AuthenticationChallenge[]): string | undefined {
406
for (const challenge of challenges) {
407
if (challenge.scheme.toLowerCase() === 'bearer' && challenge.params.claims) {
408
try {
409
return base64Decode(challenge.params.claims);
410
} catch (e) {
411
this._logger.warn('[extractClaimsFromChallenges]', 'failed to decode claims... checking if it is already JSON', e);
412
try {
413
JSON.parse(challenge.params.claims);
414
return challenge.params.claims;
415
} catch (e) {
416
this._logger.error('[extractClaimsFromChallenges]', 'failed to parse claims as JSON... returning undefined', e);
417
}
418
}
419
}
420
}
421
return undefined;
422
}
423
424
//#endregion
425
426
private async getAllSessionsForPca(
427
cachedPca: ICachedPublicClientApplication,
428
scopeData: ScopeData,
429
accountFilter?: AuthenticationSessionAccountInformation
430
): Promise<AuthenticationSession[]> {
431
let filteredAccounts = accountFilter
432
? cachedPca.accounts.filter(a => a.homeAccountId === accountFilter.id)
433
: cachedPca.accounts;
434
435
// Group accounts by homeAccountId
436
const accountGroups = new Map<string, AccountInfo[]>();
437
for (const account of filteredAccounts) {
438
const existing = accountGroups.get(account.homeAccountId) || [];
439
existing.push(account);
440
accountGroups.set(account.homeAccountId, existing);
441
}
442
443
// Filter to one account per homeAccountId
444
filteredAccounts = Array.from(accountGroups.values()).map(accounts => {
445
if (accounts.length === 1) {
446
return accounts[0];
447
}
448
449
// If we have a specific tenant to target, prefer that one
450
if (scopeData.tenantId) {
451
const matchingTenant = accounts.find(a => a.tenantId === scopeData.tenantId);
452
if (matchingTenant) {
453
return matchingTenant;
454
}
455
}
456
457
// Otherwise prefer the home tenant
458
return accounts.find(a => a.tenantId === a.idTokenClaims?.tid) || accounts[0];
459
});
460
461
const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();
462
const sessions: AuthenticationSession[] = [];
463
return this._eventBufferer.bufferEventsAsync(async () => {
464
for (const account of filteredAccounts) {
465
try {
466
let forceRefresh: true | undefined;
467
if (scopeData.tenantId) {
468
// If the tenants do not match, then we need to skip the cache
469
// to get a new token for the new tenant
470
if (account.tenantId !== scopeData.tenantId) {
471
forceRefresh = true;
472
}
473
} else {
474
// If we are requesting the home tenant and we don't yet have
475
// a token for the home tenant, we need to skip the cache
476
// to get a new token for the home tenant
477
if (account.tenantId !== account.idTokenClaims?.tid) {
478
forceRefresh = true;
479
}
480
}
481
// When claims are present, force refresh to ensure we get a token that satisfies the claims
482
let claims: string | undefined;
483
if (scopeData.claims) {
484
forceRefresh = true;
485
claims = scopeData.claims;
486
}
487
let redirectUri: string | undefined;
488
// If we have the broker available and are on macOS, we HAVE to include the redirect URI or MSAL will throw an error.
489
// HOWEVER, if we are _not_ using the broker, we MUST NOT include the redirect URI or MSAL will throw an error.
490
if (cachedPca.isBrokerAvailable && process.platform === 'darwin') {
491
redirectUri = Config.macOSBrokerRedirectUri;
492
}
493
const result = await cachedPca.acquireTokenSilent({
494
account,
495
authority,
496
scopes: scopeData.scopesToSend,
497
claims,
498
redirectUri,
499
forceRefresh
500
});
501
sessions.push(this.sessionFromAuthenticationResult(result, scopeData.originalScopes));
502
} catch (e) {
503
// If we can't get a token silently, the account is probably in a bad state so we should skip it
504
// MSAL will log this already, so we don't need to log it again
505
this._telemetryReporter.sendTelemetryErrorEvent(e);
506
this._logger.info(`[getAllSessionsForPca] [${scopeData.scopeStr}] [${account.username}] failed to acquire token silently, skipping account`, JSON.stringify(e));
507
continue;
508
}
509
}
510
return sessions;
511
});
512
}
513
514
private sessionFromAuthenticationResult(result: AuthenticationResult, scopes: readonly string[]): AuthenticationSession & { idToken: string } {
515
return {
516
accessToken: result.accessToken,
517
idToken: result.idToken,
518
id: result.account?.homeAccountId ?? result.uniqueId,
519
account: {
520
id: result.account?.homeAccountId ?? result.uniqueId,
521
label: result.account?.username.toLowerCase() ?? 'Unknown',
522
},
523
scopes
524
};
525
}
526
527
private sessionFromAccountInfo(account: AccountInfo): AuthenticationSession {
528
return {
529
accessToken: '1234',
530
id: account.homeAccountId,
531
scopes: [],
532
account: {
533
id: account.homeAccountId,
534
label: account.username.toLowerCase(),
535
},
536
idToken: account.idToken,
537
};
538
}
539
}
540
541