Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.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
6
import { PublicClientApplication, AccountInfo, SilentFlowRequest, AuthenticationResult, InteractiveRequest, LogLevel, RefreshTokenRequest, BrokerOptions } from '@azure/msal-node';
7
import { NativeBrokerPlugin } from '@azure/msal-node-extensions';
8
import { Disposable, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter, workspace } from 'vscode';
9
import { 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 (process.platform !== 'win32') {
55
this._logger.info(`[${this._clientId}] Native Broker is only available on Windows`);
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
removeAccount(account: AccountInfo): Promise<void> {
232
if (this.isBrokerAvailable) {
233
return this._accountAccess.setAllowedAccess(account, false);
234
}
235
return this._sequencer.queue(() => this._pca.getTokenCache().removeAccount(account));
236
}
237
238
private _registerOnSecretStorageChanged() {
239
if (this.isBrokerAvailable) {
240
return this._accountAccess.onDidAccountAccessChange(() => this._sequencer.queue(() => this._update()));
241
}
242
return this._secretStorageCachePlugin.onDidChange(() => this._sequencer.queue(() => this._update()));
243
}
244
245
private _lastSeen = new Map<string, number>();
246
private _verifyIfUsingBroker(result: AuthenticationResult): boolean {
247
// If we're not brokering, we don't need to verify the date
248
// the cache check will be sufficient
249
if (!result.fromNativeBroker) {
250
return true;
251
}
252
// The nativeAccountId is what the broker uses to differenciate all
253
// types of accounts. Even if the "account" is a duplicate of another because
254
// it's actaully a guest account in another tenant.
255
let key = result.account!.nativeAccountId;
256
if (!key) {
257
this._logger.error(`[verifyIfUsingBroker] [${this._clientId}] [${result.account!.username}] no nativeAccountId found. Using homeAccountId instead.`);
258
key = result.account!.homeAccountId;
259
}
260
const lastSeen = this._lastSeen.get(key);
261
const lastTimeAuthed = result.account!.idTokenClaims!.iat!;
262
if (!lastSeen) {
263
this._lastSeen.set(key, lastTimeAuthed);
264
return true;
265
}
266
if (lastSeen === lastTimeAuthed) {
267
return false;
268
}
269
this._lastSeen.set(key, lastTimeAuthed);
270
return true;
271
}
272
273
private async _update() {
274
const before = this._accounts;
275
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update before: ${before.length}`);
276
// Clear in-memory cache so we know we're getting account data from the SecretStorage
277
this._pca.clearCache();
278
let after = await this._pca.getAllAccounts();
279
if (this.isBrokerAvailable) {
280
after = after.filter(a => this._accountAccess.isAllowedAccess(a));
281
}
282
this._accounts = after;
283
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update after: ${after.length}`);
284
285
const beforeSet = new Set(before.map(b => b.homeAccountId));
286
const afterSet = new Set(after.map(a => a.homeAccountId));
287
288
const added = after.filter(a => !beforeSet.has(a.homeAccountId));
289
const deleted = before.filter(b => !afterSet.has(b.homeAccountId));
290
if (added.length > 0 || deleted.length > 0) {
291
this._onDidAccountsChangeEmitter.fire({ added, changed: [], deleted });
292
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication accounts changed. added: ${added.length}, deleted: ${deleted.length}`);
293
if (!after.length) {
294
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication final account deleted. Firing event.`);
295
this._onDidRemoveLastAccountEmitter.fire();
296
}
297
}
298
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update complete`);
299
}
300
}
301
302
export class Sequencer {
303
304
private current: Promise<unknown> = Promise.resolve(null);
305
306
queue<T>(promiseTask: () => Promise<T>): Promise<T> {
307
return this.current = this.current.then(() => promiseTask(), () => promiseTask());
308
}
309
}
310
311