Introduce inbuilt MsalCachePlugin to replace msal-node-extensions (#21335)

This commit is contained in:
Cheena Malhotra
2022-11-30 21:23:32 -08:00
committed by GitHub
parent 02e9f8d3ef
commit 511523d002
9 changed files with 188 additions and 373 deletions

View File

@@ -442,12 +442,11 @@ export abstract class AzureAuth implements vscode.Disposable {
authLibrary: this._authLibrary
};
await this.saveToken(tenant, resource, accountKey, result);
await this.saveTokenAdal(tenant, resource, accountKey, result);
return result;
}
public async getTenantsMsal(token: string): Promise<Tenant[]> {
const tenantUri = url.resolve(this.metadata.settings.armResource.endpoint, 'tenants?api-version=2019-11-01');
try {
@@ -527,7 +526,7 @@ export abstract class AzureAuth implements vscode.Disposable {
//#endregion
//#region token management
private async saveToken(tenant: Tenant, resource: Resource, accountKey: azdata.AccountKey, { accessToken, refreshToken, expiresOn }: OAuthTokenResponse) {
private async saveTokenAdal(tenant: Tenant, resource: Resource, accountKey: azdata.AccountKey, { accessToken, refreshToken, expiresOn }: OAuthTokenResponse) {
const msg = localize('azure.cacheErrorAdd', "Error when adding your account to the cache.");
if (!tenant.id || !resource.id) {
Logger.pii('Tenant ID or resource ID was undefined', [], [], tenant, resource);

View File

@@ -7,18 +7,17 @@ import * as azdata from 'azdata';
import * as events from 'events';
import * as nls from 'vscode-nls';
import * as vscode from 'vscode';
import * as os from 'os';
import { SimpleTokenCache } from './simpleTokenCache';
import providerSettings from './providerSettings';
import { AzureAccountProvider as AzureAccountProvider } from './azureAccountProvider';
import { AzureAccountProviderMetadata } from 'azurecore';
import { ProviderSettings } from './interfaces';
import { MsalCachePluginProvider } from './utils/msalCachePlugin';
import * as loc from '../localizedConstants';
import { PublicClientApplication } from '@azure/msal-node';
import { DataProtectionScope, PersistenceCachePlugin, FilePersistenceWithDataProtection, KeychainPersistence, LibSecretPersistence } from '@azure/msal-node-extensions';
import * as path from 'path';
import { Logger } from '../utils/Logger';
import { Configuration, PublicClientApplication } from '@azure/msal-node';
import * as Constants from '../constants';
import { Logger } from '../utils/Logger';
import { ILoggerCallback, LogLevel as MsalLogLevel } from "@azure/msal-common";
let localize = nls.loadMessageBundle();
@@ -34,12 +33,12 @@ export class AzureAccountProviderService implements vscode.Disposable {
private _accountDisposals: { [accountProviderId: string]: vscode.Disposable } = {};
private _accountProviders: { [accountProviderId: string]: azdata.AccountProvider } = {};
private _credentialProvider: azdata.CredentialProvider | undefined = undefined;
private _cachePluginProvider: MsalCachePluginProvider | undefined = undefined;
private _configChangePromiseChain: Thenable<void> = Promise.resolve();
private _currentConfig: vscode.WorkspaceConfiguration | undefined = undefined;
private _event: events.EventEmitter = new events.EventEmitter();
private readonly _uriEventHandler: UriEventHandler = new UriEventHandler();
public clientApplication!: PublicClientApplication;
public persistence: FilePersistenceWithDataProtection | KeychainPersistence | LibSecretPersistence | undefined;
constructor(private _context: vscode.ExtensionContext,
private _userStoragePath: string,
@@ -144,51 +143,38 @@ export class AzureAccountProviderService implements vscode.Disposable {
private async registerAccountProvider(provider: ProviderSettings): Promise<void> {
const isSaw: boolean = vscode.env.appName.toLowerCase().indexOf(Constants.Saw) > 0;
const noSystemKeychain = vscode.workspace.getConfiguration(Constants.AzureSection).get<boolean>(Constants.NoSystemKeyChainSection);
const platform = os.platform();
const tokenCacheKey = `azureTokenCache-${provider.metadata.id}`;
const tokenCacheKeyMsal = `azureTokenCacheMsal-${provider.metadata.id}`;
try {
if (!this._credentialProvider) {
throw new Error('Credential provider not registered');
}
// ADAL Token Cache
let simpleTokenCache = new SimpleTokenCache(tokenCacheKey, this._userStoragePath, noSystemKeychain, this._credentialProvider);
await simpleTokenCache.init();
const cachePath = path.join(this._userStoragePath, Constants.ConfigFilePath);
switch (platform) {
case Constants.Platform.Windows:
const dataProtectionScope = DataProtectionScope.CurrentUser;
const optionalEntropy = "";
this.persistence = await FilePersistenceWithDataProtection.create(cachePath, dataProtectionScope, optionalEntropy);
break;
case Constants.Platform.Mac:
case Constants.Platform.Linux:
this.persistence = await KeychainPersistence.create(cachePath, Constants.ServiceName, Constants.Account);
break;
}
if (!this.persistence) {
Logger.error('Unable to intialize persistence for access token cache. Tokens will not persist in system memory for future use.');
throw new Error('Unable to intialize persistence for access token cache. Tokens will not persist in system memory for future use.');
}
// MSAL Cache Plugin
this._cachePluginProvider = new MsalCachePluginProvider(tokenCacheKeyMsal, this._userStoragePath);
let persistenceCachePlugin: PersistenceCachePlugin = new PersistenceCachePlugin(
this.persistence, {
retryNumber: 500,
retryDelay: 150
});
const MSAL_CONFIG = {
const msalConfiguration: Configuration = {
auth: {
clientId: provider.metadata.settings.clientId,
redirect_uri: `${provider.metadata.settings.redirectUri}/redirect`
authority: 'https://login.windows.net/common'
},
system: {
loggerOptions: {
loggerCallback: this.getLoggerCallback(),
logLevel: MsalLogLevel.Trace,
piiLoggingEnabled: true,
},
},
cache: {
cachePlugin: persistenceCachePlugin
cachePlugin: this._cachePluginProvider?.getCachePlugin()
}
}
this.clientApplication = new PublicClientApplication(MSAL_CONFIG);
this.clientApplication = new PublicClientApplication(msalConfiguration);
let accountProvider = new AzureAccountProvider(provider.metadata as AzureAccountProviderMetadata,
simpleTokenCache, this._context, this.clientApplication, this._uriEventHandler, this._authLibrary, isSaw);
this._accountProviders[provider.metadata.id] = accountProvider;
@@ -198,6 +184,27 @@ export class AzureAccountProviderService implements vscode.Disposable {
}
}
private getLoggerCallback(): ILoggerCallback {
return (level: number, message: string, containsPii: boolean) => {
if (!containsPii) {
switch (level) {
case MsalLogLevel.Error:
Logger.error(message);
break;
case MsalLogLevel.Info:
Logger.info(message);
break;
case MsalLogLevel.Verbose:
default:
Logger.verbose(message);
break;
}
} else {
Logger.verbose(message);
}
}
}
private async unregisterAccountProvider(provider: ProviderSettings): Promise<void> {
try {
this._accountDisposals[provider.metadata.id].dispose();

View File

@@ -6,7 +6,7 @@ import * as keytarType from 'keytar';
import { join, parse } from 'path';
import { FileDatabase } from './utils/fileDatabase';
import * as azdata from 'azdata';
import * as crypto from 'crypto';
import { FileEncryptionHelper } from './utils/fileEncryptionHelper';
function getSystemKeytar(): Keytar | undefined {
try {
@@ -25,42 +25,8 @@ const separator = '§';
async function getFileKeytar(filePath: string, credentialService: azdata.CredentialProvider): Promise<Keytar | undefined> {
const fileName = parse(filePath).base;
const iv = await credentialService.readCredential(`${fileName}-iv`);
const key = await credentialService.readCredential(`${fileName}-key`);
let ivBuffer: Buffer;
let keyBuffer: Buffer;
if (!iv?.password || !key?.password) {
ivBuffer = crypto.randomBytes(16);
keyBuffer = crypto.randomBytes(32);
try {
await credentialService.saveCredential(`${fileName}-iv`, ivBuffer.toString('hex'));
await credentialService.saveCredential(`${fileName}-key`, keyBuffer.toString('hex'));
} catch (ex) {
console.log(ex);
}
} else {
ivBuffer = Buffer.from(iv.password, 'hex');
keyBuffer = Buffer.from(key.password, 'hex');
}
const fileSaver = async (content: string): Promise<string> => {
const cipherIv = crypto.createCipheriv('aes-256-gcm', keyBuffer, ivBuffer);
return `${cipherIv.update(content, 'utf8', 'hex')}${cipherIv.final('hex')}%${cipherIv.getAuthTag().toString('hex')}`;
};
const fileOpener = async (content: string): Promise<string> => {
const decipherIv = crypto.createDecipheriv('aes-256-gcm', keyBuffer, ivBuffer);
const split = content.split('%');
if (split.length !== 2) {
throw new Error('File didn\'t contain the auth tag.');
}
decipherIv.setAuthTag(Buffer.from(split[1], 'hex'));
return `${decipherIv.update(split[0], 'hex', 'utf8')}${decipherIv.final('utf8')}`;
};
const db = new FileDatabase(filePath, fileOpener, fileSaver);
const fileEncryptionHelper: FileEncryptionHelper = new FileEncryptionHelper(credentialService, fileName);
const db = new FileDatabase(filePath, fileEncryptionHelper.fileOpener, fileEncryptionHelper.fileSaver);
await db.initialize();
const fileKeytar: Keytar = {
@@ -94,6 +60,7 @@ async function getFileKeytar(filePath: string, credentialService: azdata.Credent
return fileKeytar;
}
export type Keytar = {
getPassword: typeof keytarType['getPassword'];
setPassword: typeof keytarType['setPassword'];
@@ -110,9 +77,7 @@ export class SimpleTokenCache {
private readonly userStoragePath: string,
private readonly forceFileStorage: boolean = false,
private readonly credentialService: azdata.CredentialProvider,
) {
}
) { }
async init(): Promise<void> {
this.serviceName = this.serviceName.replace(/-/g, '_');

View File

@@ -88,7 +88,6 @@ export class FileDatabase {
this.isDirty = true;
}
public async initialize(): Promise<void> {
this.isInitialized = true;
this.saveInterval = setInterval(() => this.save(), 20 * 1000);

View File

@@ -0,0 +1,56 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as azdata from 'azdata';
import * as crypto from 'crypto';
export class FileEncryptionHelper {
constructor(
private _credentialService: azdata.CredentialProvider,
private _fileName: string,
) { }
private _ivBuffer: Buffer | undefined;
private _keyBuffer: Buffer | undefined;
async init(): Promise<void> {
const iv = await this._credentialService.readCredential(`${this._fileName}-iv`);
const key = await this._credentialService.readCredential(`${this._fileName}-key`);
if (!iv?.password || !key?.password) {
this._ivBuffer = crypto.randomBytes(16);
this._keyBuffer = crypto.randomBytes(32);
try {
await this._credentialService.saveCredential(`${this._fileName}-iv`, this._ivBuffer.toString('hex'));
await this._credentialService.saveCredential(`${this._fileName}-key`, this._keyBuffer.toString('hex'));
} catch (ex) {
console.log(ex);
}
} else {
this._ivBuffer = Buffer.from(iv.password, 'hex');
this._keyBuffer = Buffer.from(key.password, 'hex');
}
}
fileSaver = async (content: string): Promise<string> => {
if (!this._keyBuffer || !this._ivBuffer) {
await this.init();
}
const cipherIv = crypto.createCipheriv('aes-256-gcm', this._keyBuffer!, this._ivBuffer!);
return `${cipherIv.update(content, 'utf8', 'hex')}${cipherIv.final('hex')}%${cipherIv.getAuthTag().toString('hex')}`;
};
fileOpener = async (content: string): Promise<string> => {
if (!this._keyBuffer || !this._ivBuffer) {
await this.init();
}
const decipherIv = crypto.createDecipheriv('aes-256-gcm', this._keyBuffer!, this._ivBuffer!);
const split = content.split('%');
if (split.length !== 2) {
throw new Error('File didn\'t contain the auth tag.');
}
decipherIv.setAuthTag(Buffer.from(split[1], 'hex'));
return `${decipherIv.update(split[0], 'hex', 'utf8')}${decipherIv.final('utf8')}`;
};
}

View File

@@ -0,0 +1,64 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ICachePlugin, TokenCacheContext } from '@azure/msal-node';
import { constants, promises as fsPromises } from 'fs';
import * as path from 'path';
import { Logger } from '../../utils/Logger';
export class MsalCachePluginProvider {
constructor(
private readonly _serviceName: string,
private readonly _msalFilePath: string
) {
this._msalFilePath = path.join(this._msalFilePath, this._serviceName);
this._serviceName = this._serviceName.replace(/-/, '_');
Logger.verbose(`MsalCachePluginProvider: Using cache path ${_msalFilePath} and serviceName ${_serviceName}`);
}
public getCachePlugin(): ICachePlugin {
const beforeCacheAccess = async (cacheContext: TokenCacheContext): Promise<void> => {
let exists = true;
try {
await fsPromises.access(this._msalFilePath, constants.R_OK | constants.W_OK);
} catch {
exists = false;
}
if (exists) {
try {
const cache = await fsPromises.readFile(this._msalFilePath, { encoding: 'utf8' });
cacheContext.tokenCache.deserialize(cache);
Logger.verbose(`MsalCachePlugin: Token read from cache successfully.`);
} catch (e) {
Logger.error(`MsalCachePlugin: Failed to read from cache file. ${e}`);
throw e;
}
}
};
const afterCacheAccess = async (cacheContext: TokenCacheContext): Promise<void> => {
if (cacheContext.cacheHasChanged) {
try {
const data = cacheContext.tokenCache.serialize();
await fsPromises.writeFile(this._msalFilePath, data, { encoding: 'utf8' });
Logger.verbose(`MsalCachePlugin: Token written to cache successfully.`);
} catch (e) {
Logger.error(`MsalCachePlugin: Failed to write to cache file. ${e}`);
throw e;
}
}
};
// This is an implementation of ICachePlugin that uses the beforeCacheAccess and afterCacheAccess callbacks to read and write to a file
// Ref https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-node-migration#enable-token-caching
// In future we should use msal-node-extensions to provide a secure storage of tokens, instead of implementing our own
// However - as of now this library does not come with pre-compiled native libraries that causes runtime issues
// Ref https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/3332
return {
beforeCacheAccess,
afterCacheAccess,
};
}
}

View File

@@ -51,8 +51,6 @@ export const S256_CODE_CHALLENGE_METHOD = 'S256';
export const SELECT_ACCOUNT = 'select_account';
export const ConfigFilePath = './cache.json'
export const Saw = 'saw';
export const ViewType = 'view';