Cache access tokens in local cache file to prevent MSAL throttling (#22763)

This commit is contained in:
Cheena Malhotra
2023-04-20 13:55:30 -07:00
committed by GitHub
parent 0bdb35d9ab
commit 8613176817
16 changed files with 309 additions and 156 deletions

View File

@@ -26,8 +26,9 @@ import * as qs from 'qs';
import { AzureAuthError } from './azureAuthError';
import { AccountInfo, AuthenticationResult, InteractionRequiredAuthError, PublicClientApplication } from '@azure/msal-node';
import { HttpClient } from './httpClient';
import { getProxyEnabledHttpClient } from '../../utils';
import { getProxyEnabledHttpClient, getTenantIgnoreList, updateTenantIgnoreList } from '../../utils';
import { errorToPromptFailedResult } from './networkUtils';
import { MsalCachePluginProvider } from '../utils/msalCachePlugin';
const localize = nls.loadMessageBundle();
export abstract class AzureAuth implements vscode.Disposable {
@@ -46,6 +47,7 @@ export abstract class AzureAuth implements vscode.Disposable {
constructor(
protected readonly metadata: AzureAccountProviderMetadata,
protected readonly tokenCache: SimpleTokenCache,
protected readonly msalCacheProvider: MsalCachePluginProvider,
protected readonly context: vscode.ExtensionContext,
protected clientApplication: PublicClientApplication,
protected readonly uriEventEmitter: vscode.EventEmitter<vscode.Uri>,
@@ -119,7 +121,8 @@ export abstract class AzureAuth implements vscode.Disposable {
const token: Token = {
token: result.response.accessToken,
key: result.response.account.homeAccountId,
tokenType: result.response.tokenType
tokenType: result.response.tokenType,
expiresOn: result.response.expiresOn!.getTime() / 1000
};
const tokenClaims = <TokenClaims>result.response.idTokenClaims;
const account = await this.hydrateAccount(token, tokenClaims);
@@ -228,7 +231,7 @@ export abstract class AzureAuth implements vscode.Disposable {
const cachedTokens = await this.getSavedTokenAdal(tenant, resource, account.key);
// Let's check to see if we can just use the cached tokens to return to the user
if (cachedTokens?.accessToken) {
if (cachedTokens) {
let expiry = Number(cachedTokens.expiresOn);
if (Number.isNaN(expiry)) {
Logger.error('Expiration time was not defined. This is expected on first launch');
@@ -315,7 +318,8 @@ export abstract class AzureAuth implements vscode.Disposable {
* (i.e. expired token, wrong scope, etc.), sends a request for a new token using the refresh token
* @param accountId
* @param azureResource
* @returns The authentication result, including the access token
* @returns The authentication result, including the access token.
* This function returns 'null' instead of 'undefined' by design as the same is returned by MSAL APIs in the flow (e.g. acquireTokenSilent).
*/
public async getTokenMsal(accountId: string, azureResource: azdata.AzureResource, tenantId: string): Promise<AuthenticationResult | azdata.PromptFailedResult | null> {
const resource = this.resources.find(s => s.azureResourceId === azureResource);
@@ -325,6 +329,12 @@ export abstract class AzureAuth implements vscode.Disposable {
return null;
}
// The user wants to ignore this tenant.
if (getTenantIgnoreList().includes(tenantId)) {
Logger.info(`Tenant ${tenantId} found in the ignore list, authentication will not be attempted.`);
return null;
}
// Resource endpoint must end with '/' to form a valid scope for MSAL token request.
const endpoint = resource.endpoint.endsWith('/') ? resource.endpoint : resource.endpoint + '/';
@@ -341,14 +351,14 @@ export abstract class AzureAuth implements vscode.Disposable {
}
// construct request
// forceRefresh needs to be set true here in order to fetch the correct token for non-full tenants, due to this issue
// forceRefresh needs to be set true here in order to fetch the correct token, due to this issue
// https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/3687
// Even for full tenants, access token is often received expired - force refresh is necessary when token expires.
const tokenRequest = {
account: account,
authority: `${this.loginEndpointUrl}${tenantId}`,
scopes: newScope,
// Force Refresh when tenant is NOT full tenant or organizational id that this account belongs to.
forceRefresh: tenantId !== account.tenantId
forceRefresh: true
};
try {
return await this.clientApplication.acquireTokenSilent(tokenRequest);
@@ -463,7 +473,7 @@ export abstract class AzureAuth implements vscode.Disposable {
public async getTenantsMsal(token: string): Promise<Tenant[]> {
const tenantUri = url.resolve(this.metadata.settings.armResource.endpoint, 'tenants?api-version=2019-11-01');
try {
Logger.verbose('Fetching tenants with uri {0}', tenantUri);
Logger.verbose(`Fetching tenants with uri: ${tenantUri}`);
let tenantList: string[] = [];
const tenantResponse = await this.httpClient.sendGetRequestAsync<any>(tenantUri, {
@@ -512,7 +522,7 @@ export abstract class AzureAuth implements vscode.Disposable {
public async getTenantsAdal(token: AccessToken): Promise<Tenant[]> {
const tenantUri = url.resolve(this.metadata.settings.armResource.endpoint, 'tenants?api-version=2019-11-01');
try {
Logger.verbose('Fetching tenants with URI: {0}', tenantUri);
Logger.verbose(`Fetching tenants with uri: ${tenantUri}`);
let tenantList: string[] = [];
const tenantResponse = await this.makeGetRequest(tenantUri, token.token);
if (tenantResponse.status !== 200) {
@@ -644,24 +654,14 @@ export abstract class AzureAuth implements vscode.Disposable {
if (!tenant.displayName && !tenant.id) {
throw new Error('Tenant did not have display name or id');
}
const getTenantConfigurationSet = (): Set<string> => {
const configuration = vscode.workspace.getConfiguration(Constants.AzureTenantConfigSection);
let values: string[] = configuration.get('filter') ?? [];
return new Set<string>(values);
};
const tenantIgnoreList = getTenantIgnoreList();
// The user wants to ignore this tenant.
if (getTenantConfigurationSet().has(tenant.id)) {
if (tenantIgnoreList.includes(tenant.id)) {
Logger.info(`Tenant ${tenant.id} found in the ignore list, authentication will not be attempted.`);
return false;
}
const updateTenantConfigurationSet = async (set: Set<string>): Promise<void> => {
const configuration = vscode.workspace.getConfiguration('azure.tenant.config');
await configuration.update('filter', Array.from(set), vscode.ConfigurationTarget.Global);
};
interface ConsentMessageItem extends vscode.MessageItem {
booleanResult: boolean;
action?: (tenantId: string) => Promise<void>;
@@ -682,9 +682,8 @@ export abstract class AzureAuth implements vscode.Disposable {
title: localize('azurecore.consentDialog.ignore', "Ignore Tenant"),
booleanResult: false,
action: async (tenantId: string) => {
let set = getTenantConfigurationSet();
set.add(tenantId);
await updateTenantConfigurationSet(set);
tenantIgnoreList.push(tenantId);
await updateTenantIgnoreList(tenantIgnoreList);
}
};
@@ -828,6 +827,7 @@ export abstract class AzureAuth implements vscode.Disposable {
}
public async deleteAllCacheMsal(): Promise<void> {
this.clientApplication.clearCache();
await this.msalCacheProvider.clearLocalCache();
}
public async deleteAllCacheAdal(): Promise<void> {
const results = await this.tokenCache.findCredentials('');
@@ -852,17 +852,18 @@ export abstract class AzureAuth implements vscode.Disposable {
}
}
public async deleteAccountCacheMsal(account: azdata.AccountKey): Promise<void> {
private async deleteAccountCacheMsal(accountKey: azdata.AccountKey): Promise<void> {
const tokenCache = this.clientApplication.getTokenCache();
let msalAccount: AccountInfo | null = await this.getAccountFromMsalCache(account.accountId);
let msalAccount: AccountInfo | null = await this.getAccountFromMsalCache(accountKey.accountId);
if (!msalAccount) {
Logger.error(`MSAL: Unable to find account ${account.accountId} for removal`);
throw Error(`Unable to find account ${account.accountId}`);
Logger.error(`MSAL: Unable to find account ${accountKey.accountId} for removal`);
throw Error(`Unable to find account ${accountKey.accountId}`);
}
await tokenCache.removeAccount(msalAccount);
await this.msalCacheProvider.clearAccountFromLocalCache(accountKey.accountId);
}
public async deleteAccountCacheAdal(account: azdata.AccountKey): Promise<void> {
private async deleteAccountCacheAdal(account: azdata.AccountKey): Promise<void> {
const results = await this.tokenCache.findCredentials(account.accountId);
if (!results) {
Logger.error('ADAL: Unable to find account for removal');
@@ -927,12 +928,22 @@ export interface Token extends AccountKey {
/**
* Access token expiry timestamp
*/
expiresOn?: number;
expiresOn: number | undefined;
/**
* TokenType
*/
tokenType: string;
/**
* Associated Tenant Id
*/
tenantId?: string;
/**
* Resource to which token belongs to.
*/
resource?: azdata.AzureResource;
}
export interface TokenClaims { // https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens

View File

@@ -19,6 +19,7 @@ import * as http from 'http';
import * as qs from 'qs';
import { promises as fs } from 'fs';
import { PublicClientApplication, CryptoProvider, AuthorizationUrlRequest, AuthorizationCodeRequest, AuthenticationResult } from '@azure/msal-node';
import { MsalCachePluginProvider } from '../utils/msalCachePlugin';
const localize = nls.loadMessageBundle();
@@ -43,12 +44,13 @@ export class AzureAuthCodeGrant extends AzureAuth {
constructor(
metadata: AzureAccountProviderMetadata,
tokenCache: SimpleTokenCache,
msalCacheProvider: MsalCachePluginProvider,
context: vscode.ExtensionContext,
uriEventEmitter: vscode.EventEmitter<vscode.Uri>,
clientApplication: PublicClientApplication,
authLibrary: string
) {
super(metadata, tokenCache, context, clientApplication, uriEventEmitter, AzureAuthType.AuthCodeGrant, AzureAuthCodeGrant.USER_FRIENDLY_NAME, authLibrary);
super(metadata, tokenCache, msalCacheProvider, context, clientApplication, uriEventEmitter, AzureAuthType.AuthCodeGrant, AzureAuthCodeGrant.USER_FRIENDLY_NAME, authLibrary);
this.cryptoProvider = new CryptoProvider();
this.pkceCodes = {
nonce: '',

View File

@@ -23,6 +23,7 @@ import { Deferred } from '../interfaces';
import { AuthenticationResult, DeviceCodeRequest, PublicClientApplication } from '@azure/msal-node';
import { SimpleTokenCache } from '../utils/simpleTokenCache';
import { Logger } from '../../utils/Logger';
import { MsalCachePluginProvider } from '../utils/msalCachePlugin';
const localize = nls.loadMessageBundle();
@@ -49,12 +50,13 @@ export class AzureDeviceCode extends AzureAuth {
constructor(
metadata: AzureAccountProviderMetadata,
tokenCache: SimpleTokenCache,
msalCacheProvider: MsalCachePluginProvider,
context: vscode.ExtensionContext,
uriEventEmitter: vscode.EventEmitter<vscode.Uri>,
clientApplication: PublicClientApplication,
authLibrary: string
) {
super(metadata, tokenCache, context, clientApplication, uriEventEmitter, AzureAuthType.DeviceCode, AzureDeviceCode.USER_FRIENDLY_NAME, authLibrary);
super(metadata, tokenCache, msalCacheProvider, context, clientApplication, uriEventEmitter, AzureAuthType.DeviceCode, AzureDeviceCode.USER_FRIENDLY_NAME, authLibrary);
this.pageTitle = localize('addAccount', "Add {0} account", this.metadata.displayName);
}

View File

@@ -21,6 +21,7 @@ import { AzureAuthCodeGrant } from './auths/azureAuthCodeGrant';
import { AzureDeviceCode } from './auths/azureDeviceCode';
import { filterAccounts } from '../azureResource/utils';
import * as Constants from '../constants';
import { MsalCachePluginProvider } from './utils/msalCachePlugin';
const localize = nls.loadMessageBundle();
@@ -35,6 +36,7 @@ export class AzureAccountProvider implements azdata.AccountProvider, vscode.Disp
tokenCache: SimpleTokenCache,
context: vscode.ExtensionContext,
clientApplication: PublicClientApplication,
private readonly msalCacheProvider: MsalCachePluginProvider,
uriEventHandler: vscode.EventEmitter<vscode.Uri>,
private readonly authLibrary: string,
private readonly forceDeviceCode: boolean = false
@@ -71,10 +73,10 @@ export class AzureAccountProvider implements azdata.AccountProvider, vscode.Disp
const deviceCodeMethod: boolean = configuration.get<boolean>(Constants.AuthType.DeviceCode, false);
if (codeGrantMethod === true && !this.forceDeviceCode) {
this.authMappings.set(AzureAuthType.AuthCodeGrant, new AzureAuthCodeGrant(metadata, tokenCache, context, uriEventHandler, this.clientApplication, this.authLibrary));
this.authMappings.set(AzureAuthType.AuthCodeGrant, new AzureAuthCodeGrant(metadata, tokenCache, this.msalCacheProvider, context, uriEventHandler, this.clientApplication, this.authLibrary));
}
if (deviceCodeMethod === true || this.forceDeviceCode) {
this.authMappings.set(AzureAuthType.DeviceCode, new AzureDeviceCode(metadata, tokenCache, context, uriEventHandler, this.clientApplication, this.authLibrary));
this.authMappings.set(AzureAuthType.DeviceCode, new AzureDeviceCode(metadata, tokenCache, this.msalCacheProvider, context, uriEventHandler, this.clientApplication, this.authLibrary));
}
if (codeGrantMethod === false && deviceCodeMethod === false && !this.forceDeviceCode) {
console.error('No authentication methods selected');
@@ -146,6 +148,16 @@ export class AzureAccountProvider implements azdata.AccountProvider, vscode.Disp
if (azureAuth) {
Logger.piiSanitized(`Getting account security token for ${JSON.stringify(account.key)} (tenant ${tenantId}). Auth Method = ${azureAuth.userFriendlyName}`, [], []);
if (this.authLibrary === Constants.AuthLibrary.MSAL) {
try {
// Fetch cached token from local cache if token is available and valid.
let accessToken = await this.msalCacheProvider.getTokenFromLocalCache(account.key.accountId, tenantId, resource);
if (this.isValidToken(accessToken)) {
return accessToken;
} // else fallback to fetching a new token.
} catch (e) {
// Log any error and move on to fetching fresh access token.
Logger.info(`Could not fetch access token from cache: ${e}, fetching new access token instead.`);
}
tenantId = tenantId || account.properties.owningTenant.id;
let authResult = await azureAuth.getTokenMsal(account.key.accountId, resource, tenantId);
if (this.isAuthenticationResult(authResult) && authResult.account && authResult.account.idTokenClaims) {
@@ -153,8 +165,15 @@ export class AzureAccountProvider implements azdata.AccountProvider, vscode.Disp
key: authResult.account.homeAccountId,
token: authResult.accessToken,
tokenType: authResult.tokenType,
expiresOn: authResult.account.idTokenClaims.exp
expiresOn: authResult.account.idTokenClaims.exp!,
tenantId: tenantId,
resource: resource
};
try {
await this.msalCacheProvider.writeTokenToLocalCache(token);
} catch (e) {
Logger.error(`Could not save access token to local cache: ${e}, this might cause throttling of AAD requests.`);
}
return token;
} else {
Logger.error(`MSAL: getToken call failed`);
@@ -172,7 +191,6 @@ export class AzureAccountProvider implements azdata.AccountProvider, vscode.Disp
account.isStale = true;
Logger.error(`_getAccountSecurityToken: Authentication method not found for account ${account.displayInfo.displayName}`);
throw Error('Failed to get authentication method, please remove and re-add the account');
}
}
@@ -192,6 +210,16 @@ export class AzureAccountProvider implements azdata.AccountProvider, vscode.Disp
}
}
/**
* Validates if access token is still valid by checking it's expiration time has a threshold of atleast 2 mins.
* @param accessToken Access token to be validated
* @returns True if access token is valid.
*/
private isValidToken(accessToken: Token | undefined): boolean {
const currentTime = new Date().getTime() / 1000;
return (accessToken !== undefined && accessToken.expiresOn !== undefined
&& Number(accessToken.expiresOn) - currentTime > 2 * 60); // threshold = 2 mins
}
private async _getSecurityToken(account: AzureAccount, resource: azdata.AzureResource): Promise<MultiTenantTokenResponse | undefined> {
void vscode.window.showInformationMessage(localize('azure.deprecatedGetSecurityToken', "A call was made to azdata.accounts.getSecurityToken, this method is deprecated and will be removed in future releases. Please use getAccountSecurityToken instead."));
@@ -254,7 +282,7 @@ export class AzureAccountProvider implements azdata.AccountProvider, vscode.Disp
return this.prompt();
}
clear(accountKey: azdata.AccountKey): Thenable<void> {
async clear(accountKey: azdata.AccountKey): Promise<void> {
return this._clear(accountKey);
}

View File

@@ -198,7 +198,8 @@ export class AzureAccountProviderService implements vscode.Disposable {
this.clientApplication = new PublicClientApplication(msalConfiguration);
let accountProvider = new AzureAccountProvider(provider.metadata as AzureAccountProviderMetadata,
simpleTokenCache, this._context, this.clientApplication, this._uriEventHandler, this._authLibrary, isSaw);
simpleTokenCache, this._context, this.clientApplication, this._cachePluginProvider,
this._uriEventHandler, this._authLibrary, isSaw);
this._accountProviders[provider.metadata.id] = accountProvider;
this._accountDisposals[provider.metadata.id] = azdata.accounts.registerAccountProvider(provider.metadata, accountProvider);
} catch (e) {

View File

@@ -26,37 +26,6 @@ export interface Subscription {
displayName: string
}
/**
* Token returned from a request for an access token
*/
export interface AzureAccountSecurityToken {
/**
* Access token, itself
*/
token: string;
/**
* Date that the token expires on
*/
expiresOn: Date | string;
/**
* Name of the resource the token is good for (ie, management.core.windows.net)
*/
resource: string;
/**
* Type of the token (pretty much always 'Bearer')
*/
tokenType: string;
}
/**
* Azure account security token maps a tenant ID to the information returned from a request to get
* an access token. The list of tenants correspond to the tenants in the account properties.
*/
export type AzureAccountSecurityTokenCollection = { [tenantId: string]: AzureAccountSecurityToken };
export interface Deferred<T, E extends Error = Error> {
resolve: (result: T | Promise<T>) => void;
reject: (reason: E) => void;

View File

@@ -6,7 +6,7 @@
import { promises as fs, constants as fsConstants } from 'fs';
import { Logger } from '../../utils/Logger';
export type ReadWriteHook = (contents: string) => Promise<string>;
export type ReadWriteHook = (contents: string, resetOnError?: boolean) => Promise<string>;
const noOpHook: ReadWriteHook = async (contents): Promise<string> => {
return contents;
};
@@ -97,7 +97,7 @@ export class FileDatabase {
try {
await fs.access(this.dbPath, fsConstants.R_OK | fsConstants.R_OK);
fileContents = await fs.readFile(this.dbPath, { encoding: 'utf8' });
fileContents = await this.readHook(fileContents);
fileContents = await this.readHook(fileContents, true);
} catch (ex) {
Logger.error(`Error occurred when initializing File Database from file system cache, ADAL cache will be reset: ${ex}`);
await this.createFile();

View File

@@ -79,7 +79,7 @@ export class FileEncryptionHelper {
return cipherText;
}
fileOpener = async (content: string): Promise<string> => {
fileOpener = async (content: string, resetOnError?: boolean): Promise<string> => {
try {
if (!this._keyBuffer || !this._ivBuffer) {
await this.init();
@@ -97,13 +97,15 @@ export class FileEncryptionHelper {
return `${decipherIv.update(plaintext, this._binaryEncoding, 'utf8')}${decipherIv.final('utf8')}`;
} catch (ex) {
Logger.error(`FileEncryptionHelper: Error occurred when decrypting data, IV/KEY will be reset: ${ex}`);
// Reset IV/Keys if crypto cannot encrypt/decrypt data.
// This could be a possible case of corruption of expected iv/key combination
await this.deleteEncryptionKey(this._ivCredId);
await this.deleteEncryptionKey(this._keyCredId);
this._ivBuffer = undefined;
this._keyBuffer = undefined;
await this.init();
if (resetOnError) {
// Reset IV/Keys if crypto cannot encrypt/decrypt data.
// This could be a possible case of corruption of expected iv/key combination
await this.deleteEncryptionKey(this._ivCredId);
await this.deleteEncryptionKey(this._keyCredId);
this._ivBuffer = undefined;
this._keyBuffer = undefined;
await this.init();
}
// Throw error so cache file can be reset to empty.
throw new Error(`Decryption failed with error: ${ex}`);
}

View File

@@ -10,28 +10,49 @@ import * as lockFile from 'lockfile';
import * as path from 'path';
import * as azdata from 'azdata';
import * as vscode from 'vscode';
import { AccountsClearTokenCacheCommand, AuthLibrary } from '../../constants';
import { AccountsClearTokenCacheCommand, AuthLibrary, LocalCacheSuffix, LockFileSuffix } from '../../constants';
import { Logger } from '../../utils/Logger';
import { FileEncryptionHelper } from './fileEncryptionHelper';
import { CacheEncryptionKeys } from 'azurecore';
import { Token } from '../auths/azureAuth';
interface CacheConfiguration {
name: string,
cacheFilePath: string,
lockFilePath: string,
lockTaken: boolean
}
interface LocalAccountCache {
tokens: Token[];
}
export class MsalCachePluginProvider {
constructor(
private readonly _serviceName: string,
private readonly _msalFilePath: string,
msalFilePath: string,
private readonly _credentialService: azdata.CredentialProvider,
private readonly _onEncryptionKeysUpdated: vscode.EventEmitter<CacheEncryptionKeys>
) {
this._msalFilePath = path.join(this._msalFilePath, this._serviceName);
this._fileEncryptionHelper = new FileEncryptionHelper(AuthLibrary.MSAL, this._credentialService, this._serviceName, this._onEncryptionKeysUpdated);
this._msalCacheConfiguration = {
name: 'MSAL',
cacheFilePath: path.join(msalFilePath, this._serviceName),
lockFilePath: path.join(msalFilePath, this._serviceName) + LockFileSuffix,
lockTaken: false
}
this._localCacheConfiguration = {
name: 'Local',
cacheFilePath: path.join(msalFilePath, this._serviceName) + LocalCacheSuffix,
lockFilePath: path.join(msalFilePath, this._serviceName) + LocalCacheSuffix + LockFileSuffix,
lockTaken: false
}
}
private _lockTaken: boolean = false;
private _fileEncryptionHelper: FileEncryptionHelper;
private getLockfilePath(): string {
return this._msalFilePath + '.lockfile';
}
private _msalCacheConfiguration: CacheConfiguration;
private _localCacheConfiguration: CacheConfiguration;
private _emptyLocalCache: LocalAccountCache = { tokens: [] };
public async init(): Promise<void> {
await this._fileEncryptionHelper.init();
@@ -42,52 +63,22 @@ export class MsalCachePluginProvider {
}
public getCachePlugin(): ICachePlugin {
const lockFilePath = this.getLockfilePath();
const beforeCacheAccess = async (cacheContext: TokenCacheContext): Promise<void> => {
await this.waitAndLock(lockFilePath);
try {
const cache = await fsPromises.readFile(this._msalFilePath, { encoding: 'utf8' });
const decryptedData = await this._fileEncryptionHelper.fileOpener(cache!);
try {
cacheContext.tokenCache.deserialize(decryptedData);
} catch (e) {
// Handle deserialization error in cache file in case file gets corrupted.
// Clearing cache here will ensure account is marked stale so re-authentication can be triggered.
Logger.verbose(`MsalCachePlugin: Error occurred when trying to read cache file, file will be deleted: ${e.message}`);
await fsPromises.unlink(this._msalFilePath);
}
Logger.verbose(`MsalCachePlugin: Token read from cache successfully.`);
const decryptedData = await this.readCache(this._msalCacheConfiguration);
cacheContext.tokenCache.deserialize(decryptedData);
} catch (e) {
if (e.code === 'ENOENT') {
// File doesn't exist, log and continue
Logger.verbose(`MsalCachePlugin: Cache file not found on disk: ${e.code}`);
}
else {
Logger.error(`MsalCachePlugin: Failed to read from cache file: ${e}`);
Logger.verbose(`MsalCachePlugin: Error occurred when trying to read cache file, file will be deleted: ${e.message}`);
await fsPromises.unlink(this._msalFilePath);
}
} finally {
lockFile.unlockSync(lockFilePath);
this._lockTaken = false;
// Handle deserialization error in cache file in case file gets corrupted.
// Clearing cache here will ensure account is marked stale so re-authentication can be triggered.
Logger.verbose(`MsalCachePlugin: Error occurred when trying to read cache file, file will be deleted: ${e.message}`);
await fsPromises.unlink(this._msalCacheConfiguration.cacheFilePath);
}
}
const afterCacheAccess = async (cacheContext: TokenCacheContext): Promise<void> => {
if (cacheContext.cacheHasChanged) {
await this.waitAndLock(lockFilePath);
try {
const data = cacheContext.tokenCache.serialize();
const encryptedData = await this._fileEncryptionHelper.fileSaver(data!);
await fsPromises.writeFile(this._msalFilePath, encryptedData, { encoding: 'utf8' });
Logger.verbose(`MsalCachePlugin: Token written to cache successfully.`);
} catch (e) {
Logger.error(`MsalCachePlugin: Failed to write to cache file. ${e}`);
throw e;
} finally {
lockFile.unlockSync(lockFilePath);
this._lockTaken = false;
}
const data = cacheContext.tokenCache.serialize();
await this.writeCache(data, this._msalCacheConfiguration);
}
};
@@ -102,14 +93,134 @@ export class MsalCachePluginProvider {
};
}
private async waitAndLock(lockFilePath: string): Promise<void> {
/**
* Fetches access token from local cache, before accessing MSAL Cache.
* @param accountId Account Id for token owner.
* @param tenantId Tenant Id to which token belongs to.
* @param resource Resource Id to which token belongs to.
* @returns Access Token.
*/
public async getTokenFromLocalCache(accountId: string, tenantId: string, resource: azdata.AzureResource): Promise<Token | undefined> {
let cache = JSON.parse(await this.readCache(this._localCacheConfiguration)) as LocalAccountCache;
let token = cache?.tokens?.find(token => (
token.key === accountId &&
token.tenantId === tenantId &&
token.resource === resource
));
return token;
}
/**
* Updates local cache with newly fetched access token to prevent throttling of AAD requests.
* @param token Access token to be written to cache file.
*/
public async writeTokenToLocalCache(token: Token): Promise<void> {
let updateCount = 0;
let cache: LocalAccountCache;
cache = JSON.parse(await this.readCache(this._localCacheConfiguration)) as LocalAccountCache;
if (cache?.tokens) {
cache.tokens.forEach(t => {
if (t.key === token.key && t.tenantId === token.tenantId && t.resource === token.resource
) {
// Update token
t = token;
updateCount++;
}
});
} else {
// Initialize token cache
cache = this._emptyLocalCache;
}
if (updateCount === 0) {
// No tokens were updated, add new token.
cache.tokens.push(token);
updateCount = 1;
}
if (updateCount === 1) {
await this.writeCache(JSON.stringify(cache), this._localCacheConfiguration);
}
else {
Logger.info(`Found multiple tokens in local cache, cache will be reset.`);
// Reset cache as we don't expect multiple tokens to be stored for same combination.
await this.writeCache(JSON.stringify(this._emptyLocalCache), this._localCacheConfiguration);
}
}
/**
* Removes associated tokens for account, to be called when account is deleted.
* @param accountId Account ID
*/
public async clearAccountFromLocalCache(accountId: string): Promise<void> {
let cache = JSON.parse(await this.readCache(this._localCacheConfiguration)) as LocalAccountCache;
let tokenIndices: number[] = [];
if (cache?.tokens) {
cache.tokens.forEach((t, i) => {
if (t.key === accountId) {
tokenIndices.push(i);
}
});
}
tokenIndices.forEach(i => {
cache.tokens.splice(i);
})
Logger.info(`Local Cache cleared for account, ${tokenIndices.length} tokens were cleared.`);
}
/**
* Clears local access token cache.
*/
public async clearLocalCache(): Promise<void> {
await this.writeCache(JSON.stringify({ tokens: [] }), this._localCacheConfiguration);
}
//#region Private helper methods
private async writeCache(fileContents: string, config: CacheConfiguration): Promise<void> {
config.lockTaken = await this.waitAndLock(config.lockFilePath, config.lockTaken);
try {
const encryptedCache = await this._fileEncryptionHelper.fileSaver(fileContents);
await fsPromises.writeFile(config.cacheFilePath, encryptedCache, { encoding: 'utf8' });
} catch (e) {
Logger.error(`MsalCachePlugin: Failed to write to '${config.name}' cache file: ${e}`);
throw e;
} finally {
lockFile.unlockSync(config.lockFilePath);
config.lockTaken = false;
}
}
private async readCache(config: CacheConfiguration): Promise<string> {
config.lockTaken = await this.waitAndLock(config.lockFilePath, config.lockTaken);
try {
const cache = await fsPromises.readFile(config.cacheFilePath, { encoding: 'utf8' });
const decryptedData = await this._fileEncryptionHelper.fileOpener(cache!, true);
return decryptedData;
} catch (e) {
if (e.code === 'ENOENT') {
// File doesn't exist, log and continue
Logger.verbose(`MsalCachePlugin: Cache file for '${config.name}' cache not found on disk: ${e.code}`);
}
else {
Logger.error(`MsalCachePlugin: Failed to read from cache file: ${e}`);
Logger.verbose(`MsalCachePlugin: Error occurred when trying to read cache file, file will be deleted: ${e.message}`);
await fsPromises.unlink(config.cacheFilePath);
}
return '{}'; // Return empty json string if cache not read.
} finally {
lockFile.unlockSync(config.lockFilePath);
config.lockTaken = false;
}
}
private async waitAndLock(lockFilePath: string, lockTaken: boolean): Promise<boolean> {
// Make 500 retry attempts with 100ms wait time between each attempt to allow enough time for the lock to be released.
const retries = 500;
const retryWait = 100;
// We cannot rely on lockfile.lockSync() to clear stale lockfile,
// so we check if the lockfile exists and if it does, calling unlockSync() will clear it.
if (lockFile.checkSync(lockFilePath) && !this._lockTaken) {
if (lockFile.checkSync(lockFilePath) && !lockTaken) {
lockFile.unlockSync(lockFilePath);
Logger.verbose(`MsalCachePlugin: Stale lockfile found and has been removed.`);
}
@@ -120,7 +231,7 @@ export class MsalCachePluginProvider {
// Use lockfile.lockSync() to ensure only one process is accessing the cache at a time.
// lockfile.lock() does not wait for async callback promise to resolve.
lockFile.lockSync(lockFilePath);
this._lockTaken = true;
lockTaken = true;
break;
} catch (e) {
if (retryAttempt === retries) {
@@ -132,5 +243,7 @@ export class MsalCachePluginProvider {
await new Promise(resolve => setTimeout(resolve, retryWait));
}
}
return lockTaken;
}
//#endregion
}

View File

@@ -35,8 +35,11 @@ declare module 'azurecore' {
/**
* Auth type of azure used to authenticate this account.
*/
azureAuthType?: AzureAuthType
azureAuthType?: AzureAuthType;
/**
* Provider settings for account.
*/
providerSettings: AzureAccountProviderMetadata;
/**
@@ -53,7 +56,6 @@ declare module 'azurecore' {
* A list of tenants (aka directories) that the account belongs to
*/
tenants: Tenant[];
}
export const enum AzureAuthType {

View File

@@ -37,6 +37,8 @@ export const TenantSection = 'tenant';
export const AzureTenantConfigSection = AzureSection + '.' + TenantSection + '.' + ConfigSection;
export const Filter = 'filter';
export const NoSystemKeyChainSection = 'noSystemKeychain';
export const oldMsalCacheFileName = 'azureTokenCacheMsal-azure_publicCloud';
@@ -72,6 +74,10 @@ export const MSALCacheName = 'accessTokenCache';
export const DefaultAuthLibrary = 'MSAL';
export const LocalCacheSuffix = '.local';
export const LockFileSuffix = '.lockfile';
export enum BuiltInCommands {
SetContext = 'setContext'
}

View File

@@ -19,7 +19,8 @@ let azureAuthCodeGrant: TypeMoq.IMock<AzureAuthCodeGrant>;
const mockToken: Token = {
key: 'someUniqueId',
token: 'test_token',
tokenType: 'Bearer'
tokenType: 'Bearer',
expiresOn: new Date().getTime() / 1000 + (60 * 60) // 1 hour from now.
};
let mockAccessToken: AccessToken;
let mockRefreshToken: RefreshToken;

View File

@@ -136,10 +136,29 @@ export function getResourceTypeDisplayName(type: string): string {
}
return type;
}
function getHttpConfiguration(): vscode.WorkspaceConfiguration {
return vscode.workspace.getConfiguration(constants.httpConfigSectionName);
}
/**
* Gets tenants to be ignored.
* @returns Tenants configured in ignore list
*/
export function getTenantIgnoreList(): string[] {
const configuration = vscode.workspace.getConfiguration(constants.AzureTenantConfigSection);
return configuration.get(constants.Filter) ?? [];
}
/**
* Updates tenant ignore list in global settings.
* @param tenantIgnoreList Tenants to be configured in ignore list
*/
export async function updateTenantIgnoreList(tenantIgnoreList: string[]): Promise<void> {
const configuration = vscode.workspace.getConfiguration(constants.AzureTenantConfigSection);
await configuration.update(constants.Filter, tenantIgnoreList, vscode.ConfigurationTarget.Global);
}
export function getResourceTypeIcon(appContext: AppContext, type: string): string {
switch (type) {
case azureResource.AzureResourceType.sqlServer: