Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/microsoft-authentication/src/AADHelper.ts
3314 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as vscode from 'vscode';
7
import * as path from 'path';
8
import { isSupportedEnvironment } from './common/uri';
9
import { IntervalTimer, raceCancellationAndTimeoutError, SequencerByKey } from './common/async';
10
import { generateCodeChallenge, generateCodeVerifier, randomUUID } from './cryptoUtils';
11
import { BetterTokenStorage, IDidChangeInOtherWindowEvent } from './betterSecretStorage';
12
import { LoopbackAuthServer } from './node/authServer';
13
import { base64Decode } from './node/buffer';
14
import fetch from './node/fetch';
15
import { UriEventHandler } from './UriEventHandler';
16
import TelemetryReporter from '@vscode/extension-telemetry';
17
import { Environment } from '@azure/ms-rest-azure-env';
18
19
const redirectUrl = 'https://vscode.dev/redirect';
20
const defaultActiveDirectoryEndpointUrl = Environment.AzureCloud.activeDirectoryEndpointUrl;
21
const DEFAULT_CLIENT_ID = 'aebc6443-996d-45c2-90f0-388ff96faa56';
22
const DEFAULT_TENANT = 'organizations';
23
const MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad';
24
const MSA_PASSTHRU_TID = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a';
25
26
const enum MicrosoftAccountType {
27
AAD = 'aad',
28
MSA = 'msa',
29
Unknown = 'unknown'
30
}
31
32
interface IToken {
33
accessToken?: string; // When unable to refresh due to network problems, the access token becomes undefined
34
idToken?: string; // depending on the scopes can be either supplied or empty
35
36
expiresIn?: number; // How long access token is valid, in seconds
37
expiresAt?: number; // UNIX epoch time at which token will expire
38
refreshToken: string;
39
40
account: {
41
label: string;
42
id: string;
43
type: MicrosoftAccountType;
44
};
45
scope: string;
46
sessionId: string; // The account id + the scope
47
}
48
49
export interface IStoredSession {
50
id: string;
51
refreshToken: string;
52
scope: string; // Scopes are alphabetized and joined with a space
53
account: {
54
label: string;
55
id: string;
56
};
57
endpoint: string | undefined;
58
}
59
60
export interface ITokenResponse {
61
access_token: string;
62
expires_in: number;
63
ext_expires_in: number;
64
refresh_token: string;
65
scope: string;
66
token_type: string;
67
id_token?: string;
68
}
69
70
export interface IMicrosoftTokens {
71
accessToken: string;
72
idToken?: string;
73
}
74
75
interface IScopeData {
76
originalScopes?: string[];
77
scopes: string[];
78
scopeStr: string;
79
scopesToSend: string;
80
clientId: string;
81
tenant: string;
82
}
83
84
export const REFRESH_NETWORK_FAILURE = 'Network failure';
85
86
export class AzureActiveDirectoryService {
87
// For details on why this is set to 2/3... see https://github.com/microsoft/vscode/issues/133201#issuecomment-966668197
88
private static REFRESH_TIMEOUT_MODIFIER = 1000 * 2 / 3;
89
private static POLLING_CONSTANT = 1000 * 60 * 30;
90
91
private _tokens: IToken[] = [];
92
private _refreshTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>();
93
private _sessionChangeEmitter: vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent> = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
94
95
// Used to keep track of current requests when not using the local server approach.
96
private _pendingNonces = new Map<string, string[]>();
97
private _codeExchangePromises = new Map<string, Promise<vscode.AuthenticationSession>>();
98
private _codeVerfifiers = new Map<string, string>();
99
100
// Used to keep track of tokens that we need to store but can't because we aren't the focused window.
101
private _pendingTokensToStore: Map<string, IToken> = new Map<string, IToken>();
102
103
// Used to sequence requests to the same scope.
104
private _sequencer = new SequencerByKey<string>();
105
106
constructor(
107
private readonly _logger: vscode.LogOutputChannel,
108
_context: vscode.ExtensionContext,
109
private readonly _uriHandler: UriEventHandler,
110
private readonly _tokenStorage: BetterTokenStorage<IStoredSession>,
111
private readonly _telemetryReporter: TelemetryReporter,
112
private readonly _env: Environment
113
) {
114
_context.subscriptions.push(this._tokenStorage.onDidChangeInOtherWindow((e) => this.checkForUpdates(e)));
115
_context.subscriptions.push(vscode.window.onDidChangeWindowState(async (e) => e.focused && await this.storePendingTokens()));
116
117
// In the event that a window isn't focused for a long time, we should still try to store the tokens at some point.
118
const timer = new IntervalTimer();
119
timer.cancelAndSet(
120
() => !vscode.window.state.focused && this.storePendingTokens(),
121
// 5 hours + random extra 0-30 seconds so that each window doesn't try to store at the same time
122
(18000000) + Math.floor(Math.random() * 30000));
123
_context.subscriptions.push(timer);
124
}
125
126
public async initialize(): Promise<void> {
127
this._logger.trace('Reading sessions from secret storage...');
128
const sessions = await this._tokenStorage.getAll(item => this.sessionMatchesEndpoint(item));
129
this._logger.trace(`Got ${sessions.length} stored sessions`);
130
131
const refreshes = sessions.map(async session => {
132
this._logger.trace(`[${session.scope}] '${session.id}' Read stored session`);
133
const scopes = session.scope.split(' ');
134
const scopeData: IScopeData = {
135
scopes,
136
scopeStr: session.scope,
137
// filter our special scopes
138
scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
139
clientId: this.getClientId(scopes),
140
tenant: this.getTenantId(scopes),
141
};
142
try {
143
await this.refreshToken(session.refreshToken, scopeData, session.id);
144
} catch (e) {
145
// If we aren't connected to the internet, then wait and try to refresh again later.
146
if (e.message === REFRESH_NETWORK_FAILURE) {
147
this._tokens.push({
148
accessToken: undefined,
149
refreshToken: session.refreshToken,
150
account: {
151
...session.account,
152
type: MicrosoftAccountType.Unknown
153
},
154
scope: session.scope,
155
sessionId: session.id
156
});
157
} else {
158
vscode.window.showErrorMessage(vscode.l10n.t('You have been signed out because reading stored authentication information failed.'));
159
this._logger.error(e);
160
await this.removeSessionByIToken({
161
accessToken: undefined,
162
refreshToken: session.refreshToken,
163
account: {
164
...session.account,
165
type: MicrosoftAccountType.Unknown
166
},
167
scope: session.scope,
168
sessionId: session.id
169
});
170
}
171
}
172
});
173
174
const result = await Promise.allSettled(refreshes);
175
for (const res of result) {
176
if (res.status === 'rejected') {
177
this._logger.error(`Failed to initialize stored data: ${res.reason}`);
178
this.clearSessions();
179
break;
180
}
181
}
182
183
for (const token of this._tokens) {
184
/* __GDPR__
185
"account" : {
186
"owner": "TylerLeonhardt",
187
"comment": "Used to determine the usage of the Microsoft Auth Provider.",
188
"scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." },
189
"accountType": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what account types are being used." }
190
}
191
*/
192
this._telemetryReporter.sendTelemetryEvent('account', {
193
// Get rid of guids from telemetry.
194
scopes: JSON.stringify(token.scope.replace(/[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/i, '{guid}').split(' ')),
195
accountType: token.account.type
196
});
197
}
198
}
199
200
//#region session operations
201
202
public get onDidChangeSessions(): vscode.Event<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent> {
203
return this._sessionChangeEmitter.event;
204
}
205
206
public getSessions(scopes: string[] | undefined, { account, authorizationServer }: vscode.AuthenticationProviderSessionOptions = {}): Promise<vscode.AuthenticationSession[]> {
207
if (!scopes) {
208
this._logger.info('Getting sessions for all scopes...');
209
const sessions = this._tokens
210
.filter(token => !account?.label || token.account.label === account.label)
211
.map(token => this.convertToSessionSync(token));
212
this._logger.info(`Got ${sessions.length} sessions for all scopes${account ? ` for account '${account.label}'` : ''}...`);
213
return Promise.resolve(sessions);
214
}
215
216
let modifiedScopes = [...scopes];
217
if (!modifiedScopes.includes('openid')) {
218
modifiedScopes.push('openid');
219
}
220
if (!modifiedScopes.includes('email')) {
221
modifiedScopes.push('email');
222
}
223
if (!modifiedScopes.includes('profile')) {
224
modifiedScopes.push('profile');
225
}
226
if (!modifiedScopes.includes('offline_access')) {
227
modifiedScopes.push('offline_access');
228
}
229
if (authorizationServer) {
230
const tenant = authorizationServer.path.split('/')[1];
231
if (tenant) {
232
modifiedScopes.push(`VSCODE_TENANT:${tenant}`);
233
}
234
}
235
modifiedScopes = modifiedScopes.sort();
236
237
const modifiedScopesStr = modifiedScopes.join(' ');
238
const clientId = this.getClientId(scopes);
239
const scopeData: IScopeData = {
240
clientId,
241
originalScopes: scopes,
242
scopes: modifiedScopes,
243
scopeStr: modifiedScopesStr,
244
// filter our special scopes
245
scopesToSend: modifiedScopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
246
tenant: this.getTenantId(modifiedScopes),
247
};
248
249
this._logger.trace(`[${scopeData.scopeStr}] Queued getting sessions` + account ? ` for ${account?.label}` : '');
250
return this._sequencer.queue(modifiedScopesStr, () => this.doGetSessions(scopeData, account));
251
}
252
253
private async doGetSessions(scopeData: IScopeData, account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession[]> {
254
this._logger.info(`[${scopeData.scopeStr}] Getting sessions` + account ? ` for ${account?.label}` : '');
255
256
const matchingTokens = this._tokens
257
.filter(token => token.scope === scopeData.scopeStr)
258
.filter(token => !account?.label || token.account.label === account.label);
259
// If we still don't have a matching token try to get a new token from an existing token by using
260
// the refreshToken. This is documented here:
261
// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#refresh-the-access-token
262
// "Refresh tokens are valid for all permissions that your client has already received consent for."
263
if (!matchingTokens.length) {
264
// Get a token with the correct client id and account.
265
let token: IToken | undefined;
266
for (const t of this._tokens) {
267
// No refresh token, so we can't make a new token from this session
268
if (!t.refreshToken) {
269
continue;
270
}
271
// Need to make sure the account matches if we were provided one
272
if (account?.label && t.account.label !== account.label) {
273
continue;
274
}
275
// If the client id is the default client id, then check for the absence of the VSCODE_CLIENT_ID scope
276
if (scopeData.clientId === DEFAULT_CLIENT_ID && !t.scope.includes('VSCODE_CLIENT_ID')) {
277
token = t;
278
break;
279
}
280
// If the client id is not the default client id, then check for the matching VSCODE_CLIENT_ID scope
281
if (scopeData.clientId !== DEFAULT_CLIENT_ID && t.scope.includes(`VSCODE_CLIENT_ID:${scopeData.clientId}`)) {
282
token = t;
283
break;
284
}
285
}
286
287
if (token) {
288
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Found a matching token with a different scopes '${token.scope}'. Attempting to get a new session using the existing session.`);
289
try {
290
const itoken = await this.doRefreshToken(token.refreshToken, scopeData);
291
this._sessionChangeEmitter.fire({ added: [this.convertToSessionSync(itoken)], removed: [], changed: [] });
292
matchingTokens.push(itoken);
293
} catch (err) {
294
this._logger.error(`[${scopeData.scopeStr}] Attempted to get a new session using the existing session with scopes '${token.scope}' but it failed due to: ${err.message ?? err}`);
295
}
296
}
297
}
298
299
this._logger.info(`[${scopeData.scopeStr}] Got ${matchingTokens.length} sessions`);
300
const results = await Promise.allSettled(matchingTokens.map(token => this.convertToSession(token, scopeData)));
301
return results
302
.filter(result => result.status === 'fulfilled')
303
.map(result => (result as PromiseFulfilledResult<vscode.AuthenticationSession>).value);
304
}
305
306
public createSession(scopes: string[], { account, authorizationServer }: vscode.AuthenticationProviderSessionOptions = {}): Promise<vscode.AuthenticationSession> {
307
let modifiedScopes = [...scopes];
308
if (!modifiedScopes.includes('openid')) {
309
modifiedScopes.push('openid');
310
}
311
if (!modifiedScopes.includes('email')) {
312
modifiedScopes.push('email');
313
}
314
if (!modifiedScopes.includes('profile')) {
315
modifiedScopes.push('profile');
316
}
317
if (!modifiedScopes.includes('offline_access')) {
318
modifiedScopes.push('offline_access');
319
}
320
if (authorizationServer) {
321
const tenant = authorizationServer.path.split('/')[1];
322
if (tenant) {
323
modifiedScopes.push(`VSCODE_TENANT:${tenant}`);
324
}
325
}
326
modifiedScopes = modifiedScopes.sort();
327
const scopeData: IScopeData = {
328
originalScopes: scopes,
329
scopes: modifiedScopes,
330
scopeStr: modifiedScopes.join(' '),
331
// filter our special scopes
332
scopesToSend: modifiedScopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
333
clientId: this.getClientId(scopes),
334
tenant: this.getTenantId(modifiedScopes),
335
};
336
337
this._logger.trace(`[${scopeData.scopeStr}] Queued creating session`);
338
return this._sequencer.queue(scopeData.scopeStr, () => this.doCreateSession(scopeData, account));
339
}
340
341
private async doCreateSession(scopeData: IScopeData, account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession> {
342
this._logger.info(`[${scopeData.scopeStr}] Creating session` + account ? ` for ${account?.label}` : '');
343
344
const runsRemote = vscode.env.remoteName !== undefined;
345
const runsServerless = vscode.env.remoteName === undefined && vscode.env.uiKind === vscode.UIKind.Web;
346
347
if (runsServerless && this._env.activeDirectoryEndpointUrl !== defaultActiveDirectoryEndpointUrl) {
348
throw new Error('Sign in to non-public clouds is not supported on the web.');
349
}
350
351
return await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Signing in to your account...'), cancellable: true }, async (_progress, token) => {
352
if (runsRemote || runsServerless) {
353
return await this.createSessionWithoutLocalServer(scopeData, account?.label, token);
354
}
355
356
try {
357
return await this.createSessionWithLocalServer(scopeData, account?.label, token);
358
} catch (e) {
359
this._logger.error(`[${scopeData.scopeStr}] Error creating session: ${e}`);
360
361
// If the error was about starting the server, try directly hitting the login endpoint instead
362
if (e.message === 'Error listening to server' || e.message === 'Closed' || e.message === 'Timeout waiting for port') {
363
return this.createSessionWithoutLocalServer(scopeData, account?.label, token);
364
}
365
366
throw e;
367
}
368
});
369
}
370
371
private async createSessionWithLocalServer(scopeData: IScopeData, loginHint: string | undefined, token: vscode.CancellationToken): Promise<vscode.AuthenticationSession> {
372
this._logger.trace(`[${scopeData.scopeStr}] Starting login flow with local server`);
373
const codeVerifier = generateCodeVerifier();
374
const codeChallenge = await generateCodeChallenge(codeVerifier);
375
const qs = new URLSearchParams({
376
response_type: 'code',
377
response_mode: 'query',
378
client_id: scopeData.clientId,
379
redirect_uri: redirectUrl,
380
scope: scopeData.scopesToSend,
381
code_challenge_method: 'S256',
382
code_challenge: codeChallenge,
383
});
384
if (loginHint) {
385
qs.set('login_hint', loginHint);
386
} else {
387
qs.set('prompt', 'select_account');
388
}
389
const loginUrl = new URL(`${scopeData.tenant}/oauth2/v2.0/authorize?${qs.toString()}`, this._env.activeDirectoryEndpointUrl).toString();
390
const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl);
391
await server.start();
392
393
let codeToExchange;
394
try {
395
vscode.env.openExternal(vscode.Uri.parse(`http://127.0.0.1:${server.port}/signin?nonce=${encodeURIComponent(server.nonce)}`));
396
const { code } = await raceCancellationAndTimeoutError(server.waitForOAuthResponse(), token, 1000 * 60 * 5); // 5 minutes
397
codeToExchange = code;
398
} finally {
399
setTimeout(() => {
400
void server.stop();
401
}, 5000);
402
}
403
404
const session = await this.exchangeCodeForSession(codeToExchange, codeVerifier, scopeData);
405
this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' Sending change event for added session`);
406
this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });
407
this._logger.info(`[${scopeData.scopeStr}] '${session.id}' session successfully created!`);
408
return session;
409
}
410
411
private async createSessionWithoutLocalServer(scopeData: IScopeData, loginHint: string | undefined, token: vscode.CancellationToken): Promise<vscode.AuthenticationSession> {
412
this._logger.trace(`[${scopeData.scopeStr}] Starting login flow without local server`);
413
let callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.microsoft-authentication`));
414
const nonce = generateCodeVerifier();
415
const callbackQuery = new URLSearchParams(callbackUri.query);
416
callbackQuery.set('nonce', encodeURIComponent(nonce));
417
callbackUri = callbackUri.with({
418
query: callbackQuery.toString()
419
});
420
const state = encodeURIComponent(callbackUri.toString(true));
421
const codeVerifier = generateCodeVerifier();
422
const codeChallenge = await generateCodeChallenge(codeVerifier);
423
const signInUrl = new URL(`${scopeData.tenant}/oauth2/v2.0/authorize`, this._env.activeDirectoryEndpointUrl);
424
const qs = new URLSearchParams({
425
response_type: 'code',
426
client_id: encodeURIComponent(scopeData.clientId),
427
response_mode: 'query',
428
redirect_uri: redirectUrl,
429
state,
430
scope: scopeData.scopesToSend,
431
code_challenge_method: 'S256',
432
code_challenge: codeChallenge,
433
});
434
if (loginHint) {
435
qs.append('login_hint', loginHint);
436
} else {
437
qs.append('prompt', 'select_account');
438
}
439
signInUrl.search = qs.toString();
440
const uri = vscode.Uri.parse(signInUrl.toString());
441
vscode.env.openExternal(uri);
442
443
444
const existingNonces = this._pendingNonces.get(scopeData.scopeStr) || [];
445
this._pendingNonces.set(scopeData.scopeStr, [...existingNonces, nonce]);
446
447
// Register a single listener for the URI callback, in case the user starts the login process multiple times
448
// before completing it.
449
let existingPromise = this._codeExchangePromises.get(scopeData.scopeStr);
450
let inputBox: vscode.InputBox | undefined;
451
if (!existingPromise) {
452
if (isSupportedEnvironment(callbackUri)) {
453
existingPromise = this.handleCodeResponse(scopeData);
454
} else {
455
inputBox = vscode.window.createInputBox();
456
existingPromise = this.handleCodeInputBox(inputBox, codeVerifier, scopeData);
457
}
458
this._codeExchangePromises.set(scopeData.scopeStr, existingPromise);
459
}
460
461
this._codeVerfifiers.set(nonce, codeVerifier);
462
463
return await raceCancellationAndTimeoutError(existingPromise, token, 1000 * 60 * 5) // 5 minutes
464
.finally(() => {
465
this._pendingNonces.delete(scopeData.scopeStr);
466
this._codeExchangePromises.delete(scopeData.scopeStr);
467
this._codeVerfifiers.delete(nonce);
468
inputBox?.dispose();
469
});
470
}
471
472
public async removeSessionById(sessionId: string, writeToDisk: boolean = true): Promise<vscode.AuthenticationSession | undefined> {
473
const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);
474
if (tokenIndex === -1) {
475
this._logger.warn(`'${sessionId}' Session not found to remove`);
476
return Promise.resolve(undefined);
477
}
478
479
const token = this._tokens.splice(tokenIndex, 1)[0];
480
this._logger.trace(`[${token.scope}] '${sessionId}' Queued removing session`);
481
return this._sequencer.queue(token.scope, () => this.removeSessionByIToken(token, writeToDisk));
482
}
483
484
public async clearSessions() {
485
this._logger.trace('Logging out of all sessions');
486
this._tokens = [];
487
await this._tokenStorage.deleteAll(item => this.sessionMatchesEndpoint(item));
488
489
this._refreshTimeouts.forEach(timeout => {
490
clearTimeout(timeout);
491
});
492
493
this._refreshTimeouts.clear();
494
this._logger.trace('All sessions logged out');
495
}
496
497
private async removeSessionByIToken(token: IToken, writeToDisk: boolean = true): Promise<vscode.AuthenticationSession | undefined> {
498
this._logger.info(`[${token.scope}] '${token.sessionId}' Logging out of session`);
499
this.removeSessionTimeout(token.sessionId);
500
501
if (writeToDisk) {
502
await this._tokenStorage.delete(token.sessionId);
503
}
504
505
const tokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);
506
if (tokenIndex !== -1) {
507
this._tokens.splice(tokenIndex, 1);
508
}
509
510
const session = this.convertToSessionSync(token);
511
this._logger.trace(`[${token.scope}] '${token.sessionId}' Sending change event for session that was removed`);
512
this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
513
this._logger.info(`[${token.scope}] '${token.sessionId}' Logged out of session successfully!`);
514
return session;
515
}
516
517
//#endregion
518
519
//#region timeout
520
521
private setSessionTimeout(sessionId: string, refreshToken: string, scopeData: IScopeData, timeout: number) {
522
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' Setting refresh timeout for ${timeout} milliseconds`);
523
this.removeSessionTimeout(sessionId);
524
this._refreshTimeouts.set(sessionId, setTimeout(async () => {
525
try {
526
const refreshedToken = await this.refreshToken(refreshToken, scopeData, sessionId);
527
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' Sending change event for session that was refreshed`);
528
this._sessionChangeEmitter.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] });
529
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' refresh timeout complete`);
530
} catch (e) {
531
if (e.message !== REFRESH_NETWORK_FAILURE) {
532
vscode.window.showErrorMessage(vscode.l10n.t('You have been signed out because reading stored authentication information failed.'));
533
await this.removeSessionById(sessionId);
534
}
535
}
536
}, timeout));
537
}
538
539
private removeSessionTimeout(sessionId: string): void {
540
const timeout = this._refreshTimeouts.get(sessionId);
541
if (timeout) {
542
clearTimeout(timeout);
543
this._refreshTimeouts.delete(sessionId);
544
}
545
}
546
547
//#endregion
548
549
//#region convert operations
550
551
private convertToTokenSync(json: ITokenResponse, scopeData: IScopeData, existingId?: string): IToken {
552
let claims = undefined;
553
this._logger.trace(`[${scopeData.scopeStr}] '${existingId ?? 'new'}' Attempting to parse token response.`);
554
555
try {
556
if (json.id_token) {
557
claims = JSON.parse(base64Decode(json.id_token.split('.')[1]));
558
} else {
559
this._logger.warn(`[${scopeData.scopeStr}] '${existingId ?? 'new'}' Attempting to parse access_token instead since no id_token was included in the response.`);
560
claims = JSON.parse(base64Decode(json.access_token.split('.')[1]));
561
}
562
} catch (e) {
563
throw e;
564
}
565
566
const id = `${claims.tid}/${(claims.oid ?? (claims.altsecid ?? '' + claims.ipd))}`;
567
const sessionId = existingId || `${id}/${randomUUID()}`;
568
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' Token response parsed successfully.`);
569
return {
570
expiresIn: json.expires_in,
571
expiresAt: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
572
accessToken: json.access_token,
573
idToken: json.id_token,
574
refreshToken: json.refresh_token,
575
scope: scopeData.scopeStr,
576
sessionId,
577
account: {
578
label: claims.preferred_username ?? claims.email ?? claims.unique_name ?? '[email protected]',
579
id,
580
type: claims.tid === MSA_TID || claims.tid === MSA_PASSTHRU_TID ? MicrosoftAccountType.MSA : MicrosoftAccountType.AAD
581
}
582
};
583
}
584
585
/**
586
* Return a session object without checking for expiry and potentially refreshing.
587
* @param token The token information.
588
*/
589
private convertToSessionSync(token: IToken): vscode.AuthenticationSession {
590
return {
591
id: token.sessionId,
592
accessToken: token.accessToken!,
593
idToken: token.idToken,
594
account: token.account,
595
scopes: token.scope.split(' ')
596
};
597
}
598
599
private async convertToSession(token: IToken, scopeData: IScopeData): Promise<vscode.AuthenticationSession> {
600
if (token.accessToken && (!token.expiresAt || token.expiresAt > Date.now())) {
601
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Token available from cache${token.expiresAt ? `, expires in ${token.expiresAt - Date.now()} milliseconds` : ''}.`);
602
return {
603
id: token.sessionId,
604
accessToken: token.accessToken,
605
idToken: token.idToken,
606
account: token.account,
607
scopes: scopeData.originalScopes ?? scopeData.scopes
608
};
609
}
610
611
try {
612
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Token expired or unavailable, trying refresh`);
613
const refreshedToken = await this.refreshToken(token.refreshToken, scopeData, token.sessionId);
614
if (refreshedToken.accessToken) {
615
return {
616
id: token.sessionId,
617
accessToken: refreshedToken.accessToken,
618
idToken: refreshedToken.idToken,
619
account: token.account,
620
// We always prefer the original scopes requested since that array is used as a key in the AuthService
621
scopes: scopeData.originalScopes ?? scopeData.scopes
622
};
623
} else {
624
throw new Error();
625
}
626
} catch (e) {
627
throw new Error('Unavailable due to network problems');
628
}
629
}
630
631
//#endregion
632
633
//#region refresh logic
634
635
private refreshToken(refreshToken: string, scopeData: IScopeData, sessionId?: string): Promise<IToken> {
636
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId ?? 'new'}' Queued refreshing token`);
637
return this._sequencer.queue(scopeData.scopeStr, () => this.doRefreshToken(refreshToken, scopeData, sessionId));
638
}
639
640
private async doRefreshToken(refreshToken: string, scopeData: IScopeData, sessionId?: string): Promise<IToken> {
641
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId ?? 'new'}' Refreshing token`);
642
const postData = new URLSearchParams({
643
refresh_token: refreshToken,
644
client_id: scopeData.clientId,
645
grant_type: 'refresh_token',
646
scope: scopeData.scopesToSend
647
}).toString();
648
649
try {
650
const json = await this.fetchTokenResponse(postData, scopeData);
651
const token = this.convertToTokenSync(json, scopeData, sessionId);
652
if (token.expiresIn) {
653
this.setSessionTimeout(token.sessionId, token.refreshToken, scopeData, token.expiresIn * AzureActiveDirectoryService.REFRESH_TIMEOUT_MODIFIER);
654
}
655
this.setToken(token, scopeData);
656
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Token refresh success`);
657
return token;
658
} catch (e) {
659
if (e.message === REFRESH_NETWORK_FAILURE) {
660
// We were unable to refresh because of a network failure (i.e. the user lost internet access).
661
// so set up a timeout to try again later. We only do this if we have a session id to reference later.
662
if (sessionId) {
663
this.setSessionTimeout(sessionId, refreshToken, scopeData, AzureActiveDirectoryService.POLLING_CONSTANT);
664
}
665
throw e;
666
}
667
this._logger.error(`[${scopeData.scopeStr}] '${sessionId ?? 'new'}' Refreshing token failed: ${e.message}`);
668
throw e;
669
}
670
}
671
672
//#endregion
673
674
//#region scope parsers
675
676
private getClientId(scopes: string[]) {
677
return scopes.reduce<string | undefined>((prev, current) => {
678
if (current.startsWith('VSCODE_CLIENT_ID:')) {
679
return current.split('VSCODE_CLIENT_ID:')[1];
680
}
681
return prev;
682
}, undefined) ?? DEFAULT_CLIENT_ID;
683
}
684
685
private getTenantId(scopes: string[]) {
686
return scopes.reduce<string | undefined>((prev, current) => {
687
if (current.startsWith('VSCODE_TENANT:')) {
688
return current.split('VSCODE_TENANT:')[1];
689
}
690
return prev;
691
}, undefined) ?? DEFAULT_TENANT;
692
}
693
694
//#endregion
695
696
//#region oauth flow
697
698
private async handleCodeResponse(scopeData: IScopeData): Promise<vscode.AuthenticationSession> {
699
let uriEventListener: vscode.Disposable;
700
return new Promise((resolve: (value: vscode.AuthenticationSession) => void, reject) => {
701
uriEventListener = this._uriHandler.event(async (uri: vscode.Uri) => {
702
try {
703
const query = new URLSearchParams(uri.query);
704
let code = query.get('code');
705
let nonce = query.get('nonce');
706
if (Array.isArray(code)) {
707
code = code[0];
708
}
709
if (!code) {
710
throw new Error('No code included in query');
711
}
712
if (Array.isArray(nonce)) {
713
nonce = nonce[0];
714
}
715
if (!nonce) {
716
throw new Error('No nonce included in query');
717
}
718
719
const acceptedStates = this._pendingNonces.get(scopeData.scopeStr) || [];
720
// Workaround double encoding issues of state in web
721
if (!acceptedStates.includes(nonce) && !acceptedStates.includes(decodeURIComponent(nonce))) {
722
throw new Error('Nonce does not match.');
723
}
724
725
const verifier = this._codeVerfifiers.get(nonce) ?? this._codeVerfifiers.get(decodeURIComponent(nonce));
726
if (!verifier) {
727
throw new Error('No available code verifier');
728
}
729
730
const session = await this.exchangeCodeForSession(code, verifier, scopeData);
731
this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });
732
this._logger.info(`[${scopeData.scopeStr}] '${session.id}' session successfully created!`);
733
resolve(session);
734
} catch (err) {
735
reject(err);
736
}
737
});
738
}).then(result => {
739
uriEventListener.dispose();
740
return result;
741
}).catch(err => {
742
uriEventListener.dispose();
743
throw err;
744
});
745
}
746
747
private async handleCodeInputBox(inputBox: vscode.InputBox, verifier: string, scopeData: IScopeData): Promise<vscode.AuthenticationSession> {
748
this._logger.trace(`[${scopeData.scopeStr}] Starting login flow with input box`);
749
inputBox.ignoreFocusOut = true;
750
inputBox.title = vscode.l10n.t('Microsoft Authentication');
751
inputBox.prompt = vscode.l10n.t('Provide the authorization code to complete the sign in flow.');
752
inputBox.placeholder = vscode.l10n.t('Paste authorization code here...');
753
return new Promise((resolve: (value: vscode.AuthenticationSession) => void, reject) => {
754
inputBox.show();
755
inputBox.onDidAccept(async () => {
756
const code = inputBox.value;
757
if (code) {
758
inputBox.dispose();
759
const session = await this.exchangeCodeForSession(code, verifier, scopeData);
760
this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' sending session changed event because session was added.`);
761
this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });
762
this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' session successfully created!`);
763
resolve(session);
764
}
765
});
766
inputBox.onDidHide(() => {
767
if (!inputBox.value) {
768
inputBox.dispose();
769
reject('Cancelled');
770
}
771
});
772
});
773
}
774
775
private async exchangeCodeForSession(code: string, codeVerifier: string, scopeData: IScopeData): Promise<vscode.AuthenticationSession> {
776
this._logger.trace(`[${scopeData.scopeStr}] Exchanging login code for session`);
777
let token: IToken | undefined;
778
try {
779
const postData = new URLSearchParams({
780
grant_type: 'authorization_code',
781
code: code,
782
client_id: scopeData.clientId,
783
scope: scopeData.scopesToSend,
784
code_verifier: codeVerifier,
785
redirect_uri: redirectUrl
786
}).toString();
787
788
const json = await this.fetchTokenResponse(postData, scopeData);
789
this._logger.trace(`[${scopeData.scopeStr}] Exchanging code for token succeeded!`);
790
token = this.convertToTokenSync(json, scopeData);
791
} catch (e) {
792
this._logger.error(`[${scopeData.scopeStr}] Error exchanging code for token: ${e}`);
793
throw e;
794
}
795
796
if (token.expiresIn) {
797
this.setSessionTimeout(token.sessionId, token.refreshToken, scopeData, token.expiresIn * AzureActiveDirectoryService.REFRESH_TIMEOUT_MODIFIER);
798
}
799
this.setToken(token, scopeData);
800
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Exchanging login code for session succeeded!`);
801
return await this.convertToSession(token, scopeData);
802
}
803
804
private async fetchTokenResponse(postData: string, scopeData: IScopeData): Promise<ITokenResponse> {
805
let endpointUrl: string;
806
if (this._env.activeDirectoryEndpointUrl !== defaultActiveDirectoryEndpointUrl) {
807
// If this is for sovereign clouds, don't try using the proxy endpoint, which supports only public cloud
808
endpointUrl = this._env.activeDirectoryEndpointUrl;
809
} else {
810
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
811
endpointUrl = proxyEndpoints?.microsoft || this._env.activeDirectoryEndpointUrl;
812
}
813
const endpoint = new URL(`${scopeData.tenant}/oauth2/v2.0/token`, endpointUrl);
814
815
let attempts = 0;
816
while (attempts <= 3) {
817
attempts++;
818
let result;
819
let errorMessage: string | undefined;
820
try {
821
result = await fetch(endpoint.toString(), {
822
method: 'POST',
823
headers: {
824
'Content-Type': 'application/x-www-form-urlencoded'
825
},
826
body: postData
827
});
828
} catch (e) {
829
errorMessage = e.message ?? e;
830
}
831
832
if (!result || result.status > 499) {
833
if (attempts > 3) {
834
this._logger.error(`[${scopeData.scopeStr}] Fetching token failed: ${result ? await result.text() : errorMessage}`);
835
break;
836
}
837
// Exponential backoff
838
await new Promise(resolve => setTimeout(resolve, 5 * attempts * attempts * 1000));
839
continue;
840
} else if (!result.ok) {
841
// For 4XX errors, the user may actually have an expired token or have changed
842
// their password recently which is throwing a 4XX. For this, we throw an error
843
// so that the user can be prompted to sign in again.
844
throw new Error(await result.text());
845
}
846
847
return await result.json() as ITokenResponse;
848
}
849
850
throw new Error(REFRESH_NETWORK_FAILURE);
851
}
852
853
//#endregion
854
855
//#region storage operations
856
857
private setToken(token: IToken, scopeData: IScopeData): void {
858
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Setting token`);
859
860
const existingTokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);
861
if (existingTokenIndex > -1) {
862
this._tokens.splice(existingTokenIndex, 1, token);
863
} else {
864
this._tokens.push(token);
865
}
866
867
// Don't await because setting the token is only useful for any new windows that open.
868
void this.storeToken(token, scopeData);
869
}
870
871
private async storeToken(token: IToken, scopeData: IScopeData): Promise<void> {
872
if (!vscode.window.state.focused) {
873
if (this._pendingTokensToStore.has(token.sessionId)) {
874
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Window is not focused, replacing token to be stored`);
875
} else {
876
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Window is not focused, pending storage of token`);
877
}
878
this._pendingTokensToStore.set(token.sessionId, token);
879
return;
880
}
881
882
await this._tokenStorage.store(token.sessionId, {
883
id: token.sessionId,
884
refreshToken: token.refreshToken,
885
scope: token.scope,
886
account: token.account,
887
endpoint: this._env.activeDirectoryEndpointUrl,
888
});
889
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Stored token`);
890
}
891
892
private async storePendingTokens(): Promise<void> {
893
if (this._pendingTokensToStore.size === 0) {
894
this._logger.trace('No pending tokens to store');
895
return;
896
}
897
898
const tokens = [...this._pendingTokensToStore.values()];
899
this._pendingTokensToStore.clear();
900
901
this._logger.trace(`Storing ${tokens.length} pending tokens...`);
902
await Promise.allSettled(tokens.map(async token => {
903
this._logger.trace(`[${token.scope}] '${token.sessionId}' Storing pending token`);
904
await this._tokenStorage.store(token.sessionId, {
905
id: token.sessionId,
906
refreshToken: token.refreshToken,
907
scope: token.scope,
908
account: token.account,
909
endpoint: this._env.activeDirectoryEndpointUrl,
910
});
911
this._logger.trace(`[${token.scope}] '${token.sessionId}' Stored pending token`);
912
}));
913
this._logger.trace('Done storing pending tokens');
914
}
915
916
private async checkForUpdates(e: IDidChangeInOtherWindowEvent<IStoredSession>): Promise<void> {
917
for (const key of e.added) {
918
const session = await this._tokenStorage.get(key);
919
if (!session) {
920
this._logger.error('session not found that was apparently just added');
921
continue;
922
}
923
924
if (!this.sessionMatchesEndpoint(session)) {
925
// If the session wasn't made for this login endpoint, ignore this update
926
continue;
927
}
928
929
const matchesExisting = this._tokens.some(token => token.scope === session.scope && token.sessionId === session.id);
930
if (!matchesExisting && session.refreshToken) {
931
try {
932
const scopes = session.scope.split(' ');
933
const scopeData: IScopeData = {
934
scopes,
935
scopeStr: session.scope,
936
// filter our special scopes
937
scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
938
clientId: this.getClientId(scopes),
939
tenant: this.getTenantId(scopes),
940
};
941
this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' Session added in another window`);
942
const token = await this.refreshToken(session.refreshToken, scopeData, session.id);
943
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Sending change event for session that was added`);
944
this._sessionChangeEmitter.fire({ added: [this.convertToSessionSync(token)], removed: [], changed: [] });
945
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Session added in another window added here`);
946
continue;
947
} catch (e) {
948
// Network failures will automatically retry on next poll.
949
if (e.message !== REFRESH_NETWORK_FAILURE) {
950
vscode.window.showErrorMessage(vscode.l10n.t('You have been signed out because reading stored authentication information failed.'));
951
await this.removeSessionById(session.id);
952
}
953
continue;
954
}
955
}
956
}
957
958
for (const { value } of e.removed) {
959
this._logger.trace(`[${value.scope}] '${value.id}' Session removed in another window`);
960
if (!this.sessionMatchesEndpoint(value)) {
961
// If the session wasn't made for this login endpoint, ignore this update
962
this._logger.trace(`[${value.scope}] '${value.id}' Session doesn't match endpoint. Skipping...`);
963
continue;
964
}
965
966
await this.removeSessionById(value.id, false);
967
this._logger.trace(`[${value.scope}] '${value.id}' Session removed in another window removed here`);
968
}
969
970
// NOTE: We don't need to handle changed sessions because all that really would give us is a new refresh token
971
// because access tokens are not stored in Secret Storage due to their short lifespan. This new refresh token
972
// is not useful in this window because we really only care about the lifetime of the _access_ token which we
973
// are already managing (see usages of `setSessionTimeout`).
974
// However, in order to minimize the amount of times we store tokens, if a token was stored via another window,
975
// we cancel any pending token storage operations.
976
for (const sessionId of e.updated) {
977
if (this._pendingTokensToStore.delete(sessionId)) {
978
this._logger.trace(`'${sessionId}' Cancelled pending token storage because token was updated in another window`);
979
}
980
}
981
}
982
983
private sessionMatchesEndpoint(session: IStoredSession): boolean {
984
// For older sessions with no endpoint set, it can be assumed to be the default endpoint
985
session.endpoint ||= defaultActiveDirectoryEndpointUrl;
986
987
return session.endpoint === this._env.activeDirectoryEndpointUrl;
988
}
989
990
//#endregion
991
}
992
993