Path: blob/main/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts
5226 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, DeviceCodeRequest } from '@azure/msal-node';6import { NativeBrokerPlugin } from '@azure/msal-node-extensions';7import { Disposable, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter, workspace, env, Uri, UIKind } from 'vscode';8import { DeferredPromise, 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 (env.uiKind === UIKind.Web) {54this._logger.info(`[${this._clientId}] Native Broker is not available in web UI`);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}229230async acquireTokenByDeviceCode(request: Omit<DeviceCodeRequest, 'deviceCodeCallback'>): Promise<AuthenticationResult | null> {231this._logger.debug(`[acquireTokenByDeviceCode] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}]`);232const result = await this._sequencer.queue(async () => {233const deferredPromise = new DeferredPromise<AuthenticationResult | null>();234const result = await Promise.race([235this._pca.acquireTokenByDeviceCode({236...request,237deviceCodeCallback: (response) => void this._deviceCodeCallback(response, deferredPromise)238}),239deferredPromise.p240]);241await deferredPromise.complete(result);242// Force an update so that the account cache is updated.243// TODO:@TylerLeonhardt The problem is, we use the sequencer for244// change events but we _don't_ use it for the accounts cache.245// We should probably use it for the accounts cache as well.246await this._update();247return result;248});249if (result) {250if (this.isBrokerAvailable && result.account) {251await this._accountAccess.setAllowedAccess(result.account, true);252}253}254return result;255}256257private async _deviceCodeCallback(258// MSAL doesn't expose this type...259response: Parameters<DeviceCodeRequest['deviceCodeCallback']>[0],260deferredPromise: DeferredPromise<AuthenticationResult | null>261): Promise<void> {262const button = l10n.t('Copy & Continue to Microsoft');263const modalResult = await window.showInformationMessage(264l10n.t({ message: 'Your Code: {0}', args: [response.userCode], comment: ['The {0} will be a code, e.g. 123-456'] }),265{266modal: true,267detail: l10n.t('To finish authenticating, navigate to Microsoft and paste in the above one-time code.')268}, button);269270if (modalResult !== button) {271this._logger.debug(`[deviceCodeCallback] [${this._clientId}] User cancelled the device code flow.`);272deferredPromise.cancel();273return;274}275276await env.clipboard.writeText(response.userCode);277await env.openExternal(Uri.parse(response.verificationUri));278await window.withProgress<void>({279location: ProgressLocation.Notification,280cancellable: true,281title: l10n.t({282message: 'Open [{0}]({0}) in a new tab and paste your one-time code: {1}',283args: [response.verificationUri, response.userCode],284comment: [285'The [{0}]({0}) will be a url and the {1} will be a code, e.g. 123456',286'{Locked="[{0}]({0})"}'287]288})289}, async (_, token) => {290const disposable = token.onCancellationRequested(() => {291this._logger.debug(`[deviceCodeCallback] [${this._clientId}] Device code flow cancelled by user.`);292deferredPromise.cancel();293});294try {295await deferredPromise.p;296this._logger.debug(`[deviceCodeCallback] [${this._clientId}] Device code flow completed successfully.`);297} catch (error) {298// Ignore errors here, they are handled at a higher scope299} finally {300disposable.dispose();301}302});303}304305removeAccount(account: AccountInfo): Promise<void> {306if (this.isBrokerAvailable) {307return this._accountAccess.setAllowedAccess(account, false);308}309return this._sequencer.queue(() => this._pca.getTokenCache().removeAccount(account));310}311312private _registerOnSecretStorageChanged() {313if (this.isBrokerAvailable) {314return this._accountAccess.onDidAccountAccessChange(() => this._sequencer.queue(() => this._update()));315}316return this._secretStorageCachePlugin.onDidChange(() => this._sequencer.queue(() => this._update()));317}318319private _lastSeen = new Map<string, number>();320private _verifyIfUsingBroker(result: AuthenticationResult): boolean {321// If we're not brokering, we don't need to verify the date322// the cache check will be sufficient323if (!result.fromNativeBroker) {324return true;325}326// The nativeAccountId is what the broker uses to differenciate all327// types of accounts. Even if the "account" is a duplicate of another because328// it's actaully a guest account in another tenant.329let key = result.account!.nativeAccountId;330if (!key) {331this._logger.error(`[verifyIfUsingBroker] [${this._clientId}] [${result.account!.username}] no nativeAccountId found. Using homeAccountId instead.`);332key = result.account!.homeAccountId;333}334const lastSeen = this._lastSeen.get(key);335const lastTimeAuthed = result.account!.idTokenClaims!.iat!;336if (!lastSeen) {337this._lastSeen.set(key, lastTimeAuthed);338return true;339}340if (lastSeen === lastTimeAuthed) {341return false;342}343this._lastSeen.set(key, lastTimeAuthed);344return true;345}346347private async _update() {348const before = this._accounts;349this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update before: ${before.length}`);350// Clear in-memory cache so we know we're getting account data from the SecretStorage351this._pca.clearCache();352let after = await this._pca.getAllAccounts();353if (this.isBrokerAvailable) {354after = after.filter(a => this._accountAccess.isAllowedAccess(a));355}356this._accounts = after;357this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update after: ${after.length}`);358359const beforeSet = new Set(before.map(b => b.homeAccountId));360const afterSet = new Set(after.map(a => a.homeAccountId));361362const added = after.filter(a => !beforeSet.has(a.homeAccountId));363const deleted = before.filter(b => !afterSet.has(b.homeAccountId));364if (added.length > 0 || deleted.length > 0) {365this._onDidAccountsChangeEmitter.fire({ added, changed: [], deleted });366this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication accounts changed. added: ${added.length}, deleted: ${deleted.length}`);367if (!after.length) {368this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication final account deleted. Firing event.`);369this._onDidRemoveLastAccountEmitter.fire();370}371}372this._logger.debug(`[update] [${this._clientId}] CachedPublicClientApplication update complete`);373}374}375376export class Sequencer {377378private current: Promise<unknown> = Promise.resolve(null);379380queue<T>(promiseTask: () => Promise<T>): Promise<T> {381return this.current = this.current.then(() => promiseTask(), () => promiseTask());382}383}384385386