Path: blob/main/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.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 { PublicClientApplication, AccountInfo, SilentFlowRequest, AuthenticationResult, InteractiveRequest, LogLevel, RefreshTokenRequest, BrokerOptions } from '@azure/msal-node';6import { NativeBrokerPlugin } from '@azure/msal-node-extensions';7import { Disposable, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter, workspace } from 'vscode';8import { raceCancellationAndTimeoutError } from '../common/async';9import { SecretStorageCachePlugin } from '../common/cachePlugin';10import { MsalLoggerOptions } from '../common/loggerOptions';11import { ICachedPublicClientApplication } from '../common/publicClientCache';12import { IAccountAccess } from '../common/accountAccess';13import { MicrosoftAuthenticationTelemetryReporter } from '../common/telemetryReporter';1415export class CachedPublicClientApplication implements ICachedPublicClientApplication {16// Core properties17private _pca: PublicClientApplication;18private _accounts: AccountInfo[] = [];19private _sequencer = new Sequencer();20private readonly _disposable: Disposable;2122// Cache properties23private readonly _secretStorageCachePlugin: SecretStorageCachePlugin;2425// Broker properties26readonly isBrokerAvailable: boolean = false;2728//#region Events2930private readonly _onDidAccountsChangeEmitter = new EventEmitter<{ added: AccountInfo[]; changed: AccountInfo[]; deleted: AccountInfo[] }>;31readonly onDidAccountsChange = this._onDidAccountsChangeEmitter.event;3233private readonly _onDidRemoveLastAccountEmitter = new EventEmitter<void>();34readonly onDidRemoveLastAccount = this._onDidRemoveLastAccountEmitter.event;3536//#endregion3738private constructor(39private readonly _clientId: string,40private readonly _secretStorage: SecretStorage,41private readonly _accountAccess: IAccountAccess,42private readonly _logger: LogOutputChannel,43telemetryReporter: MicrosoftAuthenticationTelemetryReporter44) {45this._secretStorageCachePlugin = new SecretStorageCachePlugin(46this._secretStorage,47// Include the prefix as a differentiator to other secrets48`pca:${this._clientId}`49);5051const loggerOptions = new MsalLoggerOptions(_logger, telemetryReporter);52let broker: BrokerOptions | undefined;53if (process.platform !== 'win32') {54this._logger.info(`[${this._clientId}] Native Broker is only available on Windows`);55} else if (workspace.getConfiguration('microsoft-authentication').get<'msal' | 'msal-no-broker'>('implementation') === 'msal-no-broker') {56this._logger.info(`[${this._clientId}] Native Broker disabled via settings`);57} else {58const nativeBrokerPlugin = new NativeBrokerPlugin();59this.isBrokerAvailable = nativeBrokerPlugin.isBrokerAvailable;60this._logger.info(`[${this._clientId}] Native Broker enabled: ${this.isBrokerAvailable}`);61if (this.isBrokerAvailable) {62broker = { nativeBrokerPlugin };63}64}65this._pca = new PublicClientApplication({66auth: { clientId: _clientId },67system: {68loggerOptions: {69correlationId: _clientId,70loggerCallback: (level, message, containsPii) => loggerOptions.loggerCallback(level, message, containsPii),71logLevel: LogLevel.Trace,72// Enable PII logging since it will only go to the output channel73piiLoggingEnabled: true74}75},76broker,77cache: { cachePlugin: this._secretStorageCachePlugin }78});79this._disposable = Disposable.from(80this._registerOnSecretStorageChanged(),81this._onDidAccountsChangeEmitter,82this._onDidRemoveLastAccountEmitter,83this._secretStorageCachePlugin84);85}8687get accounts(): AccountInfo[] { return this._accounts; }88get clientId(): string { return this._clientId; }8990static async create(91clientId: string,92secretStorage: SecretStorage,93accountAccess: IAccountAccess,94logger: LogOutputChannel,95telemetryReporter: MicrosoftAuthenticationTelemetryReporter96): Promise<CachedPublicClientApplication> {97const app = new CachedPublicClientApplication(clientId, secretStorage, accountAccess, logger, telemetryReporter);98await app.initialize();99return app;100}101102private async initialize(): Promise<void> {103await this._sequencer.queue(() => this._update());104}105106dispose(): void {107this._disposable.dispose();108}109110async acquireTokenSilent(request: SilentFlowRequest): Promise<AuthenticationResult> {111this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] starting...`);112let result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(request));113this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] got result`);114// Check expiration of id token and if it's 5min before expiration, force a refresh.115// this is what MSAL does for access tokens already so we're just adding it for id tokens since we care about those.116// NOTE: Once we stop depending on id tokens for some things we can remove all of this.117const idTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp;118if (idTokenExpirationInSecs) {119const fiveMinutesBefore = new Date(120(idTokenExpirationInSecs - 5 * 60) // subtract 5 minutes121* 1000 // convert to milliseconds122);123if (fiveMinutesBefore < new Date()) {124this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is expired or about to expire. Forcing refresh...`);125const newRequest = this.isBrokerAvailable126// HACK: Broker doesn't support forceRefresh so we need to pass in claims which will force a refresh127? { ...request, claims: request.claims ?? '{ "id_token": {}}' }128: { ...request, forceRefresh: true };129result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(newRequest));130this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result`);131}132const newIdTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp;133if (newIdTokenExpirationInSecs) {134const fiveMinutesBefore = new Date(135(newIdTokenExpirationInSecs - 5 * 60) // subtract 5 minutes136* 1000 // convert to milliseconds137);138if (fiveMinutesBefore < new Date()) {139this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is still expired.`);140141// 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, thus142// there has been a situation where both tokens are expired.143if (this.isBrokerAvailable) {144this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] forcing refresh with different claims...`);145const newRequest = { ...request, claims: request.claims ?? '{ "access_token": {}}' };146result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(newRequest));147this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result with different claims`);148const newIdTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp;149if (newIdTokenExpirationInSecs) {150const fiveMinutesBefore = new Date(151(newIdTokenExpirationInSecs - 5 * 60) // subtract 5 minutes152* 1000 // convert to milliseconds153);154if (fiveMinutesBefore < new Date()) {155this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is still expired.`);156}157}158}159}160}161}162163if (!result.account) {164this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] no account found in result`);165} else if (!result.fromCache && this._verifyIfUsingBroker(result)) {166this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] firing event due to change`);167this._onDidAccountsChangeEmitter.fire({ added: [], changed: [result.account], deleted: [] });168}169return result;170}171172async acquireTokenInteractive(request: InteractiveRequest): Promise<AuthenticationResult> {173this._logger.debug(`[acquireTokenInteractive] [${this._clientId}] [${request.authority}] [${request.scopes?.join(' ')}] loopbackClientOverride: ${request.loopbackClient ? 'true' : 'false'}`);174return await window.withProgress(175{176location: ProgressLocation.Notification,177cancellable: true,178title: l10n.t('Signing in to Microsoft...')179},180(_process, token) => this._sequencer.queue(async () => {181try {182const result = await raceCancellationAndTimeoutError(183this._pca.acquireTokenInteractive(request),184token,1851000 * 60 * 5186);187if (this.isBrokerAvailable) {188await this._accountAccess.setAllowedAccess(result.account!, true);189}190// Force an update so that the account cache is updated.191// TODO:@TylerLeonhardt The problem is, we use the sequencer for192// change events but we _don't_ use it for the accounts cache.193// We should probably use it for the accounts cache as well.194await this._update();195return result;196} catch (error) {197this._logger.error(`[acquireTokenInteractive] [${this._clientId}] [${request.authority}] [${request.scopes?.join(' ')}] error: ${error}`);198throw error;199}200})201);202}203204/**205* Allows for passing in a refresh token to get a new access token. This is the migration scenario.206* TODO: MSAL Migration. Remove this when we remove the old flow.207* @param request a {@link RefreshTokenRequest} object that contains the refresh token and other parameters.208* @returns an {@link AuthenticationResult} object that contains the result of the token acquisition operation.209*/210async acquireTokenByRefreshToken(request: RefreshTokenRequest): Promise<AuthenticationResult | null> {211this._logger.debug(`[acquireTokenByRefreshToken] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}]`);212const result = await this._sequencer.queue(async () => {213const result = await this._pca.acquireTokenByRefreshToken(request);214// Force an update so that the account cache is updated.215// TODO:@TylerLeonhardt The problem is, we use the sequencer for216// change events but we _don't_ use it for the accounts cache.217// We should probably use it for the accounts cache as well.218await this._update();219return result;220});221if (result) {222// this._setupRefresh(result);223if (this.isBrokerAvailable && result.account) {224await this._accountAccess.setAllowedAccess(result.account, true);225}226}227return result;228}229230removeAccount(account: AccountInfo): Promise<void> {231if (this.isBrokerAvailable) {232return this._accountAccess.setAllowedAccess(account, false);233}234return this._sequencer.queue(() => this._pca.getTokenCache().removeAccount(account));235}236237private _registerOnSecretStorageChanged() {238if (this.isBrokerAvailable) {239return this._accountAccess.onDidAccountAccessChange(() => this._sequencer.queue(() => this._update()));240}241return this._secretStorageCachePlugin.onDidChange(() => this._sequencer.queue(() => this._update()));242}243244private _lastSeen = new Map<string, number>();245private _verifyIfUsingBroker(result: AuthenticationResult): boolean {246// If we're not brokering, we don't need to verify the date247// the cache check will be sufficient248if (!result.fromNativeBroker) {249return true;250}251// The nativeAccountId is what the broker uses to differenciate all252// types of accounts. Even if the "account" is a duplicate of another because253// it's actaully a guest account in another tenant.254let key = result.account!.nativeAccountId;255if (!key) {256this._logger.error(`[verifyIfUsingBroker] [${this._clientId}] [${result.account!.username}] no nativeAccountId found. Using homeAccountId instead.`);257key = result.account!.homeAccountId;258}259const lastSeen = this._lastSeen.get(key);260const lastTimeAuthed = result.account!.idTokenClaims!.iat!;261if (!lastSeen) {262this._lastSeen.set(key, lastTimeAuthed);263return true;264}265if (lastSeen === lastTimeAuthed) {266return false;267}268this._lastSeen.set(key, lastTimeAuthed);269return true;270}271272private async _update() {273const before = this._accounts;274this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update before: ${before.length}`);275// Clear in-memory cache so we know we're getting account data from the SecretStorage276this._pca.clearCache();277let after = await this._pca.getAllAccounts();278if (this.isBrokerAvailable) {279after = after.filter(a => this._accountAccess.isAllowedAccess(a));280}281this._accounts = after;282this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update after: ${after.length}`);283284const beforeSet = new Set(before.map(b => b.homeAccountId));285const afterSet = new Set(after.map(a => a.homeAccountId));286287const added = after.filter(a => !beforeSet.has(a.homeAccountId));288const deleted = before.filter(b => !afterSet.has(b.homeAccountId));289if (added.length > 0 || deleted.length > 0) {290this._onDidAccountsChangeEmitter.fire({ added, changed: [], deleted });291this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication accounts changed. added: ${added.length}, deleted: ${deleted.length}`);292if (!after.length) {293this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication final account deleted. Firing event.`);294this._onDidRemoveLastAccountEmitter.fire();295}296}297this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update complete`);298}299}300301export class Sequencer {302303private current: Promise<unknown> = Promise.resolve(null);304305queue<T>(promiseTask: () => Promise<T>): Promise<T> {306return this.current = this.current.then(() => promiseTask(), () => promiseTask());307}308}309310311