Path: blob/main/extensions/microsoft-authentication/src/node/publicClientCache.ts
3320 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { AccountInfo } from '@azure/msal-node';6import { SecretStorage, LogOutputChannel, Disposable, EventEmitter, Memento, Event } from 'vscode';7import { ICachedPublicClientApplication, ICachedPublicClientApplicationManager } from '../common/publicClientCache';8import { CachedPublicClientApplication } from './cachedPublicClientApplication';9import { IAccountAccess, ScopedAccountAccess } from '../common/accountAccess';10import { MicrosoftAuthenticationTelemetryReporter } from '../common/telemetryReporter';11import { Environment } from '@azure/ms-rest-azure-env';12import { Config } from '../common/config';13import { DEFAULT_REDIRECT_URI } from '../common/env';1415export interface IPublicClientApplicationInfo {16clientId: string;17authority: string;18}1920export class CachedPublicClientApplicationManager implements ICachedPublicClientApplicationManager {21// The key is the clientId22private readonly _pcas = new Map<string, ICachedPublicClientApplication>();23private readonly _pcaDisposables = new Map<string, Disposable>();2425private _disposable: Disposable;2627private readonly _onDidAccountsChangeEmitter = new EventEmitter<{ added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }>();28readonly onDidAccountsChange = this._onDidAccountsChangeEmitter.event;2930private constructor(31private readonly _env: Environment,32private readonly _pcasSecretStorage: IPublicClientApplicationSecretStorage,33private readonly _accountAccess: IAccountAccess,34private readonly _secretStorage: SecretStorage,35private readonly _logger: LogOutputChannel,36private readonly _telemetryReporter: MicrosoftAuthenticationTelemetryReporter,37disposables: Disposable[]38) {39this._disposable = Disposable.from(40...disposables,41this._registerSecretStorageHandler(),42this._onDidAccountsChangeEmitter43);44}4546static async create(47secretStorage: SecretStorage,48logger: LogOutputChannel,49telemetryReporter: MicrosoftAuthenticationTelemetryReporter,50env: Environment51): Promise<CachedPublicClientApplicationManager> {52const pcasSecretStorage = await PublicClientApplicationsSecretStorage.create(secretStorage, env.name);53// TODO: Remove the migrations in a version54const migrations = await pcasSecretStorage.getOldValue();55const accountAccess = await ScopedAccountAccess.create(secretStorage, env.name, logger, migrations);56const manager = new CachedPublicClientApplicationManager(env, pcasSecretStorage, accountAccess, secretStorage, logger, telemetryReporter, [pcasSecretStorage, accountAccess]);57await manager.initialize();58return manager;59}6061private _registerSecretStorageHandler() {62return this._pcasSecretStorage.onDidChange(() => this._handleSecretStorageChange());63}6465private async initialize() {66this._logger.debug('[initialize] Initializing PublicClientApplicationManager');67let clientIds: string[] | undefined;68try {69clientIds = await this._pcasSecretStorage.get();70} catch (e) {71// data is corrupted72this._logger.error('[initialize] Error initializing PublicClientApplicationManager:', e);73await this._pcasSecretStorage.delete();74}75if (!clientIds) {76return;77}7879const promises = new Array<Promise<ICachedPublicClientApplication>>();80for (const clientId of clientIds) {81try {82// Load the PCA in memory83promises.push(this._doCreatePublicClientApplication(clientId));84} catch (e) {85this._logger.error('[initialize] Error intitializing PCA:', clientId);86}87}8889const results = await Promise.allSettled(promises);90let pcasChanged = false;91for (const result of results) {92if (result.status === 'rejected') {93this._logger.error('[initialize] Error getting PCA:', result.reason);94} else {95if (!result.value.accounts.length) {96pcasChanged = true;97const clientId = result.value.clientId;98this._pcaDisposables.get(clientId)?.dispose();99this._pcaDisposables.delete(clientId);100this._pcas.delete(clientId);101this._logger.debug(`[initialize] [${clientId}] PCA disposed because it's empty.`);102}103}104}105if (pcasChanged) {106await this._storePublicClientApplications();107}108this._logger.debug('[initialize] PublicClientApplicationManager initialized');109}110111dispose() {112this._disposable.dispose();113Disposable.from(...this._pcaDisposables.values()).dispose();114}115116async getOrCreate(clientId: string, migrate?: { refreshTokensToMigrate?: string[]; tenant: string }): Promise<ICachedPublicClientApplication> {117let pca = this._pcas.get(clientId);118if (pca) {119this._logger.debug(`[getOrCreate] [${clientId}] PublicClientApplicationManager cache hit`);120} else {121this._logger.debug(`[getOrCreate] [${clientId}] PublicClientApplicationManager cache miss, creating new PCA...`);122pca = await this._doCreatePublicClientApplication(clientId);123await this._storePublicClientApplications();124this._logger.debug(`[getOrCreate] [${clientId}] PCA created.`);125}126127// TODO: MSAL Migration. Remove this when we remove the old flow.128if (migrate?.refreshTokensToMigrate?.length) {129this._logger.debug(`[getOrCreate] [${clientId}] Migrating refresh tokens to PCA...`);130const authority = new URL(migrate.tenant, this._env.activeDirectoryEndpointUrl).toString();131let redirectUri = DEFAULT_REDIRECT_URI;132if (pca.isBrokerAvailable && process.platform === 'darwin') {133redirectUri = Config.macOSBrokerRedirectUri;134}135for (const refreshToken of migrate.refreshTokensToMigrate) {136try {137// Use the refresh token to acquire a result. This will cache the refresh token for future operations.138// The scopes don't matter here since we can create any token from the refresh token.139const result = await pca.acquireTokenByRefreshToken({140refreshToken,141forceCache: true,142scopes: [],143authority,144redirectUri145});146if (result?.account) {147this._logger.debug(`[getOrCreate] [${clientId}] Refresh token migrated to PCA.`);148}149} catch (e) {150this._logger.error(`[getOrCreate] [${clientId}] Error migrating refresh token:`, e);151}152}153}154return pca;155}156157private async _doCreatePublicClientApplication(clientId: string): Promise<ICachedPublicClientApplication> {158const pca = await CachedPublicClientApplication.create(clientId, this._secretStorage, this._accountAccess, this._logger, this._telemetryReporter);159this._pcas.set(clientId, pca);160const disposable = Disposable.from(161pca,162pca.onDidAccountsChange(e => this._onDidAccountsChangeEmitter.fire(e)),163pca.onDidRemoveLastAccount(() => {164// The PCA has no more accounts, so we can dispose it so we're not keeping it165// around forever.166disposable.dispose();167this._pcaDisposables.delete(clientId);168this._pcas.delete(clientId);169this._logger.debug(`[_doCreatePublicClientApplication] [${clientId}] PCA disposed. Firing off storing of PCAs...`);170void this._storePublicClientApplications();171})172);173this._pcaDisposables.set(clientId, disposable);174// Fire for the initial state and only if accounts exist175if (pca.accounts.length > 0) {176this._onDidAccountsChangeEmitter.fire({ added: pca.accounts, changed: [], deleted: [] });177}178return pca;179}180181getAll(): ICachedPublicClientApplication[] {182return Array.from(this._pcas.values());183}184185private async _handleSecretStorageChange() {186this._logger.debug(`[_handleSecretStorageChange] Handling PCAs secret storage change...`);187let result: string[] | undefined;188try {189result = await this._pcasSecretStorage.get();190} catch (_e) {191// The data in secret storage has been corrupted somehow so192// we store what we have in this window193await this._storePublicClientApplications();194return;195}196if (!result) {197this._logger.debug(`[_handleSecretStorageChange] PCAs deleted in secret storage. Disposing all...`);198Disposable.from(...this._pcaDisposables.values()).dispose();199this._pcas.clear();200this._pcaDisposables.clear();201this._logger.debug(`[_handleSecretStorageChange] Finished PCAs secret storage change.`);202return;203}204205const pcaKeysFromStorage = new Set(result);206// Handle the deleted ones207for (const pcaKey of this._pcas.keys()) {208if (!pcaKeysFromStorage.delete(pcaKey)) {209this._logger.debug(`[_handleSecretStorageChange] PCA was deleted in another window: ${pcaKey}`);210}211}212213// Handle the new ones214for (const clientId of pcaKeysFromStorage) {215try {216this._logger.debug(`[_handleSecretStorageChange] [${clientId}] Creating new PCA that was created in another window...`);217await this._doCreatePublicClientApplication(clientId);218this._logger.debug(`[_handleSecretStorageChange] [${clientId}] PCA created.`);219} catch (_e) {220// This really shouldn't happen, but should we do something about this?221this._logger.error(`Failed to create new PublicClientApplication: ${clientId}`);222continue;223}224}225226this._logger.debug('[_handleSecretStorageChange] Finished handling PCAs secret storage change.');227}228229private _storePublicClientApplications() {230return this._pcasSecretStorage.store(Array.from(this._pcas.keys()));231}232}233234interface IPublicClientApplicationSecretStorage {235get(): Promise<string[] | undefined>;236getOldValue(): Promise<{ clientId: string; authority: string }[] | undefined>;237store(value: string[]): Thenable<void>;238delete(): Thenable<void>;239onDidChange: Event<void>;240}241242class PublicClientApplicationsSecretStorage implements IPublicClientApplicationSecretStorage, Disposable {243private _disposable: Disposable;244245private readonly _onDidChangeEmitter = new EventEmitter<void>;246readonly onDidChange: Event<void> = this._onDidChangeEmitter.event;247248private readonly _oldKey: string;249private readonly _key: string;250251private constructor(252private readonly _secretStorage: SecretStorage,253private readonly _cloudName: string254) {255this._oldKey = `publicClientApplications-${this._cloudName}`;256this._key = `publicClients-${this._cloudName}`;257258this._disposable = Disposable.from(259this._onDidChangeEmitter,260this._secretStorage.onDidChange(e => {261if (e.key === this._key) {262this._onDidChangeEmitter.fire();263}264})265);266}267268static async create(secretStorage: SecretStorage, cloudName: string): Promise<PublicClientApplicationsSecretStorage> {269const storage = new PublicClientApplicationsSecretStorage(secretStorage, cloudName);270await storage.initialize();271return storage;272}273274/**275* Runs the migration.276* TODO: Remove this after a version.277*/278private async initialize() {279const oldValue = await this.getOldValue();280if (!oldValue) {281return;282}283const newValue = await this.get() ?? [];284for (const { clientId } of oldValue) {285if (!newValue.includes(clientId)) {286newValue.push(clientId);287}288}289await this.store(newValue);290}291292async get(): Promise<string[] | undefined> {293const value = await this._secretStorage.get(this._key);294if (!value) {295return undefined;296}297return JSON.parse(value);298}299300/**301* Old representation of data that included the authority. This should be removed in a version or 2.302* @returns An array of objects with clientId and authority303*/304async getOldValue(): Promise<{ clientId: string; authority: string }[] | undefined> {305const value = await this._secretStorage.get(this._oldKey);306if (!value) {307return undefined;308}309const result: { clientId: string; authority: string }[] = [];310for (const stringifiedObj of JSON.parse(value)) {311const obj = JSON.parse(stringifiedObj);312if (obj.clientId && obj.authority) {313result.push(obj);314}315}316return result;317}318319store(value: string[]): Thenable<void> {320return this._secretStorage.store(this._key, JSON.stringify(value));321}322323delete(): Thenable<void> {324return this._secretStorage.delete(this._key);325}326327dispose() {328this._disposable.dispose();329}330}331332333