Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts
5226 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 { PublicClientApplication, AccountInfo, SilentFlowRequest, AuthenticationResult, InteractiveRequest, LogLevel, RefreshTokenRequest, BrokerOptions, DeviceCodeRequest } from '@azure/msal-node';
7
import { NativeBrokerPlugin } from '@azure/msal-node-extensions';
8
import { Disposable, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter, workspace, env, Uri, UIKind } from 'vscode';
9
import { DeferredPromise, raceCancellationAndTimeoutError } from '../common/async';
10
import { SecretStorageCachePlugin } from '../common/cachePlugin';
11
import { MsalLoggerOptions } from '../common/loggerOptions';
12
import { ICachedPublicClientApplication } from '../common/publicClientCache';
13
import { IAccountAccess } from '../common/accountAccess';
14
import { MicrosoftAuthenticationTelemetryReporter } from '../common/telemetryReporter';
15
16
export class CachedPublicClientApplication implements ICachedPublicClientApplication {
17
// Core properties
18
private _pca: PublicClientApplication;
19
private _accounts: AccountInfo[] = [];
20
private _sequencer = new Sequencer();
21
private readonly _disposable: Disposable;
22
23
// Cache properties
24
private readonly _secretStorageCachePlugin: SecretStorageCachePlugin;
25
26
// Broker properties
27
readonly isBrokerAvailable: boolean = false;
28
29
//#region Events
30
31
private readonly _onDidAccountsChangeEmitter = new EventEmitter<{ added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }>;
32
readonly onDidAccountsChange = this._onDidAccountsChangeEmitter.event;
33
34
private readonly _onDidRemoveLastAccountEmitter = new EventEmitter<void>();
35
readonly onDidRemoveLastAccount = this._onDidRemoveLastAccountEmitter.event;
36
37
//#endregion
38
39
private constructor(
40
private readonly _clientId: string,
41
private readonly _secretStorage: SecretStorage,
42
private readonly _accountAccess: IAccountAccess,
43
private readonly _logger: LogOutputChannel,
44
telemetryReporter: MicrosoftAuthenticationTelemetryReporter
45
) {
46
this._secretStorageCachePlugin = new SecretStorageCachePlugin(
47
this._secretStorage,
48
// Include the prefix as a differentiator to other secrets
49
`pca:${this._clientId}`
50
);
51
52
const loggerOptions = new MsalLoggerOptions(_logger, telemetryReporter);
53
let broker: BrokerOptions | undefined;
54
if (env.uiKind === UIKind.Web) {
55
this._logger.info(`[${this._clientId}] Native Broker is not available in web UI`);
56
} else if (workspace.getConfiguration('microsoft-authentication').get<'msal' | 'msal-no-broker'>('implementation') === 'msal-no-broker') {
57
this._logger.info(`[${this._clientId}] Native Broker disabled via settings`);
58
} else {
59
const nativeBrokerPlugin = new NativeBrokerPlugin();
60
this.isBrokerAvailable = nativeBrokerPlugin.isBrokerAvailable;
61
this._logger.info(`[${this._clientId}] Native Broker enabled: ${this.isBrokerAvailable}`);
62
if (this.isBrokerAvailable) {
63
broker = { nativeBrokerPlugin };
64
}
65
}
66
this._pca = new PublicClientApplication({
67
auth: { clientId: _clientId },
68
system: {
69
loggerOptions: {
70
correlationId: _clientId,
71
loggerCallback: (level, message, containsPii) => loggerOptions.loggerCallback(level, message, containsPii),
72
logLevel: LogLevel.Trace,
73
// Enable PII logging since it will only go to the output channel
74
piiLoggingEnabled: true
75
}
76
},
77
broker,
78
cache: { cachePlugin: this._secretStorageCachePlugin }
79
});
80
this._disposable = Disposable.from(
81
this._registerOnSecretStorageChanged(),
82
this._onDidAccountsChangeEmitter,
83
this._onDidRemoveLastAccountEmitter,
84
this._secretStorageCachePlugin
85
);
86
}
87
88
get accounts(): AccountInfo[] { return this._accounts; }
89
get clientId(): string { return this._clientId; }
90
91
static async create(
92
clientId: string,
93
secretStorage: SecretStorage,
94
accountAccess: IAccountAccess,
95
logger: LogOutputChannel,
96
telemetryReporter: MicrosoftAuthenticationTelemetryReporter
97
): Promise<CachedPublicClientApplication> {
98
const app = new CachedPublicClientApplication(clientId, secretStorage, accountAccess, logger, telemetryReporter);
99
await app.initialize();
100
return app;
101
}
102
103
private async initialize(): Promise<void> {
104
await this._sequencer.queue(() => this._update());
105
}
106
107
dispose(): void {
108
this._disposable.dispose();
109
}
110
111
async acquireTokenSilent(request: SilentFlowRequest): Promise<AuthenticationResult> {
112
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] starting...`);
113
let result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(request));
114
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] got result`);
115
// Check expiration of id token and if it's 5min before expiration, force a refresh.
116
// this is what MSAL does for access tokens already so we're just adding it for id tokens since we care about those.
117
// NOTE: Once we stop depending on id tokens for some things we can remove all of this.
118
const idTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp;
119
if (idTokenExpirationInSecs) {
120
const fiveMinutesBefore = new Date(
121
(idTokenExpirationInSecs - 5 * 60) // subtract 5 minutes
122
* 1000 // convert to milliseconds
123
);
124
if (fiveMinutesBefore < new Date()) {
125
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is expired or about to expire. Forcing refresh...`);
126
const newRequest = this.isBrokerAvailable
127
// HACK: Broker doesn't support forceRefresh so we need to pass in claims which will force a refresh
128
? { ...request, claims: request.claims ?? '{ "id_token": {}}' }
129
: { ...request, forceRefresh: true };
130
result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(newRequest));
131
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result`);
132
}
133
const newIdTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp;
134
if (newIdTokenExpirationInSecs) {
135
const fiveMinutesBefore = new Date(
136
(newIdTokenExpirationInSecs - 5 * 60) // subtract 5 minutes
137
* 1000 // convert to milliseconds
138
);
139
if (fiveMinutesBefore < new Date()) {
140
this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is still expired.`);
141
142
// HACK: Only for the Broker we try one more time with different claims to force a refresh. Why? We've seen the Broker caching tokens by the claims requested, thus
143
// there has been a situation where both tokens are expired.
144
if (this.isBrokerAvailable) {
145
this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] forcing refresh with different claims...`);
146
const newRequest = { ...request, claims: request.claims ?? '{ "access_token": {}}' };
147
result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(newRequest));
148
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result with different claims`);
149
const newIdTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp;
150
if (newIdTokenExpirationInSecs) {
151
const fiveMinutesBefore = new Date(
152
(newIdTokenExpirationInSecs - 5 * 60) // subtract 5 minutes
153
* 1000 // convert to milliseconds
154
);
155
if (fiveMinutesBefore < new Date()) {
156
this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is still expired.`);
157
}
158
}
159
}
160
}
161
}
162
}
163
164
if (!result.account) {
165
this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] no account found in result`);
166
} else if (!result.fromCache && this._verifyIfUsingBroker(result)) {
167
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] firing event due to change`);
168
this._onDidAccountsChangeEmitter.fire({ added: [], changed: [result.account], deleted: [] });
169
}
170
return result;
171
}
172
173
async acquireTokenInteractive(request: InteractiveRequest): Promise<AuthenticationResult> {
174
this._logger.debug(`[acquireTokenInteractive] [${this._clientId}] [${request.authority}] [${request.scopes?.join(' ')}] loopbackClientOverride: ${request.loopbackClient ? 'true' : 'false'}`);
175
return await window.withProgress(
176
{
177
location: ProgressLocation.Notification,
178
cancellable: true,
179
title: l10n.t('Signing in to Microsoft...')
180
},
181
(_process, token) => this._sequencer.queue(async () => {
182
try {
183
const result = await raceCancellationAndTimeoutError(
184
this._pca.acquireTokenInteractive(request),
185
token,
186
1000 * 60 * 5
187
);
188
if (this.isBrokerAvailable) {
189
await this._accountAccess.setAllowedAccess(result.account!, true);
190
}
191
// Force an update so that the account cache is updated.
192
// TODO:@TylerLeonhardt The problem is, we use the sequencer for
193
// change events but we _don't_ use it for the accounts cache.
194
// We should probably use it for the accounts cache as well.
195
await this._update();
196
return result;
197
} catch (error) {
198
this._logger.error(`[acquireTokenInteractive] [${this._clientId}] [${request.authority}] [${request.scopes?.join(' ')}] error: ${error}`);
199
throw error;
200
}
201
})
202
);
203
}
204
205
/**
206
* Allows for passing in a refresh token to get a new access token. This is the migration scenario.
207
* TODO: MSAL Migration. Remove this when we remove the old flow.
208
* @param request a {@link RefreshTokenRequest} object that contains the refresh token and other parameters.
209
* @returns an {@link AuthenticationResult} object that contains the result of the token acquisition operation.
210
*/
211
async acquireTokenByRefreshToken(request: RefreshTokenRequest): Promise<AuthenticationResult | null> {
212
this._logger.debug(`[acquireTokenByRefreshToken] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}]`);
213
const result = await this._sequencer.queue(async () => {
214
const result = await this._pca.acquireTokenByRefreshToken(request);
215
// Force an update so that the account cache is updated.
216
// TODO:@TylerLeonhardt The problem is, we use the sequencer for
217
// change events but we _don't_ use it for the accounts cache.
218
// We should probably use it for the accounts cache as well.
219
await this._update();
220
return result;
221
});
222
if (result) {
223
// this._setupRefresh(result);
224
if (this.isBrokerAvailable && result.account) {
225
await this._accountAccess.setAllowedAccess(result.account, true);
226
}
227
}
228
return result;
229
}
230
231
async acquireTokenByDeviceCode(request: Omit<DeviceCodeRequest, 'deviceCodeCallback'>): Promise<AuthenticationResult | null> {
232
this._logger.debug(`[acquireTokenByDeviceCode] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}]`);
233
const result = await this._sequencer.queue(async () => {
234
const deferredPromise = new DeferredPromise<AuthenticationResult | null>();
235
const result = await Promise.race([
236
this._pca.acquireTokenByDeviceCode({
237
...request,
238
deviceCodeCallback: (response) => void this._deviceCodeCallback(response, deferredPromise)
239
}),
240
deferredPromise.p
241
]);
242
await deferredPromise.complete(result);
243
// Force an update so that the account cache is updated.
244
// TODO:@TylerLeonhardt The problem is, we use the sequencer for
245
// change events but we _don't_ use it for the accounts cache.
246
// We should probably use it for the accounts cache as well.
247
await this._update();
248
return result;
249
});
250
if (result) {
251
if (this.isBrokerAvailable && result.account) {
252
await this._accountAccess.setAllowedAccess(result.account, true);
253
}
254
}
255
return result;
256
}
257
258
private async _deviceCodeCallback(
259
// MSAL doesn't expose this type...
260
response: Parameters<DeviceCodeRequest['deviceCodeCallback']>[0],
261
deferredPromise: DeferredPromise<AuthenticationResult | null>
262
): Promise<void> {
263
const button = l10n.t('Copy & Continue to Microsoft');
264
const modalResult = await window.showInformationMessage(
265
l10n.t({ message: 'Your Code: {0}', args: [response.userCode], comment: ['The {0} will be a code, e.g. 123-456'] }),
266
{
267
modal: true,
268
detail: l10n.t('To finish authenticating, navigate to Microsoft and paste in the above one-time code.')
269
}, button);
270
271
if (modalResult !== button) {
272
this._logger.debug(`[deviceCodeCallback] [${this._clientId}] User cancelled the device code flow.`);
273
deferredPromise.cancel();
274
return;
275
}
276
277
await env.clipboard.writeText(response.userCode);
278
await env.openExternal(Uri.parse(response.verificationUri));
279
await window.withProgress<void>({
280
location: ProgressLocation.Notification,
281
cancellable: true,
282
title: l10n.t({
283
message: 'Open [{0}]({0}) in a new tab and paste your one-time code: {1}',
284
args: [response.verificationUri, response.userCode],
285
comment: [
286
'The [{0}]({0}) will be a url and the {1} will be a code, e.g. 123456',
287
'{Locked="[{0}]({0})"}'
288
]
289
})
290
}, async (_, token) => {
291
const disposable = token.onCancellationRequested(() => {
292
this._logger.debug(`[deviceCodeCallback] [${this._clientId}] Device code flow cancelled by user.`);
293
deferredPromise.cancel();
294
});
295
try {
296
await deferredPromise.p;
297
this._logger.debug(`[deviceCodeCallback] [${this._clientId}] Device code flow completed successfully.`);
298
} catch (error) {
299
// Ignore errors here, they are handled at a higher scope
300
} finally {
301
disposable.dispose();
302
}
303
});
304
}
305
306
removeAccount(account: AccountInfo): Promise<void> {
307
if (this.isBrokerAvailable) {
308
return this._accountAccess.setAllowedAccess(account, false);
309
}
310
return this._sequencer.queue(() => this._pca.getTokenCache().removeAccount(account));
311
}
312
313
private _registerOnSecretStorageChanged() {
314
if (this.isBrokerAvailable) {
315
return this._accountAccess.onDidAccountAccessChange(() => this._sequencer.queue(() => this._update()));
316
}
317
return this._secretStorageCachePlugin.onDidChange(() => this._sequencer.queue(() => this._update()));
318
}
319
320
private _lastSeen = new Map<string, number>();
321
private _verifyIfUsingBroker(result: AuthenticationResult): boolean {
322
// If we're not brokering, we don't need to verify the date
323
// the cache check will be sufficient
324
if (!result.fromNativeBroker) {
325
return true;
326
}
327
// The nativeAccountId is what the broker uses to differenciate all
328
// types of accounts. Even if the "account" is a duplicate of another because
329
// it's actaully a guest account in another tenant.
330
let key = result.account!.nativeAccountId;
331
if (!key) {
332
this._logger.error(`[verifyIfUsingBroker] [${this._clientId}] [${result.account!.username}] no nativeAccountId found. Using homeAccountId instead.`);
333
key = result.account!.homeAccountId;
334
}
335
const lastSeen = this._lastSeen.get(key);
336
const lastTimeAuthed = result.account!.idTokenClaims!.iat!;
337
if (!lastSeen) {
338
this._lastSeen.set(key, lastTimeAuthed);
339
return true;
340
}
341
if (lastSeen === lastTimeAuthed) {
342
return false;
343
}
344
this._lastSeen.set(key, lastTimeAuthed);
345
return true;
346
}
347
348
private async _update() {
349
const before = this._accounts;
350
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update before: ${before.length}`);
351
// Clear in-memory cache so we know we're getting account data from the SecretStorage
352
this._pca.clearCache();
353
let after = await this._pca.getAllAccounts();
354
if (this.isBrokerAvailable) {
355
after = after.filter(a => this._accountAccess.isAllowedAccess(a));
356
}
357
this._accounts = after;
358
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update after: ${after.length}`);
359
360
const beforeSet = new Set(before.map(b => b.homeAccountId));
361
const afterSet = new Set(after.map(a => a.homeAccountId));
362
363
const added = after.filter(a => !beforeSet.has(a.homeAccountId));
364
const deleted = before.filter(b => !afterSet.has(b.homeAccountId));
365
if (added.length > 0 || deleted.length > 0) {
366
this._onDidAccountsChangeEmitter.fire({ added, changed: [], deleted });
367
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication accounts changed. added: ${added.length}, deleted: ${deleted.length}`);
368
if (!after.length) {
369
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication final account deleted. Firing event.`);
370
this._onDidRemoveLastAccountEmitter.fire();
371
}
372
}
373
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update complete`);
374
}
375
}
376
377
export class Sequencer {
378
379
private current: Promise<unknown> = Promise.resolve(null);
380
381
queue<T>(promiseTask: () => Promise<T>): Promise<T> {
382
return this.current = this.current.then(() => promiseTask(), () => promiseTask());
383
}
384
}
385
386