Detach authority/tenant from the PublicClientApp (#242719)

everything
This commit is contained in:
Tyler James Leonhardt
2025-03-05 10:55:10 -08:00
committed by GitHub
parent c790f5e777
commit 95ab795ff0
7 changed files with 251 additions and 111 deletions

View File

@@ -51,12 +51,7 @@ 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,
this._env.name
);
this._publicClientManager = new CachedPublicClientApplicationManager(_context.secrets, this._logger, this._env.name);
const accountChangeEvent = this._eventBufferer.wrapEvent(
this._publicClientManager.onDidAccountsChange,
(last, newEvent) => {
@@ -109,8 +104,8 @@ export class MsalAuthProvider implements AuthenticationProvider {
clientTenantMap.get(key)!.refreshTokens.push(session.refreshToken);
}
for (const { clientId, tenant, refreshTokens } of clientTenantMap.values()) {
await this.getOrCreatePublicClientApplication(clientId, tenant, refreshTokens);
for (const { clientId, refreshTokens } of clientTenantMap.values()) {
await this._publicClientManager.getOrCreate(clientId, refreshTokens);
}
}
@@ -173,8 +168,8 @@ export class MsalAuthProvider implements AuthenticationProvider {
return allSessions;
}
const cachedPca = await this.getOrCreatePublicClientApplication(scopeData.clientId, scopeData.tenant);
const sessions = await this.getAllSessionsForPca(cachedPca, scopeData.originalScopes, scopeData.scopesToSend, options?.account);
const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);
const sessions = await this.getAllSessionsForPca(cachedPca, scopeData, options?.account);
this._logger.info(`[getSessions] [${scopeData.scopeStr}] returned ${sessions.length} session(s)`);
return sessions;
@@ -185,7 +180,7 @@ export class MsalAuthProvider implements AuthenticationProvider {
// Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead.
this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting');
const cachedPca = await this.getOrCreatePublicClientApplication(scopeData.clientId, scopeData.tenant);
const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId);
// Used for showing a friendlier message to the user when the explicitly cancel a flow.
let userCancelled: boolean | undefined;
@@ -211,6 +206,7 @@ export class MsalAuthProvider implements AuthenticationProvider {
: ExtensionHost.WebWorker,
});
const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();
let lastError: Error | undefined;
for (const flow of flows) {
if (flow !== flows[0]) {
@@ -223,6 +219,7 @@ export class MsalAuthProvider implements AuthenticationProvider {
try {
const result = await flow.trigger({
cachedPca,
authority,
scopes: scopeData.scopesToSend,
loginHint: options.account?.label,
windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined,
@@ -260,7 +257,7 @@ export class MsalAuthProvider implements AuthenticationProvider {
if (account.homeAccountId === sessionId) {
this._telemetryReporter.sendLogoutEvent();
promises.push(cachedPca.removeAccount(account));
this._logger.info(`[removeSession] [${sessionId}] [${cachedPca.clientId}] [${cachedPca.authority}] removing session...`);
this._logger.info(`[removeSession] [${sessionId}] [${cachedPca.clientId}] removing session...`);
}
}
}
@@ -281,26 +278,69 @@ export class MsalAuthProvider implements AuthenticationProvider {
//#endregion
private async getOrCreatePublicClientApplication(clientId: string, tenant: string, refreshTokensToMigrate?: string[]): Promise<ICachedPublicClientApplication> {
const authority = new URL(tenant, this._env.activeDirectoryEndpointUrl).toString();
return await this._publicClientManager.getOrCreate(clientId, authority, refreshTokensToMigrate);
}
private async getAllSessionsForPca(
cachedPca: ICachedPublicClientApplication,
originalScopes: readonly string[],
scopesToSend: string[],
scopeData: ScopeData,
accountFilter?: AuthenticationSessionAccountInformation
): Promise<AuthenticationSession[]> {
const accounts = accountFilter
let filteredAccounts = accountFilter
? cachedPca.accounts.filter(a => a.homeAccountId === accountFilter.id)
: cachedPca.accounts;
// Group accounts by homeAccountId
const accountGroups = new Map<string, AccountInfo[]>();
for (const account of filteredAccounts) {
const existing = accountGroups.get(account.homeAccountId) || [];
existing.push(account);
accountGroups.set(account.homeAccountId, existing);
}
// Filter to one account per homeAccountId
filteredAccounts = Array.from(accountGroups.values()).map(accounts => {
if (accounts.length === 1) {
return accounts[0];
}
// If we have a specific tenant to target, prefer that one
if (scopeData.tenantId) {
const matchingTenant = accounts.find(a => a.tenantId === scopeData.tenantId);
if (matchingTenant) {
return matchingTenant;
}
}
// Otherwise prefer the home tenant
return accounts.find(a => a.tenantId === a.idTokenClaims?.tid) || accounts[0];
});
const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();
const sessions: AuthenticationSession[] = [];
return this._eventBufferer.bufferEventsAsync(async () => {
for (const account of accounts) {
for (const account of filteredAccounts) {
try {
const result = await cachedPca.acquireTokenSilent({ account, scopes: scopesToSend, redirectUri });
sessions.push(this.sessionFromAuthenticationResult(result, originalScopes));
let forceRefresh: true | undefined;
if (scopeData.tenantId) {
// If the tenants do not match, then we need to skip the cache
// to get a new token for the new tenant
if (account.tenantId !== scopeData.tenantId) {
forceRefresh = true;
}
} else {
// If we are requesting the home tenant and we don't yet have
// a token for the home tenant, we need to skip the cache
// to get a new token for the home tenant
if (account.tenantId !== account.idTokenClaims?.tid) {
forceRefresh = true;
}
}
const result = await cachedPca.acquireTokenSilent({
account,
authority,
scopes: scopeData.scopesToSend,
redirectUri,
forceRefresh
});
sessions.push(this.sessionFromAuthenticationResult(result, scopeData.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

View File

@@ -3,9 +3,9 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { PublicClientApplication, AccountInfo, Configuration, SilentFlowRequest, AuthenticationResult, InteractiveRequest, LogLevel, RefreshTokenRequest } from '@azure/msal-node';
import { PublicClientApplication, AccountInfo, SilentFlowRequest, AuthenticationResult, InteractiveRequest, LogLevel, RefreshTokenRequest } from '@azure/msal-node';
import { NativeBrokerPlugin } from '@azure/msal-node-extensions';
import { Disposable, Memento, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter } from 'vscode';
import { Disposable, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter } from 'vscode';
import { raceCancellationAndTimeoutError } from '../common/async';
import { SecretStorageCachePlugin } from '../common/cachePlugin';
import { MsalLoggerOptions } from '../common/loggerOptions';
@@ -23,11 +23,11 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
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 })}`
`pca:${this._clientId}`
);
// Broker properties
private readonly _accountAccess = new ScopedAccountAccess(this._secretStorage, this._cloudName, this._clientId, this._authority);
private readonly _accountAccess = new ScopedAccountAccess(this._secretStorage, this._cloudName, this.clientId, this._logger, this._authoritiesToMigrate);
private readonly _isBrokerAvailable: boolean;
//#region Events
@@ -42,22 +42,19 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
constructor(
private readonly _clientId: string,
private readonly _authority: string,
private readonly _cloudName: string,
private readonly _globalMemento: Memento,
private readonly _secretStorage: SecretStorage,
private readonly _logger: LogOutputChannel
private readonly _logger: LogOutputChannel,
private readonly _authoritiesToMigrate?: string[],
) {
// TODO:@TylerLeonhardt clean up old use of memento. Remove this in an iteration
this._globalMemento.update(`lastRemoval:${this._clientId}:${this._authority}`, undefined);
const loggerOptions = new MsalLoggerOptions(_logger);
const nativeBrokerPlugin = new NativeBrokerPlugin();
this._isBrokerAvailable = nativeBrokerPlugin.isBrokerAvailable ?? false;
this._pca = new PublicClientApplication({
auth: { clientId: _clientId, authority: _authority },
auth: { clientId: _clientId },
system: {
loggerOptions: {
correlationId: `${_clientId}] [${_authority}`,
correlationId: _clientId,
loggerCallback: (level, message, containsPii) => loggerOptions.loggerCallback(level, message, containsPii),
logLevel: LogLevel.Trace
}
@@ -74,7 +71,6 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
get accounts(): AccountInfo[] { return this._accounts; }
get clientId(): string { return this._clientId; }
get authority(): string { return this._authority; }
async initialize(): Promise<void> {
if (this._isBrokerAvailable) {
@@ -88,9 +84,9 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
}
async acquireTokenSilent(request: SilentFlowRequest): Promise<AuthenticationResult> {
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] starting...`);
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] starting...`);
let result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(request));
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] got result`);
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] got result`);
// Check expiration of id token and if it's 5min before expiration, force a refresh.
// this is what MSAL does for access tokens already so we're just adding it for id tokens since we care about those.
// NOTE: Once we stop depending on id tokens for some things we can remove all of this.
@@ -101,13 +97,13 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
* 1000 // convert to milliseconds
);
if (fiveMinutesBefore < new Date()) {
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is expired or about to expire. Forcing refresh...`);
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is expired or about to expire. Forcing refresh...`);
const newRequest = this._isBrokerAvailable
// HACK: Broker doesn't support forceRefresh so we need to pass in claims which will force a refresh
? { ...request, claims: '{ "id_token": {}}' }
: { ...request, forceRefresh: true };
result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(newRequest));
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result`);
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result`);
}
const newIdTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp;
if (newIdTokenExpirationInSecs) {
@@ -116,15 +112,15 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
* 1000 // convert to milliseconds
);
if (fiveMinutesBefore < new Date()) {
this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is still expired.`);
this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is still expired.`);
// 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
// there has been a situation where both tokens are expired.
if (this._isBrokerAvailable) {
this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] forcing refresh with different claims...`);
this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] forcing refresh with different claims...`);
const newRequest = { ...request, claims: '{ "access_token": {}}' };
result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(newRequest));
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result with different claims`);
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result with different claims`);
const newIdTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp;
if (newIdTokenExpirationInSecs) {
const fiveMinutesBefore = new Date(
@@ -132,7 +128,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
* 1000 // convert to milliseconds
);
if (fiveMinutesBefore < new Date()) {
this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is still expired.`);
this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is still expired.`);
}
}
}
@@ -140,15 +136,17 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
}
}
if (result.account && !result.fromCache && this._verifyIfUsingBroker(result)) {
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] firing event due to change`);
if (!result.account) {
this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] no account found in result`);
} else if (!result.fromCache && this._verifyIfUsingBroker(result)) {
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] firing event due to change`);
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'}`);
this._logger.debug(`[acquireTokenInteractive] [${this._clientId}] [${request.authority}] [${request.scopes?.join(' ')}] loopbackClientOverride: ${request.loopbackClient ? 'true' : 'false'}`);
return await window.withProgress(
{
location: ProgressLocation.Notification,
@@ -180,8 +178,8 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
* @param request a {@link RefreshTokenRequest} object that contains the refresh token and other parameters.
* @returns an {@link AuthenticationResult} object that contains the result of the token acquisition operation.
*/
async acquireTokenByRefreshToken(request: RefreshTokenRequest) {
this._logger.debug(`[acquireTokenByRefreshToken] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}]`);
async acquireTokenByRefreshToken(request: RefreshTokenRequest): Promise<AuthenticationResult | null> {
this._logger.debug(`[acquireTokenByRefreshToken] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}]`);
const result = await this._sequencer.queue(() => this._pca.acquireTokenByRefreshToken(request));
if (result) {
// this._setupRefresh(result);
@@ -213,7 +211,14 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
if (!result.fromNativeBroker) {
return true;
}
const key = result.account!.homeAccountId;
// The nativeAccountId is what the broker uses to differenciate all
// types of accounts. Even if the "account" is a duplicate of another because
// it's actaully a guest account in another tenant.
let key = result.account!.nativeAccountId;
if (!key) {
this._logger.error(`[verifyIfUsingBroker] [${this._clientId}] [${result.account!.username}] no nativeAccountId found. Using homeAccountId instead.`);
key = result.account!.homeAccountId;
}
const lastSeen = this._lastSeen.get(key);
const lastTimeAuthed = result.account!.idTokenClaims!.iat!;
if (!lastSeen) {
@@ -229,7 +234,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
private async _update() {
const before = this._accounts;
this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update before: ${before.length}`);
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update before: ${before.length}`);
// Clear in-memory cache so we know we're getting account data from the SecretStorage
this._pca.clearCache();
let after = await this._pca.getAllAccounts();
@@ -237,7 +242,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
after = after.filter(a => this._accountAccess.isAllowedAccess(a));
}
this._accounts = after;
this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update after: ${after.length}`);
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update after: ${after.length}`);
const beforeSet = new Set(before.map(b => b.homeAccountId));
const afterSet = new Set(after.map(a => a.homeAccountId));
@@ -246,13 +251,13 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
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}`);
this._logger.debug(`[update] [${this._clientId}] 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._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication final account deleted. Firing event.`);
this._onDidRemoveLastAccountEmitter.fire();
}
}
this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update complete`);
this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update complete`);
}
}

View File

@@ -25,6 +25,7 @@ interface IMsalFlowOptions {
interface IMsalFlowTriggerOptions {
cachedPca: ICachedPublicClientApplication;
authority: string;
scopes: string[];
loginHint?: string;
windowHandle?: Buffer;
@@ -45,11 +46,12 @@ class DefaultLoopbackFlow implements IMsalFlow {
supportsWebWorkerExtensionHost: false
};
async trigger({ cachedPca, scopes, loginHint, windowHandle, logger }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
async trigger({ cachedPca, authority, scopes, loginHint, windowHandle, logger }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
logger.info('Trying default msal flow...');
return await cachedPca.acquireTokenInteractive({
openBrowser: async (url: string) => { await env.openExternal(Uri.parse(url)); },
scopes,
authority,
successTemplate: loopbackTemplate,
errorTemplate: loopbackTemplate,
loginHint,
@@ -66,12 +68,13 @@ class UrlHandlerFlow implements IMsalFlow {
supportsWebWorkerExtensionHost: false
};
async trigger({ cachedPca, scopes, loginHint, windowHandle, logger, uriHandler }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
async trigger({ cachedPca, authority, scopes, loginHint, windowHandle, logger, uriHandler }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
logger.info('Trying protocol handler flow...');
const loopbackClient = new UriHandlerLoopbackClient(uriHandler, redirectUri, logger);
return await cachedPca.acquireTokenInteractive({
openBrowser: (url: string) => loopbackClient.openBrowser(url),
scopes,
authority,
loopbackClient,
loginHint,
prompt: loginHint ? undefined : 'select_account',

View File

@@ -14,8 +14,8 @@ export interface IPublicClientApplicationInfo {
}
export class CachedPublicClientApplicationManager implements ICachedPublicClientApplicationManager {
// The key is the clientId and authority JSON stringified
private readonly _pcas = new Map<string, CachedPublicClientApplication>();
// The key is the clientId
private readonly _pcas = new Map<string, ICachedPublicClientApplication>();
private readonly _pcaDisposables = new Map<string, Disposable>();
private _disposable: Disposable;
@@ -25,7 +25,6 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient
readonly onDidAccountsChange = this._onDidAccountsChangeEmitter.event;
constructor(
private readonly _globalMemento: Memento,
private readonly _secretStorage: SecretStorage,
private readonly _logger: LogOutputChannel,
private readonly _cloudName: string
@@ -44,26 +43,27 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient
async initialize() {
this._logger.debug('[initialize] Initializing PublicClientApplicationManager');
let keys: string[] | undefined;
let clientIds: string[] | undefined;
let migrations: Map<string, string[]> | undefined;
try {
keys = await this._pcasSecretStorage.get();
migrations = await this._getMigrationsPerClientId();
clientIds = await this._pcasSecretStorage.get();
} catch (e) {
// data is corrupted
this._logger.error('[initialize] Error initializing PublicClientApplicationManager:', e);
await this._pcasSecretStorage.delete();
}
if (!keys) {
if (!clientIds) {
return;
}
const promises = new Array<Promise<ICachedPublicClientApplication>>();
for (const key of keys) {
for (const clientId of clientIds) {
try {
const { clientId, authority } = JSON.parse(key) as IPublicClientApplicationInfo;
// Load the PCA in memory
promises.push(this._doCreatePublicClientApplication(clientId, authority, key));
promises.push(this._doCreatePublicClientApplication(clientId));
} catch (e) {
this._logger.error('[initialize] Error intitializing PCA:', key);
this._logger.error('[initialize] Error intitializing PCA:', clientId);
}
}
@@ -75,11 +75,11 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient
} else {
if (!result.value.accounts.length) {
pcasChanged = true;
const pcaKey = JSON.stringify({ clientId: result.value.clientId, authority: result.value.authority });
this._pcaDisposables.get(pcaKey)?.dispose();
this._pcaDisposables.delete(pcaKey);
this._pcas.delete(pcaKey);
this._logger.debug(`[initialize] [${result.value.clientId}] [${result.value.authority}] PCA disposed because it's empty.`);
const clientId = result.value.clientId;
this._pcaDisposables.get(clientId)?.dispose();
this._pcaDisposables.delete(clientId);
this._pcas.delete(clientId);
this._logger.debug(`[initialize] [${clientId}] PCA disposed because it's empty.`);
}
}
}
@@ -89,37 +89,52 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient
this._logger.debug('[initialize] PublicClientApplicationManager initialized');
}
private async _getMigrationsPerClientId(): Promise<Map<string, string[]> | undefined> {
await this._pcasSecretStorage.initialize();
const oldValue = await this._pcasSecretStorage.getOldValue();
// returns a map of clientIds to the authorities found in the old value
if (!oldValue) {
return undefined;
}
const result = new Map<string, string[]>();
for (const { clientId, authority } of oldValue) {
if (!result.has(clientId)) {
result.set(clientId, []);
}
result.get(clientId)?.push(authority);
}
return result;
}
dispose() {
this._disposable.dispose();
Disposable.from(...this._pcaDisposables.values()).dispose();
}
async getOrCreate(clientId: string, authority: string, refreshTokensToMigrate?: string[]): Promise<ICachedPublicClientApplication> {
// Use the clientId and authority as the key
const pcasKey = JSON.stringify({ clientId, authority });
let pca = this._pcas.get(pcasKey);
async getOrCreate(clientId: string, refreshTokensToMigrate?: string[]): Promise<ICachedPublicClientApplication> {
let pca = this._pcas.get(clientId);
if (pca) {
this._logger.debug(`[getOrCreate] [${clientId}] [${authority}] PublicClientApplicationManager cache hit`);
this._logger.debug(`[getOrCreate] [${clientId}] PublicClientApplicationManager cache hit`);
} else {
this._logger.debug(`[getOrCreate] [${clientId}] [${authority}] PublicClientApplicationManager cache miss, creating new PCA...`);
pca = await this._doCreatePublicClientApplication(clientId, authority, pcasKey);
this._logger.debug(`[getOrCreate] [${clientId}] PublicClientApplicationManager cache miss, creating new PCA...`);
pca = await this._doCreatePublicClientApplication(clientId, refreshTokensToMigrate);
await this._storePublicClientApplications();
this._logger.debug(`[getOrCreate] [${clientId}] [${authority}] PCA created.`);
this._logger.debug(`[getOrCreate] [${clientId}] PCA created.`);
}
// TODO: MSAL Migration. Remove this when we remove the old flow.
if (refreshTokensToMigrate?.length) {
this._logger.debug(`[getOrCreate] [${clientId}] [${authority}] Migrating refresh tokens to PCA...`);
this._logger.debug(`[getOrCreate] [${clientId}] Migrating refresh tokens to PCA...`);
for (const refreshToken of refreshTokensToMigrate) {
try {
// Use the refresh token to acquire a result. This will cache the refresh token for future operations.
// The scopes don't matter here since we can create any token from the refresh token.
const result = await pca.acquireTokenByRefreshToken({ refreshToken, forceCache: true, scopes: [] });
if (result?.account) {
this._logger.debug(`[getOrCreate] [${clientId}] [${authority}] Refresh token migrated to PCA.`);
this._logger.debug(`[getOrCreate] [${clientId}] Refresh token migrated to PCA.`);
}
} catch (e) {
this._logger.error(`[getOrCreate] [${clientId}] [${authority}] Error migrating refresh token:`, e);
this._logger.error(`[getOrCreate] [${clientId}] Error migrating refresh token:`, e);
}
}
// reinitialize the PCA so the account is properly cached
@@ -128,9 +143,9 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient
return pca;
}
private async _doCreatePublicClientApplication(clientId: string, authority: string, pcasKey: string) {
const pca = new CachedPublicClientApplication(clientId, authority, this._cloudName, this._globalMemento, this._secretStorage, this._logger);
this._pcas.set(pcasKey, pca);
private async _doCreatePublicClientApplication(clientId: string, authoritiesToMigrate?: string[]): Promise<ICachedPublicClientApplication> {
const pca = new CachedPublicClientApplication(clientId, this._cloudName, this._secretStorage, this._logger, authoritiesToMigrate);
this._pcas.set(clientId, pca);
const disposable = Disposable.from(
pca,
pca.onDidAccountsChange(e => this._onDidAccountsChangeEmitter.fire(e)),
@@ -138,13 +153,13 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient
// The PCA has no more accounts, so we can dispose it so we're not keeping it
// around forever.
disposable.dispose();
this._pcaDisposables.delete(pcasKey);
this._pcas.delete(pcasKey);
this._logger.debug(`[_doCreatePublicClientApplication] [${clientId}] [${authority}] PCA disposed. Firing off storing of PCAs...`);
this._pcaDisposables.delete(clientId);
this._pcas.delete(clientId);
this._logger.debug(`[_doCreatePublicClientApplication] [${clientId}] PCA disposed. Firing off storing of PCAs...`);
void this._storePublicClientApplications();
})
);
this._pcaDisposables.set(pcasKey, disposable);
this._pcaDisposables.set(clientId, disposable);
// Intialize the PCA after the `onDidAccountsChange` is set so we get initial state.
await pca.initialize();
return pca;
@@ -183,15 +198,14 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient
}
// Handle the new ones
for (const newPca of pcaKeysFromStorage) {
for (const clientId 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.`);
this._logger.debug(`[_handleSecretStorageChange] [${clientId}] Creating new PCA that was created in another window...`);
await this._doCreatePublicClientApplication(clientId);
this._logger.debug(`[_handleSecretStorageChange] [${clientId}] 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}`);
this._logger.error(`Failed to create new PublicClientApplication: ${clientId}`);
continue;
}
}
@@ -210,7 +224,8 @@ class PublicClientApplicationsSecretStorage {
private readonly _onDidChangeEmitter = new EventEmitter<void>;
readonly onDidChange: Event<void> = this._onDidChangeEmitter.event;
private readonly _key = `publicClientApplications-${this._cloudName}`;
private readonly _oldKey = `publicClientApplications-${this._cloudName}`;
private readonly _key = `publicClients-${this._cloudName}`;
constructor(private readonly _secretStorage: SecretStorage, private readonly _cloudName: string) {
this._disposable = Disposable.from(
@@ -223,6 +238,24 @@ class PublicClientApplicationsSecretStorage {
);
}
/**
* Runs the migration.
* TODO: Remove this after a version.
*/
async initialize() {
const oldValue = await this.getOldValue();
if (!oldValue) {
return;
}
const newValue = await this.get() ?? [];
for (const { clientId } of oldValue) {
if (!newValue.includes(clientId)) {
newValue.push(clientId);
}
}
await this.store(newValue);
}
async get(): Promise<string[] | undefined> {
const value = await this._secretStorage.get(this._key);
if (!value) {
@@ -231,6 +264,25 @@ class PublicClientApplicationsSecretStorage {
return JSON.parse(value);
}
/**
* Old representation of data that included the authority. This should be removed in a version or 2.
* @returns An array of objects with clientId and authority
*/
async getOldValue(): Promise<{ clientId: string; authority: string }[] | undefined> {
const value = await this._secretStorage.get(this._oldKey);
if (!value) {
return undefined;
}
const result: { clientId: string; authority: string }[] = [];
for (const stringifiedObj of JSON.parse(value)) {
const obj = JSON.parse(stringifiedObj);
if (obj.clientId && obj.authority) {
result.push(obj);
}
}
return result;
}
store(value: string[]): Thenable<void> {
return this._secretStorage.store(this._key, JSON.stringify(value));
}