Rework eventing for PCAs and fix a few bugs along the way (#227854)

A big change, but a good one... This addresses some core issues around how we manage multiple PublicClientApplications (which are an object that should be created for each set of clientId,authority). Previously, we were doing some pretty nasty things to detect when a new PCA was created/deleted and as a result it would cause infinite loops and the likes...

Now we've focused on managing that in SecretStorage by looking for a `publicClientApplications` key. This is all encapsulated in the new `PublicClientApplicationsSecretStorage`.

Since we no longer relied on that hack, we still needed some way to have a PCA inform that:
* accounts have changed
* the last account was removed (signaling that this PCA could be disposed of in `PublicClientApplicationsSecretStorage`)

Both of these events have been added to `CachedPublicClientApplication` (now in its own file) and are being used. (replacing the old `_accountChangeHandler` which was hacky... true events are cleaner).

Last thing in the eventing space is that I try to minimize calls to `_storePublicClientApplications` so to not spam events across SecretStorage. You can see this in my usage of `_doCreatePublicClientApplication` over `getOrCreate`.

Couple random other things:
* `changed` accounts are properly bubbled up in `_onDidChangeSessionsEmitter` which is needed when a token is refreshed
* `getSessions` when no scopes are passed in no longer causes new tokens to be minted
* we use to only remove the first account we found but in some cases there may be the same account across different PCAs, so there's a `return` that's removed in `authProvider.ts` that fixes this bug
* Logging is clearer and more verbose (in a good way)
This commit is contained in:
Tyler James Leonhardt 2024-09-07 06:03:25 +02:00 committed by GitHub
parent bc0764dcb1
commit 533d8ec6a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 355 additions and 216 deletions

View file

@ -7,6 +7,8 @@ import type { Disposable, Event } from 'vscode';
export interface ICachedPublicClientApplication extends Disposable {
initialize(): Promise<void>;
onDidAccountsChange: Event<{ added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }>;
onDidRemoveLastAccount: Event<void>;
acquireTokenSilent(request: SilentFlowRequest): Promise<AuthenticationResult>;
acquireTokenInteractive(request: InteractiveRequest): Promise<AuthenticationResult>;
removeAccount(account: AccountInfo): Promise<void>;
@ -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<ICachedPublicClientApplication>;
getAll(): ICachedPublicClientApplication[];
}

View file

@ -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<void> {
@ -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<AuthenticationSession[]> {
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<string, AuthenticationSession>();
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<void> {
this._logger.info('[removeSession]', sessionId, 'starting');
const promises = new Array<Promise<void>>();
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,
};
}
}

View file

@ -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<void>();
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<void> {
return this._update();
}
dispose(): void {
this._disposable.dispose();
}
async acquireTokenSilent(request: SilentFlowRequest): Promise<AuthenticationResult> {
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<AuthenticationResult> {
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<void> {
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<string>(`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`);
}
}

View file

@ -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<string, CachedPublicClientApplication>();
private readonly _pcaDisposables = new Map<string, Disposable>();
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<Promise<ICachedPublicClientApplication>>();
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<ICachedPublicClientApplication> {
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<void>;
readonly onDidChange: Event<void> = 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<string[] | undefined> {
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<void> {
return this._update();
store(value: string[]): Thenable<void> {
return this._secretStorage.store(PublicClientApplicationsSecretStorage.key, JSON.stringify(value));
}
dispose(): void {
delete(): Thenable<void> {
return this._secretStorage.delete(PublicClientApplicationsSecretStorage.key);
}
dispose() {
this._disposable.dispose();
}
async acquireTokenSilent(request: SilentFlowRequest): Promise<AuthenticationResult> {
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<AuthenticationResult> {
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<void> {
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<string>(`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`);
}
}