mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-04-01 09:30:31 -04:00
MSAL cache encryption + log improvements (#22335)
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
||||
import { Deferred } from '../interfaces';
|
||||
import * as url from 'url';
|
||||
import * as Constants from '../../constants';
|
||||
import { SimpleTokenCache } from '../simpleTokenCache';
|
||||
import { SimpleTokenCache } from '../utils/simpleTokenCache';
|
||||
import { MemoryDatabase } from '../utils/memoryDatabase';
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AzureAccountProviderMetadata, AzureAuthType, Resource, Tenant } from 'a
|
||||
import { Deferred } from '../interfaces';
|
||||
import * as vscode from 'vscode';
|
||||
import * as crypto from 'crypto';
|
||||
import { SimpleTokenCache } from '../simpleTokenCache';
|
||||
import { SimpleTokenCache } from '../utils/simpleTokenCache';
|
||||
import { SimpleWebServer } from '../utils/simpleWebServer';
|
||||
import { AzureAuthError } from './azureAuthError';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from 'azurecore';
|
||||
import { Deferred } from '../interfaces';
|
||||
import { AuthenticationResult, DeviceCodeRequest, PublicClientApplication } from '@azure/msal-node';
|
||||
import { SimpleTokenCache } from '../simpleTokenCache';
|
||||
import { SimpleTokenCache } from '../utils/simpleTokenCache';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
const localize = nls.loadMessageBundle();
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'azurecore';
|
||||
import { Deferred } from './interfaces';
|
||||
import { AuthenticationResult, PublicClientApplication } from '@azure/msal-node';
|
||||
import { SimpleTokenCache } from './simpleTokenCache';
|
||||
import { SimpleTokenCache } from './utils/simpleTokenCache';
|
||||
import { Logger } from '../utils/Logger';
|
||||
import { MultiTenantTokenResponse, Token, AzureAuth } from './auths/azureAuth';
|
||||
import { AzureAuthCodeGrant } from './auths/azureAuthCodeGrant';
|
||||
@@ -105,7 +105,7 @@ export class AzureAccountProvider implements azdata.AccountProvider, vscode.Disp
|
||||
|
||||
private async _initialize(storedAccounts: AzureAccount[]): Promise<AzureAccount[]> {
|
||||
const accounts: AzureAccount[] = [];
|
||||
console.log(`Initializing stored accounts ${JSON.stringify(accounts)}`);
|
||||
Logger.verbose(`Initializing stored accounts ${JSON.stringify(accounts)}`);
|
||||
const updatedAccounts = filterAccounts(storedAccounts, this.authLibrary);
|
||||
for (let account of updatedAccounts) {
|
||||
const azureAuth = this.getAuthMethod(account);
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as azdata from 'azdata';
|
||||
import * as events from 'events';
|
||||
import * as nls from 'vscode-nls';
|
||||
import * as vscode from 'vscode';
|
||||
import { SimpleTokenCache } from './simpleTokenCache';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { SimpleTokenCache } from './utils/simpleTokenCache';
|
||||
import providerSettings from './providerSettings';
|
||||
import { AzureAccountProvider as AzureAccountProvider } from './azureAccountProvider';
|
||||
import { AzureAccountProviderMetadata } from 'azurecore';
|
||||
@@ -144,8 +146,8 @@ export class AzureAccountProviderService implements vscode.Disposable {
|
||||
const isSaw: boolean = vscode.env.appName.toLowerCase().indexOf(Constants.Saw) > 0;
|
||||
const noSystemKeychain = vscode.workspace.getConfiguration(Constants.AzureSection).get<boolean>(Constants.NoSystemKeyChainSection);
|
||||
const tokenCacheKey = `azureTokenCache-${provider.metadata.id}`;
|
||||
// Hardcode the MSAL Cache Key so there is only one cache location
|
||||
const tokenCacheKeyMsal = `azureTokenCacheMsal-azure_publicCloud`;
|
||||
const tokenCacheKeyMsal = Constants.MSALCacheName;
|
||||
await this.clearOldCacheIfExists();
|
||||
try {
|
||||
if (!this._credentialProvider) {
|
||||
throw new Error('Credential provider not registered');
|
||||
@@ -156,7 +158,7 @@ export class AzureAccountProviderService implements vscode.Disposable {
|
||||
await simpleTokenCache.init();
|
||||
|
||||
// MSAL Cache Plugin
|
||||
this._cachePluginProvider = new MsalCachePluginProvider(tokenCacheKeyMsal, this._userStoragePath);
|
||||
this._cachePluginProvider = new MsalCachePluginProvider(tokenCacheKeyMsal, this._userStoragePath, this._credentialProvider);
|
||||
|
||||
const msalConfiguration: Configuration = {
|
||||
auth: {
|
||||
@@ -185,6 +187,22 @@ export class AzureAccountProviderService implements vscode.Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears old cache file that is no longer needed on system.
|
||||
*/
|
||||
private async clearOldCacheIfExists(): Promise<void> {
|
||||
let filePath = path.join(this._userStoragePath, Constants.oldMsalCacheFileName);
|
||||
try {
|
||||
await fsPromises.access(filePath);
|
||||
await fsPromises.unlink('file:' + filePath);
|
||||
Logger.verbose(`Old cache file removed successfully.`);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
Logger.verbose(`Error occurred while removing old cache file: ${e}`);
|
||||
} // else file doesn't exist.
|
||||
}
|
||||
}
|
||||
|
||||
private getLoggerCallback(): ILoggerCallback {
|
||||
return (level: number, message: string, containsPii: boolean) => {
|
||||
if (!containsPii) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { promises as fs, constants as fsConstants } from 'fs';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
export type ReadWriteHook = (contents: string) => Promise<string>;
|
||||
const noOpHook: ReadWriteHook = async (contents): Promise<string> => {
|
||||
@@ -97,7 +98,7 @@ export class FileDatabase {
|
||||
fileContents = await fs.readFile(this.dbPath, { encoding: 'utf8' });
|
||||
fileContents = await this.readHook(fileContents);
|
||||
} catch (ex) {
|
||||
console.log(`file db does not exist ${ex}`);
|
||||
Logger.error(`Error occurred when initializing File Database from file system cache, ADAL cache will be reset: ${ex}`);
|
||||
await this.createFile();
|
||||
this.db = {};
|
||||
this.isDirty = true;
|
||||
@@ -107,7 +108,7 @@ export class FileDatabase {
|
||||
try {
|
||||
this.db = JSON.parse(fileContents);
|
||||
} catch (ex) {
|
||||
console.log(`DB was corrupted, resetting it ${ex}`);
|
||||
Logger.error(`Error occurred when reading file database contents as JSON, ADAL cache will be reset: ${ex}`);
|
||||
await this.createFile();
|
||||
this.db = {};
|
||||
}
|
||||
@@ -139,7 +140,7 @@ export class FileDatabase {
|
||||
|
||||
this.isDirty = false;
|
||||
} catch (ex) {
|
||||
console.log(`File saving is erroring! ${ex}`);
|
||||
Logger.error(`Error occurred while saving cache contents to file storage, this may cause issues with ADAL cache persistence: ${ex}`);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
@@ -3,32 +3,50 @@
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as azdata from 'azdata';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
import * as vscode from 'vscode';
|
||||
import { AuthLibrary } from '../../constants';
|
||||
import * as LocalizedConstants from '../../localizedConstants';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
export class FileEncryptionHelper {
|
||||
constructor(
|
||||
private _credentialService: azdata.CredentialProvider,
|
||||
private _fileName: string,
|
||||
) { }
|
||||
private readonly _authLibrary: AuthLibrary,
|
||||
private readonly _credentialService: azdata.CredentialProvider,
|
||||
protected readonly _fileName: string
|
||||
) {
|
||||
this._algorithm = this._authLibrary === AuthLibrary.MSAL ? 'aes-256-cbc' : 'aes-256-gcm';
|
||||
this._bufferEncoding = this._authLibrary === AuthLibrary.MSAL ? 'utf16le' : 'hex';
|
||||
this._binaryEncoding = this._authLibrary === AuthLibrary.MSAL ? 'base64' : 'hex';
|
||||
}
|
||||
|
||||
private _algorithm: string;
|
||||
private _bufferEncoding: BufferEncoding;
|
||||
private _binaryEncoding: crypto.HexBase64BinaryEncoding;
|
||||
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) {
|
||||
public async init(): Promise<void> {
|
||||
|
||||
const ivCredId = `${this._fileName}-iv`;
|
||||
const keyCredId = `${this._fileName}-key`;
|
||||
|
||||
const iv = await this.readEncryptionKey(ivCredId);
|
||||
const key = await this.readEncryptionKey(keyCredId);
|
||||
|
||||
if (!iv || !key) {
|
||||
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);
|
||||
|
||||
if (!await this.saveEncryptionKey(ivCredId, this._ivBuffer.toString(this._bufferEncoding))
|
||||
|| !await this.saveEncryptionKey(keyCredId, this._keyBuffer.toString(this._bufferEncoding))) {
|
||||
Logger.error(`Encryption keys could not be saved in credential store, this will cause access token persistence issues.`);
|
||||
await this.showCredSaveErrorOnWindows();
|
||||
}
|
||||
} else {
|
||||
this._ivBuffer = Buffer.from(iv.password, 'hex');
|
||||
this._keyBuffer = Buffer.from(key.password, 'hex');
|
||||
this._ivBuffer = Buffer.from(iv, this._bufferEncoding);
|
||||
this._keyBuffer = Buffer.from(key, this._bufferEncoding);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,21 +54,68 @@ export class FileEncryptionHelper {
|
||||
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')}`;
|
||||
};
|
||||
const cipherIv = crypto.createCipheriv(this._algorithm, this._keyBuffer!, this._ivBuffer!);
|
||||
let cipherText = `${cipherIv.update(content, 'utf8', this._binaryEncoding)}${cipherIv.final(this._binaryEncoding)}`;
|
||||
if (this._authLibrary === AuthLibrary.ADAL) {
|
||||
cipherText += `%${(cipherIv as crypto.CipherGCM).getAuthTag().toString(this._binaryEncoding)}`;
|
||||
}
|
||||
return cipherText;
|
||||
}
|
||||
|
||||
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.');
|
||||
let plaintext = content;
|
||||
const decipherIv = crypto.createDecipheriv(this._algorithm, this._keyBuffer!, this._ivBuffer!);
|
||||
if (this._authLibrary === AuthLibrary.ADAL) {
|
||||
const split = content.split('%');
|
||||
if (split.length !== 2) {
|
||||
throw new Error('File didn\'t contain the auth tag.');
|
||||
}
|
||||
(decipherIv as crypto.DecipherGCM).setAuthTag(Buffer.from(split[1], this._binaryEncoding));
|
||||
plaintext = split[0];
|
||||
}
|
||||
decipherIv.setAuthTag(Buffer.from(split[1], 'hex'));
|
||||
return `${decipherIv.update(split[0], 'hex', 'utf8')}${decipherIv.final('utf8')}`;
|
||||
};
|
||||
return `${decipherIv.update(plaintext, this._binaryEncoding, 'utf8')}${decipherIv.final('utf8')}`;
|
||||
}
|
||||
|
||||
protected async readEncryptionKey(credentialId: string): Promise<string | undefined> {
|
||||
return (await this._credentialService.readCredential(credentialId))?.password;
|
||||
}
|
||||
|
||||
protected async saveEncryptionKey(credentialId: string, password: string): Promise<boolean> {
|
||||
let status: boolean = false;
|
||||
try {
|
||||
await this._credentialService.saveCredential(credentialId, password)
|
||||
.then((result) => {
|
||||
status = result;
|
||||
if (result) {
|
||||
Logger.info(`FileEncryptionHelper: Successfully saved encryption key ${credentialId} for ${this._authLibrary} persistent cache encryption in system credential store.`);
|
||||
}
|
||||
}, (e => {
|
||||
throw Error(`FileEncryptionHelper: Could not save encryption key: ${credentialId}: ${e}`);
|
||||
}));
|
||||
} catch (ex) {
|
||||
if (os.platform() === 'win32') {
|
||||
Logger.error(`FileEncryptionHelper: Please try cleaning saved credentials from Windows Credential Manager created by Azure Data Studio to allow creating new credentials.`);
|
||||
}
|
||||
Logger.error(ex);
|
||||
throw ex;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
protected async showCredSaveErrorOnWindows(): Promise<void> {
|
||||
if (os.platform() === 'win32') {
|
||||
await vscode.window.showWarningMessage(LocalizedConstants.azureCredStoreSaveFailedError,
|
||||
LocalizedConstants.reloadChoice, LocalizedConstants.cancel)
|
||||
.then(async (selection) => {
|
||||
if (selection === LocalizedConstants.reloadChoice) {
|
||||
await vscode.commands.executeCommand('workbench.action.reloadWindow');
|
||||
}
|
||||
}, error => {
|
||||
Logger.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,23 @@ import { promises as fsPromises } from 'fs';
|
||||
|
||||
import * as lockFile from 'lockfile';
|
||||
import * as path from 'path';
|
||||
import { AccountsClearTokenCacheCommand } from '../../constants';
|
||||
import * as azdata from 'azdata';
|
||||
import { AccountsClearTokenCacheCommand, AuthLibrary } from '../../constants';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
import { FileEncryptionHelper } from './fileEncryptionHelper';
|
||||
|
||||
export class MsalCachePluginProvider {
|
||||
constructor(
|
||||
private readonly _serviceName: string,
|
||||
private readonly _msalFilePath: string,
|
||||
private readonly _credentialService: azdata.CredentialProvider
|
||||
) {
|
||||
this._msalFilePath = path.join(this._msalFilePath, this._serviceName);
|
||||
this._serviceName = this._serviceName.replace(/-/, '_');
|
||||
Logger.verbose(`MsalCachePluginProvider: Using cache path ${_msalFilePath} and serviceName ${_serviceName}`);
|
||||
this._fileEncryptionHelper = new FileEncryptionHelper(AuthLibrary.MSAL, this._credentialService, this._serviceName);
|
||||
}
|
||||
|
||||
private _lockTaken: boolean = false;
|
||||
private _fileEncryptionHelper: FileEncryptionHelper;
|
||||
|
||||
private getLockfilePath(): string {
|
||||
return this._msalFilePath + '.lockfile';
|
||||
@@ -33,8 +36,9 @@ export class MsalCachePluginProvider {
|
||||
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(cache);
|
||||
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.
|
||||
@@ -49,7 +53,8 @@ export class MsalCachePluginProvider {
|
||||
}
|
||||
else {
|
||||
Logger.error(`MsalCachePlugin: Failed to read from cache file: ${e}`);
|
||||
throw e;
|
||||
Logger.verbose(`MsalCachePlugin: Error occurred when trying to read cache file, file contents will be cleared: ${e.message}`);
|
||||
await fsPromises.writeFile(this._msalFilePath, '', { encoding: 'utf8' });
|
||||
}
|
||||
} finally {
|
||||
lockFile.unlockSync(lockFilePath);
|
||||
@@ -62,7 +67,8 @@ export class MsalCachePluginProvider {
|
||||
await this.waitAndLock(lockFilePath);
|
||||
try {
|
||||
const data = cacheContext.tokenCache.serialize();
|
||||
await fsPromises.writeFile(this._msalFilePath, data, { encoding: 'utf8' });
|
||||
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}`);
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as keytarType from 'keytar';
|
||||
import { join, parse } from 'path';
|
||||
import { FileDatabase } from './utils/fileDatabase';
|
||||
import { FileDatabase } from './fileDatabase';
|
||||
import * as azdata from 'azdata';
|
||||
import { FileEncryptionHelper } from './utils/fileEncryptionHelper';
|
||||
import { FileEncryptionHelper } from './fileEncryptionHelper';
|
||||
import { AuthLibrary } from '../../constants';
|
||||
|
||||
function getSystemKeytar(): Keytar | undefined {
|
||||
try {
|
||||
@@ -25,7 +26,7 @@ const separator = '§';
|
||||
|
||||
async function getFileKeytar(filePath: string, credentialService: azdata.CredentialProvider): Promise<Keytar | undefined> {
|
||||
const fileName = parse(filePath).base;
|
||||
const fileEncryptionHelper: FileEncryptionHelper = new FileEncryptionHelper(credentialService, fileName);
|
||||
const fileEncryptionHelper: FileEncryptionHelper = new FileEncryptionHelper(AuthLibrary.ADAL, credentialService, fileName);
|
||||
const db = new FileDatabase(filePath, fileEncryptionHelper.fileOpener, fileEncryptionHelper.fileSaver);
|
||||
await db.initialize();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import * as http from 'http';
|
||||
import * as url from 'url';
|
||||
import { AddressInfo } from 'net';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
export type WebHandler = (req: http.IncomingMessage, reqUrl: url.UrlWithParsedQuery, res: http.ServerResponse) => void;
|
||||
|
||||
@@ -24,7 +25,7 @@ export class SimpleWebServer {
|
||||
const time = new Date().getTime();
|
||||
|
||||
if (time - this.lastUsed > this.autoShutoffTimer) {
|
||||
console.log('Shutting off webserver...');
|
||||
Logger.verbose('Shutting off webserver...');
|
||||
this.shutdown().catch(console.error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
Reference in New Issue
Block a user