/*--------------------------------------------------------------------------------------------- * 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 { Event, Emitter } from 'vs/base/common/event'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { Memento } from 'vs/workbench/common/memento'; import AccountStore from 'sql/platform/accounts/common/accountStore'; import { AccountDialogController } from 'sql/workbench/services/accountManagement/browser/accountDialogController'; import { AutoOAuthDialogController } from 'sql/workbench/services/accountManagement/browser/autoOAuthDialogController'; import { AccountProviderAddedEventParams, UpdateAccountListEventParams } from 'sql/platform/accounts/common/eventTypes'; import { IAccountManagementService } from 'sql/platform/accounts/common/interfaces'; import { Deferred } from 'sql/base/common/promise'; import { localize } from 'vs/nls'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { URI } from 'vs/base/common/uri'; import { values } from 'vs/base/common/collections'; import { ILogService } from 'vs/platform/log/common/log'; import { INotificationService, Severity, INotification } from 'vs/platform/notification/common/notification'; import { Action } from 'vs/base/common/actions'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ADAL_AUTH_LIBRARY, AuthLibrary, filterAccounts, MSAL_AUTH_LIBRARY } from 'sql/workbench/services/accountManagement/browser/accountDialog'; export class AccountManagementService implements IAccountManagementService { // CONSTANTS /////////////////////////////////////////////////////////// private static ACCOUNT_MEMENTO = 'AccountManagement'; // MEMBER VARIABLES //////////////////////////////////////////////////// public _providers: { [id: string]: AccountProviderWithMetadata } = {}; public _serviceBrand: undefined; private _accountStore: AccountStore; private _authLibrary: AuthLibrary; private _accountDialogController?: AccountDialogController; private _autoOAuthDialogController?: AutoOAuthDialogController; private _mementoContext?: Memento; protected readonly disposables = new DisposableStore(); private readonly configurationService: IConfigurationService; // EVENT EMITTERS ////////////////////////////////////////////////////// private _addAccountProviderEmitter: Emitter; public get addAccountProviderEvent(): Event { return this._addAccountProviderEmitter.event; } private _removeAccountProviderEmitter: Emitter; public get removeAccountProviderEvent(): Event { return this._removeAccountProviderEmitter.event; } private _updateAccountListEmitter: Emitter; public get updateAccountListEvent(): Event { return this._updateAccountListEmitter.event; } // CONSTRUCTOR ///////////////////////////////////////////////////////// constructor( @IInstantiationService private _instantiationService: IInstantiationService, @IStorageService private _storageService: IStorageService, @IClipboardService private _clipboardService: IClipboardService, @IOpenerService private _openerService: IOpenerService, @ILogService private readonly _logService: ILogService, @INotificationService private readonly _notificationService: INotificationService, @IConfigurationService configurationService: IConfigurationService ) { this._mementoContext = new Memento(AccountManagementService.ACCOUNT_MEMENTO, this._storageService); const mementoObj = this._mementoContext.getMemento(StorageScope.GLOBAL, StorageTarget.MACHINE); this._accountStore = this._instantiationService.createInstance(AccountStore, mementoObj); // Setup the event emitters this._addAccountProviderEmitter = new Emitter(); this._removeAccountProviderEmitter = new Emitter(); this._updateAccountListEmitter = new Emitter(); this.configurationService = configurationService; // Determine authentication library in use, to support filtering accounts respectively. // When this value is changed a restart is required so there isn't a need to dynamically update this value at runtime. this._authLibrary = this.configurationService.getValue('azure.authenticationLibrary'); _storageService.onWillSaveState(() => this.shutdown()); this.registerListeners(); } private get autoOAuthDialogController(): AutoOAuthDialogController { // If the add account dialog hasn't been defined, create a new one if (!this._autoOAuthDialogController) { this._autoOAuthDialogController = this._instantiationService.createInstance(AutoOAuthDialogController); } return this._autoOAuthDialogController; } // PUBLIC METHODS ////////////////////////////////////////////////////// /** * Called from an account provider (via extension host -> main thread interop) when an * account's properties have been updated (usually when the account goes stale). * @param updatedAccount Account with the updated properties */ public accountUpdated(updatedAccount: azdata.Account): Promise { let self = this; // 1) Update the account in the store // 2a) If the account was added, then the account provider incorrectly called this method. // Remove the account // 2b) If the account was modified, then update it in the local cache and notify any // listeners that the account provider's list changed // 3) Handle any errors return this.doWithProvider(updatedAccount.key.providerId, provider => { return self._accountStore.addOrUpdate(updatedAccount) .then(result => { if (result.accountAdded) { self._accountStore.remove(updatedAccount.key); return Promise.reject('Called with a new account!'); } if (result.accountModified) { self.spliceModifiedAccount(provider, result.changedAccount); self.fireAccountListUpdate(provider, false); } return Promise.resolve(); }); }); } /** * Asks the requested provider to prompt for an account * @param providerId ID of the provider to ask to prompt for an account * @return Promise to return an account */ public addAccount(providerId: string): Promise { const closeAction: Action = new Action('closeAddingAccount', localize('accountManagementService.close', "Close"), undefined, true); const loginNotification: INotification = { severity: Severity.Info, message: localize('loggingIn', "Adding account..."), progress: { infinite: true }, actions: { primary: [closeAction] } }; return this.doWithProvider(providerId, async (provider) => { const notificationHandler = this._notificationService.notify(loginNotification); try { let account = await provider.provider.prompt(); if (this.isCanceledResult(account)) { return; } let result = await this._accountStore.addOrUpdate(account); if (!result) { this._logService.error('adding account failed'); throw Error('Adding account failed, check Azure Accounts log for more info.') } if (result.accountAdded) { // Add the account to the list provider.accounts.push(result.changedAccount); } if (result.accountModified) { this.spliceModifiedAccount(provider, result.changedAccount); } this.fireAccountListUpdate(provider, result.accountAdded); } finally { notificationHandler.close(); } }); } /** * Adds an account to the account store without prompting the user * @param account account to add */ public addAccountWithoutPrompt(account: azdata.Account): Promise { return this.doWithProvider(account.key.providerId, async (provider) => { let result = await this._accountStore.addOrUpdate(account); if (!result) { this._logService.error('adding account failed'); } if (result.accountAdded) { // Add the account to the list provider.accounts.push(result.changedAccount); } if (result.accountModified) { this.spliceModifiedAccount(provider, result.changedAccount); } this.fireAccountListUpdate(provider, result.accountAdded); }); } private isCanceledResult(result: azdata.Account | azdata.PromptFailedResult): result is azdata.PromptFailedResult { return (result).canceled; } /** * Asks the requested provider to refresh an account * @param account account to refresh * @return Promise to return an account */ public refreshAccount(account: azdata.Account): Promise { let self = this; return this.doWithProvider(account.key.providerId, async (provider) => { let refreshedAccount = await provider.provider.refresh(account); if (self.isCanceledResult(refreshedAccount)) { // Pattern here is to throw if this fails. Handled upstream. throw new Error(localize('refreshFailed', "Refresh account was canceled by the user")); } else { account = refreshedAccount; } let result = await self._accountStore.addOrUpdate(account); if (result.accountAdded) { // Add the account to the list provider.accounts.push(result.changedAccount!); } if (result.accountModified) { // Find the updated account and splice the updated on in let indexToRemove: number = provider.accounts.findIndex(account => { return account.key.accountId === result.changedAccount!.key.accountId; }); if (indexToRemove >= 0) { provider.accounts.splice(indexToRemove, 1, result.changedAccount!); } } self.fireAccountListUpdate(provider, result.accountAdded); return result.changedAccount!; }); } /** * Retrieves metadata of all providers that have been registered * @returns Registered account providers */ public getAccountProviderMetadata(): Promise { return Promise.resolve(values(this._providers).map(provider => provider.metadata)); } /** * Retrieves the accounts that belong to a specific provider * @param providerId ID of the provider the returned accounts belong to * @returns Promise to return a list of accounts */ public getAccountsForProvider(providerId: string): Promise { let self = this; // 1) Get the accounts from the store // 2) Filter the accounts based on the auth library // 3) Update our local cache of accounts return this.doWithProvider(providerId, provider => { return self._accountStore.getAccountsByProvider(provider.metadata.id) .then(accounts => this._authLibrary ? filterAccounts(accounts, this._authLibrary) : accounts) .then(accounts => { self._providers[providerId].accounts = accounts; return accounts; }); }); } /** * Retrieves all the accounts registered with ADS based on auth library in use. */ public getAccounts(): Promise { return this._accountStore.getAllAccounts() .then(accounts => this._authLibrary ? filterAccounts(accounts, this._authLibrary) : accounts); } /** * Generates a security token by asking the account's provider * @param account Account to generate security token for * @param resource The resource to get the security token for * @return Promise to return the security token */ public getSecurityToken(account: azdata.Account, resource: azdata.AzureResource): Promise<{} | undefined> { return this.doWithProvider(account.key.providerId, provider => { return Promise.resolve(provider.provider.getSecurityToken(account, resource)); }); } /** * Generates a security token by asking the account's provider * @param account Account to generate security token for * @param tenant Tenant to generate security token for * @param resource The resource to get the security token for * @return Promise to return the security token */ public getAccountSecurityToken(account: azdata.Account, tenant: string, resource: azdata.AzureResource): Promise { return this.doWithProvider(account.key.providerId, provider => { return Promise.resolve(provider.provider.getAccountSecurityToken(account, tenant, resource)); }); } /** * Removes an account from the account store and clears sensitive data in the provider * @param accountKey Key for the account to remove * @returns Promise with result of account removal, true if account was * removed, false otherwise. */ public removeAccount(accountKey: azdata.AccountKey): Promise { // Step 1) Remove the account // Step 2) Clear the sensitive data from the provider (regardless of whether the account was removed) // Step 3) Update the account cache and fire an event return this.doWithProvider(accountKey.providerId, async provider => { const result = await this._accountStore.remove(accountKey); await provider.provider.clear(accountKey); if (!result) { return result; } let indexToRemove: number = provider.accounts.findIndex(account => { return account.key.accountId === accountKey.accountId; }); if (indexToRemove >= 0) { provider.accounts.splice(indexToRemove, 1); this.fireAccountListUpdate(provider, false); } return result; }); } /** * Removes all registered accounts */ public async removeAccounts(): Promise { const accounts = await this.getAccounts(); if (accounts.length === 0) { return false; } let finalResult = true; for (const account of accounts) { try { const removeResult = await this.removeAccount(account.key); if (removeResult === false) { this._logService.info('Error when removing %s.', account.key); finalResult = false; } } catch (ex) { this._logService.error('Error when removing an account %s. Exception: %s', account.key, JSON.stringify(ex)); } } return finalResult; } // UI METHODS ////////////////////////////////////////////////////////// /** * Opens the account list dialog * @return Promise that finishes when the account list dialog closes */ public openAccountListDialog(): Promise { let self = this; return new Promise((resolve, reject) => { try { // If the account list dialog hasn't been defined, create a new one if (!self._accountDialogController) { self._accountDialogController = self._instantiationService.createInstance(AccountDialogController); } self._accountDialogController.openAccountDialog(); self._accountDialogController.accountDialog!.onCloseEvent(resolve); } catch (e) { reject(e); } }); } /** * Begin auto OAuth device code open add account dialog * @return Promise that finishes when the account list dialog opens */ public beginAutoOAuthDeviceCode(providerId: string, title: string, message: string, userCode: string, uri: string): Promise { let self = this; return this.doWithProvider(providerId, provider => { return self.autoOAuthDialogController.openAutoOAuthDialog(providerId, title, message, userCode, uri); }); } /** * End auto OAuth Device code closes add account dialog */ public endAutoOAuthDeviceCode(): void { this.autoOAuthDialogController.closeAutoOAuthDialog(); } /** * Called from the UI when a user cancels the auto OAuth dialog */ public cancelAutoOAuthDeviceCode(providerId: string): void { void this.doWithProvider(providerId, provider => Promise.resolve(provider.provider.autoOAuthCancelled())) .then( // Swallow errors undefined, err => { this._logService.warn(`Error when cancelling auto OAuth: ${err}`); } ) .then(() => this.autoOAuthDialogController.closeAutoOAuthDialog()); } /** * Copy the user code to the clipboard and open a browser to the verification URI */ public async copyUserCodeAndOpenBrowser(userCode: string, uri: string): Promise { await this._clipboardService.writeText(userCode); await this._openerService.open(URI.parse(uri)); } private async _registerProvider(providerMetadata: azdata.AccountProviderMetadata, provider: azdata.AccountProvider): Promise { this._providers[providerMetadata.id] = { metadata: providerMetadata, provider: provider, accounts: [] }; const accounts = await this._accountStore.getAccountsByProvider(providerMetadata.id); const updatedAccounts = await provider.initialize(accounts); // Don't add the accounts that are explicitly marked to be deleted to the cache. this._providers[providerMetadata.id].accounts = updatedAccounts.filter(s => !s.delete); const writePromises = updatedAccounts.map(async (account) => { if (account.delete === true) { return this._accountStore.remove(account.key); } return this._accountStore.addOrUpdate(account); }); await Promise.all(writePromises); const p = this._providers[providerMetadata.id]; this._addAccountProviderEmitter.fire({ addedProvider: p.metadata, initialAccounts: p.accounts.slice(0) // Slice here to make sure no one can modify our cache }); // Notify listeners that the account has been updated this.fireAccountListUpdate(p, false); } // SERVICE MANAGEMENT METHODS ////////////////////////////////////////// /** * Called by main thread to register an account provider from extension * @param providerMetadata Metadata of the provider that is being registered * @param provider References to the methods of the provider */ public registerProvider(providerMetadata: azdata.AccountProviderMetadata, provider: azdata.AccountProvider): Promise { return this._registerProvider(providerMetadata, provider); } /** * Handler for when shutdown of the application occurs. Writes out the memento. */ private shutdown(): void { if (this._mementoContext) { this._mementoContext.saveMemento(); } } public unregisterProvider(providerMetadata: azdata.AccountProviderMetadata): void { // Delete this account provider delete this._providers[providerMetadata.id]; // Alert our listeners that we've removed a provider this._removeAccountProviderEmitter.fire(providerMetadata); } // TODO: Support for orphaned accounts (accounts with no provider) // PRIVATE HELPERS ///////////////////////////////////////////////////// private doWithProvider(providerId: string, op: (provider: AccountProviderWithMetadata) => Promise): Promise { let provider = this._providers[providerId]; if (!provider) { // If the provider doesn't already exist wait until it gets registered let deferredPromise = new Deferred(); let toDispose = this.addAccountProviderEvent(params => { if (params.addedProvider.id === providerId) { toDispose.dispose(); deferredPromise.resolve(op(this._providers[providerId])); } }); return deferredPromise; } return op(provider); } private fireAccountListUpdate(provider: AccountProviderWithMetadata, sort: boolean) { // Step 1) Get and sort the list if (sort) { provider.accounts.sort((a: azdata.Account, b: azdata.Account) => { if (a.displayInfo.displayName < b.displayInfo.displayName) { return -1; } if (a.displayInfo.displayName > b.displayInfo.displayName) { return 1; } return 0; }); } const authLibrary: AuthLibrary = this.configurationService.getValue('azure.authenticationLibrary'); let updatedAccounts: azdata.Account[] if (authLibrary) { updatedAccounts = filterAccounts(provider.accounts, authLibrary); } // Step 2) Fire the event let eventArg: UpdateAccountListEventParams = { providerId: provider.metadata.id, accountList: updatedAccounts ?? provider.accounts }; this._updateAccountListEmitter.fire(eventArg); } private spliceModifiedAccount(provider: AccountProviderWithMetadata, modifiedAccount: azdata.Account) { // Find the updated account and splice the updated one in let indexToRemove: number = provider.accounts.findIndex(account => { return account.key.accountId === modifiedAccount.key.accountId; }); if (indexToRemove >= 0) { provider.accounts.splice(indexToRemove, 1, modifiedAccount); } } private registerListeners(): void { this.disposables.add(this.configurationService.onDidChangeConfiguration(async e => { if (e.affectsConfiguration('azure.authenticationLibrary')) { const authLibrary: AuthLibrary = this.configurationService.getValue('azure.authenticationLibrary') ?? MSAL_AUTH_LIBRARY; let accounts = await this._accountStore.getAllAccounts(); if (accounts) { let updatedAccounts = await this.filterAndMergeAccounts(accounts, authLibrary); let eventArg: UpdateAccountListEventParams; if (updatedAccounts.length > 0) { updatedAccounts.forEach(account => { if (account.key.authLibrary === MSAL_AUTH_LIBRARY) { account.isStale = false; } }); eventArg = { providerId: updatedAccounts[0].key.providerId, accountList: updatedAccounts }; } else { // default to public cloud if no accounts eventArg = { providerId: 'azure_publicCloud', accountList: updatedAccounts }; } this._updateAccountListEmitter.fire(eventArg); } } })); } // Filters and merges accounts from both authentication libraries private async filterAndMergeAccounts(accounts: azdata.Account[], currentAuthLibrary: AuthLibrary): Promise { // Fetch accounts for alternate authenticationLibrary const altLibrary = currentAuthLibrary === MSAL_AUTH_LIBRARY ? ADAL_AUTH_LIBRARY : MSAL_AUTH_LIBRARY; const altLibraryAccounts = filterAccounts(accounts, altLibrary); // Fetch accounts for current authenticationLibrary const currentLibraryAccounts = filterAccounts(accounts, currentAuthLibrary); // In the list of alternate accounts, check if the accounts are present in the current library cache, // if not, add the account and mark it stale. The original account is marked as taken so its not picked again. for (let account of altLibraryAccounts) { await this.removeAccount(account.key); if (currentLibraryAccounts.find(a => account.displayInfo.email === a.displayInfo.email)) { continue; } else { // TODO: Refresh access token for the account if feasible. account.isStale = true; account.key.authLibrary = currentAuthLibrary; currentLibraryAccounts.push(account); await this.addAccountWithoutPrompt(account); } } return currentLibraryAccounts; } } /** * Joins together an account provider, its metadata, and its accounts, used in the provider list */ export interface AccountProviderWithMetadata { metadata: azdata.AccountProviderMetadata; provider: azdata.AccountProvider; accounts: azdata.Account[]; }