diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index c397b1ac824..1f58ce0fee8 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -115,6 +115,7 @@ export interface IProductConfiguration { readonly languageExtensionTips?: readonly string[]; readonly trustedExtensionUrlPublicKeys?: IStringDictionary; readonly trustedExtensionAuthAccess?: string[] | IStringDictionary; + readonly inheritAuthAccountPreference?: IStringDictionary; readonly trustedExtensionProtocolHandlers?: readonly string[]; readonly commandPaletteSuggestedCommandIds?: string[]; diff --git a/src/vs/workbench/api/browser/mainThreadAuthentication.ts b/src/vs/workbench/api/browser/mainThreadAuthentication.ts index 89a6f10f562..9725664e76f 100644 --- a/src/vs/workbench/api/browser/mainThreadAuthentication.ts +++ b/src/vs/workbench/api/browser/mainThreadAuthentication.ts @@ -89,6 +89,10 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu this._register(this.authenticationService.onDidChangeSessions(e => { this._proxy.$onDidChangeAuthenticationSessions(e.providerId, e.label); })); + this._register(this.authenticationExtensionsService.onDidChangeAccountPreference(e => { + const providerInfo = this.authenticationService.getProvider(e.providerId); + this._proxy.$onDidChangeAuthenticationSessions(providerInfo.id, providerInfo.label, e.extensionIds); + })); } async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): Promise { @@ -203,21 +207,19 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu if (options.clearSessionPreference) { // Clearing the session preference is usually paired with createIfNone, so just remove the preference and // defer to the rest of the logic in this function to choose the session. - this.authenticationExtensionsService.removeSessionPreference(providerId, extensionId, scopes); + this._removeAccountPreference(extensionId, providerId, scopes); } + const matchingAccountPreferenceSession = this._getAccountPreference(extensionId, providerId, scopes, sessions); + // Check if the sessions we have are valid if (!options.forceNewSession && sessions.length) { - if (provider.supportsMultipleAccounts) { - // If we have an existing session preference, use that. If not, we'll return any valid session at the end of this function. - const existingSessionPreference = this.authenticationExtensionsService.getSessionPreference(providerId, extensionId, scopes); - if (existingSessionPreference) { - const matchingSession = sessions.find(session => session.id === existingSessionPreference); - if (matchingSession && this.authenticationAccessService.isAccessAllowed(providerId, matchingSession.account.label, extensionId)) { - return matchingSession; - } - } - } else if (this.authenticationAccessService.isAccessAllowed(providerId, sessions[0].account.label, extensionId)) { + // If we have an existing session preference, use that. If not, we'll return any valid session at the end of this function. + if (matchingAccountPreferenceSession && this.authenticationAccessService.isAccessAllowed(providerId, matchingAccountPreferenceSession.account.label, extensionId)) { + return matchingAccountPreferenceSession; + } + // If we only have one account for a single auth provider, lets just check if it's allowed and return it if it is. + if (!provider.supportsMultipleAccounts && this.authenticationAccessService.isAccessAllowed(providerId, sessions[0].account.label, extensionId)) { return sessions[0]; } } @@ -244,12 +246,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu ? await this.authenticationExtensionsService.selectSession(providerId, extensionId, extensionName, scopes, sessions) : sessions[0]; } else { - let accountToCreate: AuthenticationSessionAccount | undefined = options.account; - if (!accountToCreate) { - const sessionIdToRecreate = this.authenticationExtensionsService.getSessionPreference(providerId, extensionId, scopes); - accountToCreate = sessionIdToRecreate ? sessions.find(session => session.id === sessionIdToRecreate)?.account : undefined; - } - + const accountToCreate: AuthenticationSessionAccount | undefined = options.account ?? matchingAccountPreferenceSession?.account; do { session = await this.authenticationService.createSession(providerId, scopes, { activateImmediate: true, account: accountToCreate }); } while ( @@ -260,15 +257,16 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu } this.authenticationAccessService.updateAllowedExtensions(providerId, session.account.label, [{ id: extensionId, name: extensionName, allowed: true }]); - this.authenticationExtensionsService.updateSessionPreference(providerId, extensionId, session); + this._updateAccountPreference(extensionId, providerId, session); return session; } - // For the silent flows, if we have a session, even though it may not be the user's preference, we'll return it anyway because it might be for a specific - // set of scopes. - const validSession = sessions.find(session => this.authenticationAccessService.isAccessAllowed(providerId, session.account.label, extensionId)); - if (validSession) { - return validSession; + // For the silent flows, if we have a session but we don't have a session preference, we'll return the first one that is valid. + if (!matchingAccountPreferenceSession) { + const validSession = sessions.find(session => this.authenticationAccessService.isAccessAllowed(providerId, session.account.label, extensionId)); + if (validSession) { + return validSession; + } } // passive flows (silent or default) @@ -307,4 +305,41 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu }; this.telemetryService.publicLog2<{ extensionId: string; providerId: string }, AuthProviderUsageClassification>('authentication.providerUsage', { providerId, extensionId }); } + + //#region Account Preferences + // TODO@TylerLeonhardt: Update this after a few iterations to no longer fallback to the session preference + + private _getAccountPreference(extensionId: string, providerId: string, scopes: string[], sessions: ReadonlyArray): AuthenticationSession | undefined { + if (sessions.length === 0) { + return undefined; + } + const accountNamePreference = this.authenticationExtensionsService.getAccountPreference(extensionId, providerId); + if (accountNamePreference) { + const session = sessions.find(session => session.account.label === accountNamePreference); + return session; + } + + const sessionIdPreference = this.authenticationExtensionsService.getSessionPreference(providerId, extensionId, scopes); + if (sessionIdPreference) { + const session = sessions.find(session => session.id === sessionIdPreference); + if (session) { + // Migrate the session preference to the account preference + this.authenticationExtensionsService.updateAccountPreference(extensionId, providerId, session.account); + return session; + } + } + return undefined; + } + + private _updateAccountPreference(extensionId: string, providerId: string, session: AuthenticationSession): void { + this.authenticationExtensionsService.updateAccountPreference(extensionId, providerId, session.account); + this.authenticationExtensionsService.updateSessionPreference(providerId, extensionId, session); + } + + private _removeAccountPreference(extensionId: string, providerId: string, scopes: string[]): void { + this.authenticationExtensionsService.removeAccountPreference(extensionId, providerId); + this.authenticationExtensionsService.removeSessionPreference(providerId, extensionId, scopes); + } + + //#endregion } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 4baa14ed812..0d65feb2852 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -302,7 +302,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return !!(await extHostAuthentication.getSession(extension, providerId, scopes, { silent: true } as any)); }, get onDidChangeSessions(): vscode.Event { - return _asExtensionEvent(extHostAuthentication.onDidChangeSessions); + return _asExtensionEvent(extHostAuthentication.getExtensionScopedSessionsEvent(extension.identifier.value)); }, registerAuthenticationProvider(id: string, label: string, provider: vscode.AuthenticationProvider, options?: vscode.AuthenticationProviderOptions): vscode.Disposable { return extHostAuthentication.registerAuthenticationProvider(id, label, provider, options); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0928dc67f2b..6a75039436c 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1906,7 +1906,7 @@ export interface ExtHostAuthenticationShape { $getSessions(id: string, scopes: string[] | undefined, options: IAuthenticationProviderSessionOptions): Promise>; $createSession(id: string, scopes: string[], options: IAuthenticationCreateSessionOptions): Promise; $removeSession(id: string, sessionId: string): Promise; - $onDidChangeAuthenticationSessions(id: string, label: string): Promise; + $onDidChangeAuthenticationSessions(id: string, label: string, extensionIdFilter?: string[]): Promise; } export interface ExtHostAiRelatedInformationShape { diff --git a/src/vs/workbench/api/common/extHostAuthentication.ts b/src/vs/workbench/api/common/extHostAuthentication.ts index b05a7b4794e..2400f8ecae6 100644 --- a/src/vs/workbench/api/common/extHostAuthentication.ts +++ b/src/vs/workbench/api/common/extHostAuthentication.ts @@ -28,9 +28,7 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { private _proxy: MainThreadAuthenticationShape; private _authenticationProviders: Map = new Map(); - private _onDidChangeSessions = new Emitter(); - readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; - + private _onDidChangeSessions = new Emitter(); private _getSessionTaskSingler = new TaskSingler(); constructor( @@ -39,6 +37,20 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { this._proxy = extHostRpc.getProxy(MainContext.MainThreadAuthentication); } + /** + * This sets up an event that will fire when the auth sessions change with a built-in filter for the extensionId + * if a session change only affects a specific extension. + * @param extensionId The extension that is interested in the event. + * @returns An event with a built-in filter for the extensionId + */ + getExtensionScopedSessionsEvent(extensionId: string): Event { + const normalizedExtensionId = extensionId.toLowerCase(); + return Event.chain(this._onDidChangeSessions.event, ($) => $ + .filter(e => !e.extensionIdFilter || e.extensionIdFilter.includes(normalizedExtensionId)) + .map(e => ({ provider: e.provider })) + ); + } + async getSession(requestingExtension: IExtensionDescription, providerId: string, scopes: readonly string[], options: vscode.AuthenticationGetSessionOptions & ({ createIfNone: true } | { forceNewSession: true } | { forceNewSession: vscode.AuthenticationForceNewSessionOptions })): Promise; async getSession(requestingExtension: IExtensionDescription, providerId: string, scopes: readonly string[], options: vscode.AuthenticationGetSessionOptions & { forceNewSession: true }): Promise; async getSession(requestingExtension: IExtensionDescription, providerId: string, scopes: readonly string[], options: vscode.AuthenticationGetSessionOptions & { forceNewSession: vscode.AuthenticationForceNewSessionOptions }): Promise; @@ -110,10 +122,10 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { throw new Error(`Unable to find authentication provider with handle: ${providerId}`); } - $onDidChangeAuthenticationSessions(id: string, label: string) { + $onDidChangeAuthenticationSessions(id: string, label: string, extensionIdFilter?: string[]) { // Don't fire events for the internal auth providers if (!id.startsWith(INTERNAL_AUTH_PROVIDER_PREFIX)) { - this._onDidChangeSessions.fire({ provider: { id, label } }); + this._onDidChangeSessions.fire({ provider: { id, label }, extensionIdFilter }); } return Promise.resolve(); } diff --git a/src/vs/workbench/contrib/authentication/browser/actions/manageAccountPreferencesForExtensionAction.ts b/src/vs/workbench/contrib/authentication/browser/actions/manageAccountPreferencesForExtensionAction.ts new file mode 100644 index 00000000000..6438c88346a --- /dev/null +++ b/src/vs/workbench/contrib/authentication/browser/actions/manageAccountPreferencesForExtensionAction.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2 } from '../../../../../platform/actions/common/actions.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IQuickInputService, IQuickPick, IQuickPickItem, QuickPickInput } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IAuthenticationUsageService } from '../../../../services/authentication/browser/authenticationUsageService.js'; +import { AuthenticationSessionAccount, IAuthenticationExtensionsService, IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; + +export class ManageAccountPreferencesForExtensionAction extends Action2 { + constructor() { + super({ + id: '_manageAccountPreferencesForExtension', + title: localize2('manageAccountPreferenceForExtension', "Manage Extension Account Preferences"), + category: localize2('accounts', "Accounts"), + f1: false + }); + } + + override run(accessor: ServicesAccessor, extensionId?: string) { + return accessor.get(IInstantiationService).createInstance(ManageAccountPreferenceForExtensionActionImpl).run(extensionId); + } +} + +interface AccountPreferenceQuickPickItem extends IQuickPickItem { + account: AuthenticationSessionAccount; + providerId: string; +} + +class ManageAccountPreferenceForExtensionActionImpl { + constructor( + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @IQuickInputService private readonly _quickInputService: IQuickInputService, + @IAuthenticationUsageService private readonly _authenticationUsageService: IAuthenticationUsageService, + @IAuthenticationExtensionsService private readonly _authenticationExtensionsService: IAuthenticationExtensionsService, + @IExtensionService private readonly _extensionService: IExtensionService + ) { } + + async run(extensionId?: string) { + if (!extensionId) { + return; + } + const extension = await this._extensionService.getExtension(extensionId); + if (!extension) { + throw new Error(`No extension with id ${extensionId}`); + } + + const providerIds = new Array(); + const providerIdToAccounts = new Map>(); + for (const providerId of this._authenticationService.getProviderIds()) { + const accounts = await this._authenticationService.getAccounts(providerId); + for (const account of accounts) { + const usage = this._authenticationUsageService.readAccountUsages(providerId, account.label).find(u => u.extensionId === extensionId.toLowerCase()); + if (usage) { + providerIds.push(providerId); + providerIdToAccounts.set(providerId, accounts); + break; + } + } + } + + let chosenProviderId: string | undefined = providerIds[0]; + if (providerIds.length > 1) { + const result = await this._quickInputService.pick( + providerIds.map(providerId => ({ + label: this._authenticationService.getProvider(providerId).label, + id: providerId, + })), + { + placeHolder: localize('selectProvider', "Select an authentication provider to manage account preferences for"), + title: localize('pickAProviderTitle', "Manage Extension Account Preferences") + } + ); + chosenProviderId = result?.id; + } + + if (!chosenProviderId) { + return; + } + + const currentAccountNamePreference = this._authenticationExtensionsService.getAccountPreference(extensionId, chosenProviderId); + const items: Array> = this._getItems(providerIdToAccounts.get(chosenProviderId)!, chosenProviderId, currentAccountNamePreference); + + const disposables = new DisposableStore(); + const picker = this._createQuickPick(disposables, extensionId, extension.displayName ?? extension.name); + if (items.length === 0) { + // We would only get here if we went through the Command Palette + disposables.add(this._handleNoAccounts(picker)); + return; + } + picker.items = items; + picker.show(); + } + + private _createQuickPick(disposableStore: DisposableStore, extensionId: string, extensionLabel: string) { + const picker = disposableStore.add(this._quickInputService.createQuickPick({ useSeparators: true })); + disposableStore.add(picker.onDidHide(() => { + disposableStore.dispose(); + })); + picker.placeholder = localize('placeholder', "Manage '{0}' account preferences...", extensionLabel); + picker.title = localize('title', "'{0}' Account Preferences For This Workspace", extensionLabel); + picker.sortByLabel = false; + disposableStore.add(picker.onDidAccept(() => { + this._accept(extensionId, picker.selectedItems); + picker.hide(); + })); + return picker; + } + + private _getItems(accounts: ReadonlyArray, providerId: string, currentAccountNamePreference: string | undefined): Array> { + return accounts.map>(a => currentAccountNamePreference === a.label + ? { + label: a.label, + account: a, + providerId, + description: localize('currentAccount', "Current account"), + picked: true + } + : { + label: a.label, + account: a, + providerId, + } + ); + } + + private _handleNoAccounts(picker: IQuickPick): IDisposable { + picker.validationMessage = localize('noAccounts', "No accounts are currently used by this extension."); + picker.buttons = [this._quickInputService.backButton]; + picker.show(); + return Event.filter(picker.onDidTriggerButton, (e) => e === this._quickInputService.backButton)(() => this.run()); + } + + private _accept(extensionId: string, selectedItems: ReadonlyArray) { + for (const item of selectedItems) { + const account = item.account; + const providerId = item.providerId; + const currentAccountName = this._authenticationExtensionsService.getAccountPreference(extensionId, providerId); + if (currentAccountName === account.label) { + // This account is already the preferred account + continue; + } + this._authenticationExtensionsService.updateAccountPreference(extensionId, providerId, account); + } + } +} diff --git a/src/vs/workbench/contrib/authentication/browser/actions/manageTrustedExtensionsForAccountAction.ts b/src/vs/workbench/contrib/authentication/browser/actions/manageTrustedExtensionsForAccountAction.ts index d619dcf0dd0..2d94e26f539 100644 --- a/src/vs/workbench/contrib/authentication/browser/actions/manageTrustedExtensionsForAccountAction.ts +++ b/src/vs/workbench/contrib/authentication/browser/actions/manageTrustedExtensionsForAccountAction.ts @@ -3,10 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Codicon } from '../../../../../base/common/codicons.js'; import { fromNow } from '../../../../../base/common/date.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2 } from '../../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; @@ -45,7 +48,8 @@ class ManageTrustedExtensionsForAccountActionImpl { @IQuickInputService private readonly _quickInputService: IQuickInputService, @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @IAuthenticationUsageService private readonly _authenticationUsageService: IAuthenticationUsageService, - @IAuthenticationAccessService private readonly _authenticationAccessService: IAuthenticationAccessService + @IAuthenticationAccessService private readonly _authenticationAccessService: IAuthenticationAccessService, + @ICommandService private readonly _commandService: ICommandService ) { } async run(options?: { providerId: string; accountLabel: string }) { @@ -179,6 +183,10 @@ class ManageTrustedExtensionsForAccountActionImpl { description, tooltip, disabled, + buttons: [{ + tooltip: localize('accountPreferences', "Manage account preferences for this extension"), + iconClass: ThemeIcon.asClassName(Codicon.settingsGear), + }], picked: extension.allowed === undefined || extension.allowed }; } @@ -212,6 +220,9 @@ class ManageTrustedExtensionsForAccountActionImpl { disposableStore.add(quickPick.onDidCustom(() => { quickPick.hide(); })); + disposableStore.add(quickPick.onDidTriggerItemButton(e => + this._commandService.executeCommand('_manageAccountPreferencesForExtension', e.item.extension.id) + )); return quickPick; } } diff --git a/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts b/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts index 40c793a67aa..a9cde0fda7e 100644 --- a/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts +++ b/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts @@ -20,6 +20,7 @@ import { IBrowserWorkbenchEnvironmentService } from '../../../services/environme import { Extensions, IExtensionFeatureTableRenderer, IExtensionFeaturesRegistry, IRenderedData, IRowData, ITableData } from '../../../services/extensionManagement/common/extensionFeatures.js'; import { ExtensionsRegistry } from '../../../services/extensions/common/extensionsRegistry.js'; import { ManageTrustedExtensionsForAccountAction } from './actions/manageTrustedExtensionsForAccountAction.js'; +import { ManageAccountPreferencesForExtensionAction } from './actions/manageAccountPreferencesForExtensionAction.js'; const codeExchangeProxyCommand = CommandsRegistry.registerCommand('workbench.getCodeExchangeProxyEndpoints', function (accessor, _) { const environmentService = accessor.get(IBrowserWorkbenchEnvironmentService); @@ -194,6 +195,7 @@ export class AuthenticationContribution extends Disposable implements IWorkbench private _registerActions(): void { this._register(registerAction2(SignOutOfAccountAction)); this._register(registerAction2(ManageTrustedExtensionsForAccountAction)); + this._register(registerAction2(ManageAccountPreferencesForExtensionAction)); } private _clearPlaceholderMenuItem(): void { diff --git a/src/vs/workbench/services/authentication/browser/authenticationExtensionsService.ts b/src/vs/workbench/services/authentication/browser/authenticationExtensionsService.ts index 302f9f967cd..73fc6a2c68d 100644 --- a/src/vs/workbench/services/authentication/browser/authenticationExtensionsService.ts +++ b/src/vs/workbench/services/authentication/browser/authenticationExtensionsService.ts @@ -16,6 +16,8 @@ import { IActivityService, NumberBadge } from '../../activity/common/activity.js import { IAuthenticationAccessService } from './authenticationAccessService.js'; import { IAuthenticationUsageService } from './authenticationUsageService.js'; import { AuthenticationSession, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService, AuthenticationSessionAccount } from '../common/authentication.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; // OAuth2 spec prohibits space in a scope, so use that to join them. const SCOPESLIST_SEPARATOR = ' '; @@ -36,11 +38,23 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth private _sessionAccessRequestItems = new Map(); private readonly _accountBadgeDisposable = this._register(new MutableDisposable()); + private _onDidAccountPreferenceChange: Emitter<{ providerId: string; extensionIds: string[] }> = this._register(new Emitter<{ providerId: string; extensionIds: string[] }>()); + readonly onDidChangeAccountPreference = this._onDidAccountPreferenceChange.event; + + private _inheritAuthAccountPreferenceParentToChildren: Record = this._productService.inheritAuthAccountPreference || {}; + private _inheritAuthAccountPreferenceChildToParent: { [extensionId: string]: string } = Object.entries(this._inheritAuthAccountPreferenceParentToChildren).reduce((acc, [parent, children]) => { + children.forEach((child: string) => { + acc[child] = parent; + }); + return acc; + }, {} as { [extensionId: string]: string }); + constructor( @IActivityService private readonly activityService: IActivityService, @IStorageService private readonly storageService: IStorageService, @IDialogService private readonly dialogService: IDialogService, @IQuickInputService private readonly quickInputService: IQuickInputService, + @IProductService private readonly _productService: IProductService, @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @IAuthenticationUsageService private readonly _authenticationUsageService: IAuthenticationUsageService, @IAuthenticationAccessService private readonly _authenticationAccessService: IAuthenticationAccessService @@ -136,7 +150,46 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth } } - //#region Session Preference + //#region Account/Session Preference + + updateAccountPreference(extensionId: string, providerId: string, account: AuthenticationSessionAccount): void { + const parentExtensionId = this._inheritAuthAccountPreferenceChildToParent[extensionId] ?? extensionId; + const key = this._getKey(parentExtensionId, providerId); + + // Store the preference in the workspace and application storage. This allows new workspaces to + // have a preference set already to limit the number of prompts that are shown... but also allows + // a specific workspace to override the global preference. + this.storageService.store(key, account.label, StorageScope.WORKSPACE, StorageTarget.MACHINE); + this.storageService.store(key, account.label, StorageScope.APPLICATION, StorageTarget.MACHINE); + + const childrenExtensions = this._inheritAuthAccountPreferenceParentToChildren[parentExtensionId]; + const extensionIds = childrenExtensions ? [parentExtensionId, ...childrenExtensions] : [parentExtensionId]; + this._onDidAccountPreferenceChange.fire({ extensionIds, providerId }); + } + + getAccountPreference(extensionId: string, providerId: string): string | undefined { + const key = this._getKey(this._inheritAuthAccountPreferenceChildToParent[extensionId] ?? extensionId, providerId); + + // If a preference is set in the workspace, use that. Otherwise, use the global preference. + return this.storageService.get(key, StorageScope.WORKSPACE) ?? this.storageService.get(key, StorageScope.APPLICATION); + } + + removeAccountPreference(extensionId: string, providerId: string): void { + const key = this._getKey(this._inheritAuthAccountPreferenceChildToParent[extensionId] ?? extensionId, providerId); + + // This won't affect any other workspaces that have a preference set, but it will remove the preference + // for this workspace and the global preference. This is only paired with a call to updateSessionPreference... + // so we really don't _need_ to remove them as they are about to be overridden anyway... but it's more correct + // to remove them first... and in case this gets called from somewhere else in the future. + this.storageService.remove(key, StorageScope.WORKSPACE); + this.storageService.remove(key, StorageScope.APPLICATION); + } + + private _getKey(extensionId: string, providerId: string): string { + return `${extensionId}-${providerId}`; + } + + // TODO@TylerLeonhardt: Remove all of this after a couple iterations updateSessionPreference(providerId: string, extensionId: string, session: AuthenticationSession): void { // The 3 parts of this key are important: @@ -178,6 +231,11 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth this.storageService.remove(key, StorageScope.APPLICATION); } + private _updateAccountAndSessionPreferences(providerId: string, extensionId: string, session: AuthenticationSession): void { + this.updateAccountPreference(extensionId, providerId, session.account); + this.updateSessionPreference(providerId, extensionId, session); + } + //#endregion private async showGetSessionPrompt(provider: IAuthenticationProvider, accountName: string, extensionId: string, extensionName: string): Promise { @@ -270,7 +328,7 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth const accountName = session.account.label; this._authenticationAccessService.updateAllowedExtensions(providerId, accountName, [{ id: extensionId, name: extensionName, allowed: true }]); - this.updateSessionPreference(providerId, extensionId, session); + this._updateAccountAndSessionPreferences(providerId, extensionId, session); this.removeAccessRequest(providerId, extensionId); resolve(session); @@ -407,7 +465,7 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth const session = await authenticationService.createSession(providerId, scopes); this._authenticationAccessService.updateAllowedExtensions(providerId, session.account.label, [{ id: extensionId, name: extensionName, allowed: true }]); - this.updateSessionPreference(providerId, extensionId, session); + this._updateAccountAndSessionPreferences(providerId, extensionId, session); } }); diff --git a/src/vs/workbench/services/authentication/common/authentication.ts b/src/vs/workbench/services/authentication/common/authentication.ts index bfd39a6e77e..f3643cf2365 100644 --- a/src/vs/workbench/services/authentication/common/authentication.ts +++ b/src/vs/workbench/services/authentication/common/authentication.ts @@ -165,8 +165,53 @@ export const IAuthenticationExtensionsService = createDecorator; + /** + * Returns the accountName (also known as account.label) to pair with `IAuthenticationAccessService` to get the account preference + * @param providerId The authentication provider id + * @param extensionId The extension id to get the preference for + * @returns The accountName of the preference, or undefined if there is no preference set + */ + getAccountPreference(extensionId: string, providerId: string): string | undefined; + /** + * Sets the account preference for the given provider and extension + * @param providerId The authentication provider id + * @param extensionId The extension id to set the preference for + * @param account The account to set the preference to + */ + updateAccountPreference(extensionId: string, providerId: string, account: AuthenticationSessionAccount): void; + /** + * Removes the account preference for the given provider and extension + * @param providerId The authentication provider id + * @param extensionId The extension id to remove the preference for + */ + removeAccountPreference(extensionId: string, providerId: string): void; + /** + * @deprecated Sets the session preference for the given provider and extension + * @param providerId + * @param extensionId + * @param session + */ updateSessionPreference(providerId: string, extensionId: string, session: AuthenticationSession): void; + /** + * @deprecated Gets the session preference for the given provider and extension + * @param providerId + * @param extensionId + * @param scopes + */ getSessionPreference(providerId: string, extensionId: string, scopes: string[]): string | undefined; + /** + * @deprecated Removes the session preference for the given provider and extension + * @param providerId + * @param extensionId + * @param scopes + */ removeSessionPreference(providerId: string, extensionId: string, scopes: string[]): void; selectSession(providerId: string, extensionId: string, extensionName: string, scopes: string[], possibleSessions: readonly AuthenticationSession[]): Promise; requestSessionAccess(providerId: string, extensionId: string, extensionName: string, scopes: string[], possibleSessions: readonly AuthenticationSession[]): void;