diff --git a/extensions/microsoft-authentication/src/common/publicClientCache.ts b/extensions/microsoft-authentication/src/common/publicClientCache.ts index cb9339f926d..925a4d1a88c 100644 --- a/extensions/microsoft-authentication/src/common/publicClientCache.ts +++ b/extensions/microsoft-authentication/src/common/publicClientCache.ts @@ -7,6 +7,8 @@ import type { Disposable, Event } from 'vscode'; export interface ICachedPublicClientApplication extends Disposable { initialize(): Promise; + onDidAccountsChange: Event<{ added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }>; + onDidRemoveLastAccount: Event; acquireTokenSilent(request: SilentFlowRequest): Promise; acquireTokenInteractive(request: InteractiveRequest): Promise; removeAccount(account: AccountInfo): Promise; @@ -16,6 +18,7 @@ export interface ICachedPublicClientApplication extends Disposable { } export interface ICachedPublicClientApplicationManager { + onDidAccountsChange: Event<{ added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }>; getOrCreate(clientId: string, authority: string): Promise; getAll(): ICachedPublicClientApplication[]; } diff --git a/extensions/microsoft-authentication/src/node/authProvider.ts b/extensions/microsoft-authentication/src/node/authProvider.ts index 3925f6f58cf..02bb66863be 100644 --- a/extensions/microsoft-authentication/src/node/authProvider.ts +++ b/extensions/microsoft-authentication/src/node/authProvider.ts @@ -48,15 +48,12 @@ export class MsalAuthProvider implements AuthenticationProvider { private readonly _env: Environment = Environment.AzureCloud ) { this._disposables = context.subscriptions; - this._publicClientManager = new CachedPublicClientApplicationManager( - context.globalState, - context.secrets, - this._logger, - (e) => this._handleAccountChange(e) + this._publicClientManager = new CachedPublicClientApplicationManager(context.globalState, context.secrets, this._logger); + this._disposables.push( + this._onDidChangeSessionsEmitter, + this._publicClientManager, + this._publicClientManager.onDidAccountsChange(e => this._handleAccountChange(e)) ); - this._disposables.push(this._publicClientManager); - this._disposables.push(this._onDidChangeSessionsEmitter); - } async initialize(): Promise { @@ -79,40 +76,43 @@ export class MsalAuthProvider implements AuthenticationProvider { * See {@link onDidChangeSessions} for more information on how this is used. * @param param0 Event that contains the added and removed accounts */ - private _handleAccountChange({ added, deleted }: { added: AccountInfo[]; deleted: AccountInfo[] }) { - const process = (a: AccountInfo) => ({ - // This shouldn't be needed - accessToken: '1234', - id: a.homeAccountId, - scopes: [], - account: { - id: a.homeAccountId, - label: a.username - }, - idToken: a.idToken, + private _handleAccountChange({ added, changed, deleted }: { added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }) { + this._onDidChangeSessionsEmitter.fire({ + added: added.map(this.sessionFromAccountInfo), + changed: changed.map(this.sessionFromAccountInfo), + removed: deleted.map(this.sessionFromAccountInfo) }); - this._onDidChangeSessionsEmitter.fire({ added: added.map(process), changed: [], removed: deleted.map(process) }); } //#region AuthenticationProvider methods async getSessions(scopes: string[] | undefined, options?: AuthenticationGetSessionOptions): Promise { + const askingForAll = scopes === undefined; const scopeData = new ScopeData(scopes); - this._logger.info('[getSessions]', scopes ? scopeData.scopeStr : 'all', 'starting'); - if (!scopes) { - // Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead. + // Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead. + this._logger.info('[getSessions]', askingForAll ? '[all]' : `[${scopeData.scopeStr}]`, 'starting'); - const allSessions: AuthenticationSession[] = []; + // This branch only gets called by Core for sign out purposes and initial population of the account menu. Since we are + // living in a world where a "session" from Core's perspective is an account, we return 1 session per account. + // See the large comment on `onDidChangeSessions` for more information. + if (askingForAll) { + const allSessionsForAccounts = new Map(); for (const cachedPca of this._publicClientManager.getAll()) { - const sessions = await this.getAllSessionsForPca(cachedPca, scopeData.originalScopes, scopeData.scopesToSend, options?.account); - allSessions.push(...sessions); + for (const account of cachedPca.accounts) { + if (allSessionsForAccounts.has(account.homeAccountId)) { + continue; + } + allSessionsForAccounts.set(account.homeAccountId, this.sessionFromAccountInfo(account)); + } } + const allSessions = Array.from(allSessionsForAccounts.values()); + this._logger.info('[getSessions] [all]', `returned ${allSessions.length} session(s)`); return allSessions; } const cachedPca = await this.getOrCreatePublicClientApplication(scopeData.clientId, scopeData.tenant); const sessions = await this.getAllSessionsForPca(cachedPca, scopeData.originalScopes, scopeData.scopesToSend, options?.account); - this._logger.info(`[getSessions] returned ${sessions.length} sessions`); + this._logger.info(`[getSessions] [${scopeData.scopeStr}] returned ${sessions.length} session(s)`); return sessions; } @@ -121,7 +121,7 @@ export class MsalAuthProvider implements AuthenticationProvider { const scopeData = new ScopeData(scopes); // Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead. - this._logger.info('[createSession]', scopeData.scopeStr, 'starting'); + this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting'); const cachedPca = await this.getOrCreatePublicClientApplication(scopeData.clientId, scopeData.tenant); let result: AuthenticationResult; try { @@ -169,32 +169,43 @@ export class MsalAuthProvider implements AuthenticationProvider { } } - const session = this.toAuthenticationSession(result, scopeData.originalScopes); + const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes); this._telemetryReporter.sendLoginEvent(session.scopes); - this._logger.info('[createSession]', scopeData.scopeStr, 'returned session'); + this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'returned session'); + // This is the only scenario in which we need to fire the _onDidChangeSessionsEmitter out of band... + // the badge flow (when the client passes no options in to getSession) will only remove a badge if a session + // was created that _matches the scopes_ that that badge requests. See `onDidChangeSessions` for more info. + // TODO: This should really be fixed in Core. this._onDidChangeSessionsEmitter.fire({ added: [session], changed: [], removed: [] }); return session; } async removeSession(sessionId: string): Promise { this._logger.info('[removeSession]', sessionId, 'starting'); + const promises = new Array>(); for (const cachedPca of this._publicClientManager.getAll()) { const accounts = cachedPca.accounts; for (const account of accounts) { if (account.homeAccountId === sessionId) { this._telemetryReporter.sendLogoutEvent(); - try { - await cachedPca.removeAccount(account); - } catch (e) { - this._telemetryReporter.sendLogoutFailedEvent(); - throw e; - } - this._logger.info('[removeSession]', sessionId, 'removed session'); - return; + promises.push(cachedPca.removeAccount(account)); + this._logger.info(`[removeSession] [${sessionId}] [${cachedPca.clientId}] [${cachedPca.authority}] removing session...`); } } } - this._logger.info('[removeSession]', sessionId, 'session not found'); + if (!promises.length) { + this._logger.info('[removeSession]', sessionId, 'session not found'); + return; + } + const results = await Promise.allSettled(promises); + for (const result of results) { + if (result.status === 'rejected') { + this._telemetryReporter.sendLogoutFailedEvent(); + this._logger.error('[removeSession]', sessionId, 'error removing session', result.reason); + } + } + + this._logger.info('[removeSession]', sessionId, `attempted to remove ${promises.length} sessions`); } //#endregion @@ -217,7 +228,7 @@ export class MsalAuthProvider implements AuthenticationProvider { for (const account of accounts) { try { const result = await cachedPca.acquireTokenSilent({ account, scopes: scopesToSend, redirectUri }); - sessions.push(this.toAuthenticationSession(result, originalScopes)); + sessions.push(this.sessionFromAuthenticationResult(result, originalScopes)); } catch (e) { // If we can't get a token silently, the account is probably in a bad state so we should skip it // MSAL will log this already, so we don't need to log it again @@ -227,7 +238,7 @@ export class MsalAuthProvider implements AuthenticationProvider { return sessions; } - private toAuthenticationSession(result: AuthenticationResult, scopes: readonly string[]): AuthenticationSession & { idToken: string } { + private sessionFromAuthenticationResult(result: AuthenticationResult, scopes: readonly string[]): AuthenticationSession & { idToken: string } { return { accessToken: result.accessToken, idToken: result.idToken, @@ -239,4 +250,17 @@ export class MsalAuthProvider implements AuthenticationProvider { scopes }; } + + private sessionFromAccountInfo(account: AccountInfo): AuthenticationSession { + return { + accessToken: '1234', + id: account.homeAccountId, + scopes: [], + account: { + id: account.homeAccountId, + label: account.username + }, + idToken: account.idToken, + }; + } } diff --git a/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts new file mode 100644 index 00000000000..62882d68ca0 --- /dev/null +++ b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PublicClientApplication, AccountInfo, Configuration, SilentFlowRequest, AuthenticationResult, InteractiveRequest } from '@azure/msal-node'; +import { Disposable, Memento, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter } from 'vscode'; +import { raceCancellationAndTimeoutError } from '../common/async'; +import { SecretStorageCachePlugin } from '../common/cachePlugin'; +import { MsalLoggerOptions } from '../common/loggerOptions'; +import { ICachedPublicClientApplication } from '../common/publicClientCache'; + +export class CachedPublicClientApplication implements ICachedPublicClientApplication { + private _pca: PublicClientApplication; + + private _accounts: AccountInfo[] = []; + private readonly _disposable: Disposable; + + private readonly _loggerOptions = new MsalLoggerOptions(this._logger); + private readonly _secretStorageCachePlugin = new SecretStorageCachePlugin( + this._secretStorage, + // Include the prefix as a differentiator to other secrets + `pca:${JSON.stringify({ clientId: this._clientId, authority: this._authority })}` + ); + private readonly _config: Configuration = { + auth: { clientId: this._clientId, authority: this._authority }, + system: { + loggerOptions: { + correlationId: `${this._clientId}] [${this._authority}`, + loggerCallback: (level, message, containsPii) => this._loggerOptions.loggerCallback(level, message, containsPii), + } + }, + cache: { + cachePlugin: this._secretStorageCachePlugin + } + }; + + /** + * We keep track of the last time an account was removed so we can recreate the PCA if we detect that an account was removed. + * This is due to MSAL-node not providing a way to detect when an account is removed from the cache. An internal issue has been + * filed to track this. If MSAL-node ever provides a way to detect this or handle this better in the Persistant Cache Plugin, + * we can remove this logic. + */ + private _lastCreated: Date; + + //#region Events + + private readonly _onDidAccountsChangeEmitter = new EventEmitter<{ added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }>; + readonly onDidAccountsChange = this._onDidAccountsChangeEmitter.event; + + private readonly _onDidRemoveLastAccountEmitter = new EventEmitter(); + readonly onDidRemoveLastAccount = this._onDidRemoveLastAccountEmitter.event; + + //#endregion + + constructor( + private readonly _clientId: string, + private readonly _authority: string, + private readonly _globalMemento: Memento, + private readonly _secretStorage: SecretStorage, + private readonly _logger: LogOutputChannel + ) { + this._pca = new PublicClientApplication(this._config); + this._lastCreated = new Date(); + this._disposable = Disposable.from( + this._registerOnSecretStorageChanged(), + this._onDidAccountsChangeEmitter, + this._onDidRemoveLastAccountEmitter + ); + } + + get accounts(): AccountInfo[] { return this._accounts; } + get clientId(): string { return this._clientId; } + get authority(): string { return this._authority; } + + initialize(): Promise { + return this._update(); + } + + dispose(): void { + this._disposable.dispose(); + } + + async acquireTokenSilent(request: SilentFlowRequest): Promise { + this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}]`); + const result = await this._pca.acquireTokenSilent(request); + if (result.account && !result.fromCache) { + this._onDidAccountsChangeEmitter.fire({ added: [], changed: [result.account], deleted: [] }); + } + return result; + } + + async acquireTokenInteractive(request: InteractiveRequest): Promise { + this._logger.debug(`[acquireTokenInteractive] [${this._clientId}] [${this._authority}] [${request.scopes?.join(' ')}] loopbackClientOverride: ${request.loopbackClient ? 'true' : 'false'}`); + return await window.withProgress( + { + location: ProgressLocation.Notification, + cancellable: true, + title: l10n.t('Signing in to Microsoft...') + }, + (_process, token) => raceCancellationAndTimeoutError( + this._pca.acquireTokenInteractive(request), + token, + 1000 * 60 * 5 + ) + ); + } + + removeAccount(account: AccountInfo): Promise { + this._globalMemento.update(`lastRemoval:${this._clientId}:${this._authority}`, new Date()); + return this._pca.getTokenCache().removeAccount(account); + } + + private _registerOnSecretStorageChanged() { + return this._secretStorageCachePlugin.onDidChange(() => this._update()); + } + + private async _update() { + const before = this._accounts; + this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update before: ${before.length}`); + // Dates are stored as strings in the memento + const lastRemovalDate = this._globalMemento.get(`lastRemoval:${this._clientId}:${this._authority}`); + if (lastRemovalDate && this._lastCreated && Date.parse(lastRemovalDate) > this._lastCreated.getTime()) { + this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication removal detected... recreating PCA...`); + this._pca = new PublicClientApplication(this._config); + this._lastCreated = new Date(); + } + + const after = await this._pca.getAllAccounts(); + this._accounts = after; + this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update after: ${after.length}`); + + const beforeSet = new Set(before.map(b => b.homeAccountId)); + const afterSet = new Set(after.map(a => a.homeAccountId)); + + const added = after.filter(a => !beforeSet.has(a.homeAccountId)); + const deleted = before.filter(b => !afterSet.has(b.homeAccountId)); + if (added.length > 0 || deleted.length > 0) { + this._onDidAccountsChangeEmitter.fire({ added, changed: [], deleted }); + this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication accounts changed. added: ${added.length}, deleted: ${deleted.length}`); + if (!after.length) { + this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication final account deleted. Firing event.`); + this._onDidRemoveLastAccountEmitter.fire(); + } + } + this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update complete`); + } +} diff --git a/extensions/microsoft-authentication/src/node/publicClientCache.ts b/extensions/microsoft-authentication/src/node/publicClientCache.ts index 34bf2c3c73b..6ecc34501f7 100644 --- a/extensions/microsoft-authentication/src/node/publicClientCache.ts +++ b/extensions/microsoft-authentication/src/node/publicClientCache.ts @@ -3,77 +3,84 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AccountInfo, AuthenticationResult, Configuration, InteractiveRequest, PublicClientApplication, SilentFlowRequest } from '@azure/msal-node'; -import { SecretStorageCachePlugin } from '../common/cachePlugin'; -import { SecretStorage, LogOutputChannel, Disposable, SecretStorageChangeEvent, EventEmitter, Memento, window, ProgressLocation, l10n } from 'vscode'; -import { MsalLoggerOptions } from '../common/loggerOptions'; +import { AccountInfo } from '@azure/msal-node'; +import { SecretStorage, LogOutputChannel, Disposable, EventEmitter, Memento, Event } from 'vscode'; import { ICachedPublicClientApplication, ICachedPublicClientApplicationManager } from '../common/publicClientCache'; -import { raceCancellationAndTimeoutError } from '../common/async'; +import { CachedPublicClientApplication } from './cachedPublicClientApplication'; export interface IPublicClientApplicationInfo { clientId: string; authority: string; } -const _keyPrefix = 'pca:'; - export class CachedPublicClientApplicationManager implements ICachedPublicClientApplicationManager { - // The key is the clientId and authority stringified + // The key is the clientId and authority JSON stringified private readonly _pcas = new Map(); + private readonly _pcaDisposables = new Map(); - private _initialized = false; private _disposable: Disposable; + private _pcasSecretStorage: PublicClientApplicationsSecretStorage; + + private readonly _onDidAccountsChangeEmitter = new EventEmitter<{ added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }>(); + readonly onDidAccountsChange = this._onDidAccountsChangeEmitter.event; constructor( private readonly _globalMemento: Memento, private readonly _secretStorage: SecretStorage, - private readonly _logger: LogOutputChannel, - private readonly _accountChangeHandler: (e: { added: AccountInfo[]; deleted: AccountInfo[] }) => void + private readonly _logger: LogOutputChannel ) { - this._disposable = _secretStorage.onDidChange(e => this._handleSecretStorageChange(e)); + this._pcasSecretStorage = new PublicClientApplicationsSecretStorage(_secretStorage); + this._disposable = Disposable.from( + this._pcasSecretStorage, + this._registerSecretStorageHandler(), + this._onDidAccountsChangeEmitter + ); + } + + private _registerSecretStorageHandler() { + return this._pcasSecretStorage.onDidChange(() => this._handleSecretStorageChange()); } async initialize() { this._logger.debug('[initialize] Initializing PublicClientApplicationManager'); - const keys = await this._secretStorage.get('publicClientApplications'); + let keys: string[] | undefined; + try { + keys = await this._pcasSecretStorage.get(); + } catch (e) { + // data is corrupted + this._logger.error('[initialize] Error initializing PublicClientApplicationManager:', e); + await this._pcasSecretStorage.delete(); + } if (!keys) { - this._initialized = true; return; } const promises = new Array>(); - try { - for (const key of JSON.parse(keys) as string[]) { - try { - const { clientId, authority } = JSON.parse(key) as IPublicClientApplicationInfo; - // Load the PCA in memory - promises.push(this.getOrCreate(clientId, authority)); - } catch (e) { - // ignore - } + for (const key of keys) { + try { + const { clientId, authority } = JSON.parse(key) as IPublicClientApplicationInfo; + // Load the PCA in memory + promises.push(this._doCreatePublicClientApplication(clientId, authority, key)); + } catch (e) { + this._logger.error('[initialize] Error intitializing PCA:', key); } - } catch (e) { - // data is corrupted - this._logger.error('[initialize] Error initializing PublicClientApplicationManager:', e); - await this._secretStorage.delete('publicClientApplications'); } - // TODO: should we do anything for when this fails? - await Promise.allSettled(promises); + const results = await Promise.allSettled(promises); + for (const result of results) { + if (result.status === 'rejected') { + this._logger.error('[initialize] Error getting PCA:', result.reason); + } + } this._logger.debug('[initialize] PublicClientApplicationManager initialized'); - this._initialized = true; } dispose() { this._disposable.dispose(); - Disposable.from(...this._pcas.values()).dispose(); + Disposable.from(...this._pcaDisposables.values()).dispose(); } async getOrCreate(clientId: string, authority: string): Promise { - if (!this._initialized) { - throw new Error('PublicClientApplicationManager not initialized'); - } - // Use the clientId and authority as the key const pcasKey = JSON.stringify({ clientId, authority }); let pca = this._pcas.get(pcasKey); @@ -83,170 +90,127 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient } this._logger.debug(`[getOrCreate] [${clientId}] [${authority}] PublicClientApplicationManager cache miss, creating new PCA...`); - pca = new CachedPublicClientApplication(clientId, authority, this._globalMemento, this._secretStorage, this._accountChangeHandler, this._logger); - this._pcas.set(pcasKey, pca); - await pca.initialize(); + pca = await this._doCreatePublicClientApplication(clientId, authority, pcasKey); await this._storePublicClientApplications(); - this._logger.debug(`[getOrCreate] [${clientId}] [${authority}] PublicClientApplicationManager PCA created`); + this._logger.debug(`[getOrCreate] [${clientId}] [${authority}] PCA created.`); + return pca; + } + + private async _doCreatePublicClientApplication(clientId: string, authority: string, pcasKey: string) { + const pca = new CachedPublicClientApplication(clientId, authority, this._globalMemento, this._secretStorage, this._logger); + this._pcas.set(pcasKey, pca); + const disposable = Disposable.from( + pca, + pca.onDidAccountsChange(e => this._onDidAccountsChangeEmitter.fire(e)), + pca.onDidRemoveLastAccount(() => { + // The PCA has no more accounts, so we can dispose it so we're not keeping it + // around forever. + disposable.dispose(); + this._pcas.delete(pcasKey); + this._logger.debug(`[_doCreatePublicClientApplication] [${clientId}] [${authority}] PCA disposed. Firing off storing of PCAs...`); + void this._storePublicClientApplications(); + }) + ); + this._pcaDisposables.set(pcasKey, disposable); + // Intialize the PCA after the `onDidAccountsChange` is set so we get initial state. + await pca.initialize(); return pca; } getAll(): ICachedPublicClientApplication[] { - if (!this._initialized) { - throw new Error('PublicClientApplicationManager not initialized'); - } return Array.from(this._pcas.values()); } - private async _handleSecretStorageChange(e: SecretStorageChangeEvent) { - if (!e.key.startsWith(_keyPrefix)) { - return; - } - - this._logger.debug(`[handleSecretStorageChange] PublicClientApplicationManager secret storage change: ${e.key}`); - const result = await this._secretStorage.get(e.key); - const pcasKey = e.key.split(_keyPrefix)[1]; - - // If the cache was deleted, or the PCA has zero accounts left, remove the PCA - if (!result || this._pcas.get(pcasKey)?.accounts.length === 0) { - this._logger.debug(`[handleSecretStorageChange] PublicClientApplicationManager removing PCA: ${pcasKey}`); - this._pcas.delete(pcasKey); + private async _handleSecretStorageChange() { + this._logger.debug(`[_handleSecretStorageChange] Handling PCAs secret storage change...`); + let result: string[] | undefined; + try { + result = await this._pcasSecretStorage.get(); + } catch (_e) { + // The data in secret storage has been corrupted somehow so + // we store what we have in this window await this._storePublicClientApplications(); - this._logger.debug(`[handleSecretStorageChange] PublicClientApplicationManager PCA removed: ${pcasKey}`); + return; + } + if (!result) { + this._logger.debug(`[_handleSecretStorageChange] PCAs deleted in secret storage. Disposing all...`); + Disposable.from(...this._pcaDisposables.values()).dispose(); + this._pcas.clear(); + this._pcaDisposables.clear(); + this._logger.debug(`[_handleSecretStorageChange] Finished PCAs secret storage change.`); return; } - // Load the PCA in memory if it's not already loaded - const { clientId, authority } = JSON.parse(pcasKey) as IPublicClientApplicationInfo; - this._logger.debug(`[handleSecretStorageChange] PublicClientApplicationManager loading PCA: ${pcasKey}`); - await this.getOrCreate(clientId, authority); - this._logger.debug(`[handleSecretStorageChange] PublicClientApplicationManager PCA loaded: ${pcasKey}`); + const pcaKeysFromStorage = new Set(result); + // Handle the deleted ones + for (const pcaKey of this._pcas.keys()) { + if (!pcaKeysFromStorage.delete(pcaKey)) { + // This PCA has been removed in another window + this._pcaDisposables.get(pcaKey)?.dispose(); + this._pcaDisposables.delete(pcaKey); + this._pcas.delete(pcaKey); + this._logger.debug(`[_handleSecretStorageChange] Disposed PCA that was deleted in another window: ${pcaKey}`); + } + } + + // Handle the new ones + for (const newPca of pcaKeysFromStorage) { + try { + const { clientId, authority } = JSON.parse(newPca); + this._logger.debug(`[_handleSecretStorageChange] [${clientId}] [${authority}] Creating new PCA that was created in another window...`); + await this._doCreatePublicClientApplication(clientId, authority, newPca); + this._logger.debug(`[_handleSecretStorageChange] [${clientId}] [${authority}] PCA created.`); + } catch (_e) { + // This really shouldn't happen, but should we do something about this? + this._logger.error(`Failed to parse new PublicClientApplication: ${newPca}`); + continue; + } + } + + this._logger.debug('[_handleSecretStorageChange] Finished handling PCAs secret storage change.'); } - private async _storePublicClientApplications() { - await this._secretStorage.store( - 'publicClientApplications', - JSON.stringify(Array.from(this._pcas.keys())) - ); + private _storePublicClientApplications() { + return this._pcasSecretStorage.store(Array.from(this._pcas.keys())); } } -class CachedPublicClientApplication implements ICachedPublicClientApplication { - private _pca: PublicClientApplication; +class PublicClientApplicationsSecretStorage { + private static key = 'publicClientApplications'; - private _accounts: AccountInfo[] = []; - private readonly _disposable: Disposable; + private _disposable: Disposable; - private readonly _loggerOptions = new MsalLoggerOptions(this._logger); - private readonly _secretStorageCachePlugin = new SecretStorageCachePlugin( - this._secretStorage, - // Include the prefix in the key so we can easily identify it later - `${_keyPrefix}${JSON.stringify({ clientId: this._clientId, authority: this._authority })}` - ); - private readonly _config: Configuration = { - auth: { clientId: this._clientId, authority: this._authority }, - system: { - loggerOptions: { - correlationId: `${this._clientId}] [${this._authority}`, - loggerCallback: (level, message, containsPii) => this._loggerOptions.loggerCallback(level, message, containsPii), - } - }, - cache: { - cachePlugin: this._secretStorageCachePlugin + private readonly _onDidChangeEmitter = new EventEmitter; + readonly onDidChange: Event = this._onDidChangeEmitter.event; + + constructor(private readonly _secretStorage: SecretStorage) { + this._disposable = Disposable.from( + this._onDidChangeEmitter, + this._secretStorage.onDidChange(e => { + if (e.key === PublicClientApplicationsSecretStorage.key) { + this._onDidChangeEmitter.fire(); + } + }) + ); + } + + async get(): Promise { + const value = await this._secretStorage.get(PublicClientApplicationsSecretStorage.key); + if (!value) { + return undefined; } - }; - - /** - * We keep track of the last time an account was removed so we can recreate the PCA if we detect that an account was removed. - * This is due to MSAL-node not providing a way to detect when an account is removed from the cache. An internal issue has been - * filed to track this. If MSAL-node ever provides a way to detect this or handle this better in the Persistant Cache Plugin, - * we can remove this logic. - */ - private _lastCreated: Date; - - constructor( - private readonly _clientId: string, - private readonly _authority: string, - private readonly _globalMemento: Memento, - private readonly _secretStorage: SecretStorage, - private readonly _accountChangeHandler: (e: { added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }) => void, - private readonly _logger: LogOutputChannel - ) { - this._pca = new PublicClientApplication(this._config); - this._lastCreated = new Date(); - this._disposable = this._registerOnSecretStorageChanged(); + return JSON.parse(value); } - get accounts(): AccountInfo[] { return this._accounts; } - get clientId(): string { return this._clientId; } - get authority(): string { return this._authority; } - - initialize(): Promise { - return this._update(); + store(value: string[]): Thenable { + return this._secretStorage.store(PublicClientApplicationsSecretStorage.key, JSON.stringify(value)); } - dispose(): void { + delete(): Thenable { + return this._secretStorage.delete(PublicClientApplicationsSecretStorage.key); + } + + dispose() { this._disposable.dispose(); } - - async acquireTokenSilent(request: SilentFlowRequest): Promise { - this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}]`); - const result = await this._pca.acquireTokenSilent(request); - if (result.account && !result.fromCache) { - this._accountChangeHandler({ added: [], changed: [result.account], deleted: [] }); - } - return result; - } - - async acquireTokenInteractive(request: InteractiveRequest): Promise { - this._logger.debug(`[acquireTokenInteractive] [${this._clientId}] [${this._authority}] [${request.scopes?.join(' ')}] loopbackClientOverride: ${request.loopbackClient ? 'true' : 'false'}`); - return await window.withProgress( - { - location: ProgressLocation.Notification, - cancellable: true, - title: l10n.t('Signing in to Microsoft...') - }, - (_process, token) => raceCancellationAndTimeoutError( - this._pca.acquireTokenInteractive(request), - token, - 1000 * 60 * 5 - ), // 5 minutes - ); - } - - removeAccount(account: AccountInfo): Promise { - this._globalMemento.update(`lastRemoval:${this._clientId}:${this._authority}`, new Date()); - return this._pca.getTokenCache().removeAccount(account); - } - - private _registerOnSecretStorageChanged() { - return this._secretStorageCachePlugin.onDidChange(() => this._update()); - } - - private async _update() { - const before = this._accounts; - this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update before: ${before.length}`); - // Dates are stored as strings in the memento - const lastRemovalDate = this._globalMemento.get(`lastRemoval:${this._clientId}:${this._authority}`); - if (lastRemovalDate && this._lastCreated && Date.parse(lastRemovalDate) > this._lastCreated.getTime()) { - this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication removal detected... recreating PCA...`); - this._pca = new PublicClientApplication(this._config); - this._lastCreated = new Date(); - } - - const after = await this._pca.getAllAccounts(); - this._accounts = after; - this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update after: ${after.length}`); - - const beforeSet = new Set(before.map(b => b.homeAccountId)); - const afterSet = new Set(after.map(a => a.homeAccountId)); - - const added = after.filter(a => !beforeSet.has(a.homeAccountId)); - const deleted = before.filter(b => !afterSet.has(b.homeAccountId)); - if (added.length > 0 || deleted.length > 0) { - this._accountChangeHandler({ added, changed: [], deleted }); - this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication accounts changed. added: ${added.length}, deleted: ${deleted.length}`); - } - this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update complete`); - } }