Path: blob/main/src/vs/workbench/services/authentication/browser/authenticationExtensionsService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';6import { scopesMatch } from '../../../../base/common/oauth.js';7import * as nls from '../../../../nls.js';8import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js';9import { CommandsRegistry } from '../../../../platform/commands/common/commands.js';10import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';11import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';12import { Severity } from '../../../../platform/notification/common/notification.js';13import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js';14import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';15import { IActivityService, NumberBadge } from '../../activity/common/activity.js';16import { IAuthenticationAccessService } from './authenticationAccessService.js';17import { IAuthenticationUsageService } from './authenticationUsageService.js';18import { AuthenticationSession, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService, AuthenticationSessionAccount, IAuthenticationWWWAuthenticateRequest, isAuthenticationWWWAuthenticateRequest } from '../common/authentication.js';19import { Emitter } from '../../../../base/common/event.js';20import { IProductService } from '../../../../platform/product/common/productService.js';21import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';2223// OAuth2 spec prohibits space in a scope, so use that to join them.24const SCOPESLIST_SEPARATOR = ' ';2526interface SessionRequest {27disposables: IDisposable[];28requestingExtensionIds: string[];29}3031interface SessionRequestInfo {32[scopesList: string]: SessionRequest;33}3435// TODO@TylerLeonhardt: This should all go in MainThreadAuthentication36export class AuthenticationExtensionsService extends Disposable implements IAuthenticationExtensionsService {37declare readonly _serviceBrand: undefined;38private _signInRequestItems = new Map<string, SessionRequestInfo>();39private _sessionAccessRequestItems = new Map<string, { [extensionId: string]: { disposables: IDisposable[]; possibleSessions: AuthenticationSession[] } }>();40private readonly _accountBadgeDisposable = this._register(new MutableDisposable());4142private _onDidAccountPreferenceChange: Emitter<{ providerId: string; extensionIds: string[] }> = this._register(new Emitter<{ providerId: string; extensionIds: string[] }>());43readonly onDidChangeAccountPreference = this._onDidAccountPreferenceChange.event;4445private _inheritAuthAccountPreferenceParentToChildren: Record<string, string[]>;46private _inheritAuthAccountPreferenceChildToParent: { [extensionId: string]: string };4748constructor(49@IActivityService private readonly activityService: IActivityService,50@IStorageService private readonly storageService: IStorageService,51@IDialogService private readonly dialogService: IDialogService,52@IQuickInputService private readonly quickInputService: IQuickInputService,53@IProductService private readonly _productService: IProductService,54@IAuthenticationService private readonly _authenticationService: IAuthenticationService,55@IAuthenticationUsageService private readonly _authenticationUsageService: IAuthenticationUsageService,56@IAuthenticationAccessService private readonly _authenticationAccessService: IAuthenticationAccessService57) {58super();59this._inheritAuthAccountPreferenceParentToChildren = this._productService.inheritAuthAccountPreference || {};60this._inheritAuthAccountPreferenceChildToParent = Object.entries(this._inheritAuthAccountPreferenceParentToChildren).reduce<{ [extensionId: string]: string }>((acc, [parent, children]) => {61children.forEach((child: string) => {62acc[child] = parent;63});64return acc;65}, {});66this.registerListeners();67}6869private registerListeners() {70this._register(this._authenticationService.onDidChangeSessions(e => {71if (e.event.added?.length) {72this.updateNewSessionRequests(e.providerId, e.event.added);73}74if (e.event.removed?.length) {75this.updateAccessRequests(e.providerId, e.event.removed);76}77}));7879this._register(this._authenticationService.onDidUnregisterAuthenticationProvider(e => {80const accessRequests = this._sessionAccessRequestItems.get(e.id) || {};81Object.keys(accessRequests).forEach(extensionId => {82this.removeAccessRequest(e.id, extensionId);83});84}));85}8687updateNewSessionRequests(providerId: string, addedSessions: readonly AuthenticationSession[]): void {88const existingRequestsForProvider = this._signInRequestItems.get(providerId);89if (!existingRequestsForProvider) {90return;91}9293Object.keys(existingRequestsForProvider).forEach(requestedScopes => {94// Parse the requested scopes from the stored key95const requestedScopesArray = requestedScopes.split(SCOPESLIST_SEPARATOR);9697// Check if any added session has matching scopes (order-independent)98if (addedSessions.some(session => scopesMatch(session.scopes, requestedScopesArray))) {99const sessionRequest = existingRequestsForProvider[requestedScopes];100sessionRequest?.disposables.forEach(item => item.dispose());101102delete existingRequestsForProvider[requestedScopes];103if (Object.keys(existingRequestsForProvider).length === 0) {104this._signInRequestItems.delete(providerId);105} else {106this._signInRequestItems.set(providerId, existingRequestsForProvider);107}108this.updateBadgeCount();109}110});111}112113private updateAccessRequests(providerId: string, removedSessions: readonly AuthenticationSession[]): void {114const providerRequests = this._sessionAccessRequestItems.get(providerId);115if (providerRequests) {116Object.keys(providerRequests).forEach(extensionId => {117removedSessions.forEach(removed => {118const indexOfSession = providerRequests[extensionId].possibleSessions.findIndex(session => session.id === removed.id);119if (indexOfSession) {120providerRequests[extensionId].possibleSessions.splice(indexOfSession, 1);121}122});123124if (!providerRequests[extensionId].possibleSessions.length) {125this.removeAccessRequest(providerId, extensionId);126}127});128}129}130131private updateBadgeCount(): void {132this._accountBadgeDisposable.clear();133134let numberOfRequests = 0;135this._signInRequestItems.forEach(providerRequests => {136Object.keys(providerRequests).forEach(request => {137numberOfRequests += providerRequests[request].requestingExtensionIds.length;138});139});140141this._sessionAccessRequestItems.forEach(accessRequest => {142numberOfRequests += Object.keys(accessRequest).length;143});144145if (numberOfRequests > 0) {146const badge = new NumberBadge(numberOfRequests, () => nls.localize('sign in', "Sign in requested"));147this._accountBadgeDisposable.value = this.activityService.showAccountsActivity({ badge });148}149}150151private removeAccessRequest(providerId: string, extensionId: string): void {152const providerRequests = this._sessionAccessRequestItems.get(providerId) || {};153if (providerRequests[extensionId]) {154dispose(providerRequests[extensionId].disposables);155delete providerRequests[extensionId];156this.updateBadgeCount();157}158}159160//#region Account/Session Preference161162updateAccountPreference(extensionId: string, providerId: string, account: AuthenticationSessionAccount): void {163const realExtensionId = ExtensionIdentifier.toKey(extensionId);164const parentExtensionId = this._inheritAuthAccountPreferenceChildToParent[realExtensionId] ?? realExtensionId;165const key = this._getKey(parentExtensionId, providerId);166167// Store the preference in the workspace and application storage. This allows new workspaces to168// have a preference set already to limit the number of prompts that are shown... but also allows169// a specific workspace to override the global preference.170this.storageService.store(key, account.label, StorageScope.WORKSPACE, StorageTarget.MACHINE);171this.storageService.store(key, account.label, StorageScope.APPLICATION, StorageTarget.MACHINE);172173const childrenExtensions = this._inheritAuthAccountPreferenceParentToChildren[parentExtensionId];174const extensionIds = childrenExtensions ? [parentExtensionId, ...childrenExtensions] : [parentExtensionId];175this._onDidAccountPreferenceChange.fire({ extensionIds, providerId });176}177178getAccountPreference(extensionId: string, providerId: string): string | undefined {179const realExtensionId = ExtensionIdentifier.toKey(extensionId);180const key = this._getKey(this._inheritAuthAccountPreferenceChildToParent[realExtensionId] ?? realExtensionId, providerId);181182// If a preference is set in the workspace, use that. Otherwise, use the global preference.183return this.storageService.get(key, StorageScope.WORKSPACE) ?? this.storageService.get(key, StorageScope.APPLICATION);184}185186removeAccountPreference(extensionId: string, providerId: string): void {187const realExtensionId = ExtensionIdentifier.toKey(extensionId);188const key = this._getKey(this._inheritAuthAccountPreferenceChildToParent[realExtensionId] ?? realExtensionId, providerId);189190// This won't affect any other workspaces that have a preference set, but it will remove the preference191// for this workspace and the global preference. This is only paired with a call to updateSessionPreference...192// so we really don't _need_ to remove them as they are about to be overridden anyway... but it's more correct193// to remove them first... and in case this gets called from somewhere else in the future.194this.storageService.remove(key, StorageScope.WORKSPACE);195this.storageService.remove(key, StorageScope.APPLICATION);196}197198private _getKey(extensionId: string, providerId: string): string {199return `${extensionId}-${providerId}`;200}201202// TODO@TylerLeonhardt: Remove all of this after a couple iterations203204updateSessionPreference(providerId: string, extensionId: string, session: AuthenticationSession): void {205const realExtensionId = ExtensionIdentifier.toKey(extensionId);206// The 3 parts of this key are important:207// * Extension id: The extension that has a preference208// * Provider id: The provider that the preference is for209// * The scopes: The subset of sessions that the preference applies to210const key = `${realExtensionId}-${providerId}-${session.scopes.join(SCOPESLIST_SEPARATOR)}`;211212// Store the preference in the workspace and application storage. This allows new workspaces to213// have a preference set already to limit the number of prompts that are shown... but also allows214// a specific workspace to override the global preference.215this.storageService.store(key, session.id, StorageScope.WORKSPACE, StorageTarget.MACHINE);216this.storageService.store(key, session.id, StorageScope.APPLICATION, StorageTarget.MACHINE);217}218219getSessionPreference(providerId: string, extensionId: string, scopes: string[]): string | undefined {220const realExtensionId = ExtensionIdentifier.toKey(extensionId);221// The 3 parts of this key are important:222// * Extension id: The extension that has a preference223// * Provider id: The provider that the preference is for224// * The scopes: The subset of sessions that the preference applies to225const key = `${realExtensionId}-${providerId}-${scopes.join(SCOPESLIST_SEPARATOR)}`;226227// If a preference is set in the workspace, use that. Otherwise, use the global preference.228return this.storageService.get(key, StorageScope.WORKSPACE) ?? this.storageService.get(key, StorageScope.APPLICATION);229}230231removeSessionPreference(providerId: string, extensionId: string, scopes: string[]): void {232const realExtensionId = ExtensionIdentifier.toKey(extensionId);233// The 3 parts of this key are important:234// * Extension id: The extension that has a preference235// * Provider id: The provider that the preference is for236// * The scopes: The subset of sessions that the preference applies to237const key = `${realExtensionId}-${providerId}-${scopes.join(SCOPESLIST_SEPARATOR)}`;238239// This won't affect any other workspaces that have a preference set, but it will remove the preference240// for this workspace and the global preference. This is only paired with a call to updateSessionPreference...241// so we really don't _need_ to remove them as they are about to be overridden anyway... but it's more correct242// to remove them first... and in case this gets called from somewhere else in the future.243this.storageService.remove(key, StorageScope.WORKSPACE);244this.storageService.remove(key, StorageScope.APPLICATION);245}246247private _updateAccountAndSessionPreferences(providerId: string, extensionId: string, session: AuthenticationSession): void {248this.updateAccountPreference(extensionId, providerId, session.account);249this.updateSessionPreference(providerId, extensionId, session);250}251252//#endregion253254private async showGetSessionPrompt(provider: IAuthenticationProvider, accountName: string, extensionId: string, extensionName: string): Promise<boolean> {255enum SessionPromptChoice {256Allow = 0,257Deny = 1,258Cancel = 2259}260const { result } = await this.dialogService.prompt<SessionPromptChoice>({261type: Severity.Info,262message: nls.localize('confirmAuthenticationAccess', "The extension '{0}' wants to access the {1} account '{2}'.", extensionName, provider.label, accountName),263buttons: [264{265label: nls.localize({ key: 'allow', comment: ['&& denotes a mnemonic'] }, "&&Allow"),266run: () => SessionPromptChoice.Allow267},268{269label: nls.localize({ key: 'deny', comment: ['&& denotes a mnemonic'] }, "&&Deny"),270run: () => SessionPromptChoice.Deny271}272],273cancelButton: {274run: () => SessionPromptChoice.Cancel275}276});277278if (result !== SessionPromptChoice.Cancel) {279this._authenticationAccessService.updateAllowedExtensions(provider.id, accountName, [{ id: extensionId, name: extensionName, allowed: result === SessionPromptChoice.Allow }]);280this.removeAccessRequest(provider.id, extensionId);281}282283return result === SessionPromptChoice.Allow;284}285286/**287* This function should be used only when there are sessions to disambiguate.288*/289async selectSession(providerId: string, extensionId: string, extensionName: string, scopeListOrRequest: ReadonlyArray<string> | IAuthenticationWWWAuthenticateRequest, availableSessions: AuthenticationSession[]): Promise<AuthenticationSession> {290const allAccounts = await this._authenticationService.getAccounts(providerId);291if (!allAccounts.length) {292throw new Error('No accounts available');293}294const disposables = new DisposableStore();295const quickPick = disposables.add(this.quickInputService.createQuickPick<{ label: string; session?: AuthenticationSession; account?: AuthenticationSessionAccount }>());296quickPick.ignoreFocusOut = true;297const accountsWithSessions = new Set<string>();298const items: { label: string; session?: AuthenticationSession; account?: AuthenticationSessionAccount }[] = availableSessions299// Only grab the first account300.filter(session => !accountsWithSessions.has(session.account.label) && accountsWithSessions.add(session.account.label))301.map(session => {302return {303label: session.account.label,304session: session305};306});307308// Add the additional accounts that have been logged into the provider but are309// don't have a session yet.310allAccounts.forEach(account => {311if (!accountsWithSessions.has(account.label)) {312items.push({ label: account.label, account });313}314});315items.push({ label: nls.localize('useOtherAccount', "Sign in to another account") });316quickPick.items = items;317quickPick.title = nls.localize(318{319key: 'selectAccount',320comment: ['The placeholder {0} is the name of an extension. {1} is the name of the type of account, such as Microsoft or GitHub.']321},322"The extension '{0}' wants to access a {1} account",323extensionName,324this._authenticationService.getProvider(providerId).label325);326quickPick.placeholder = nls.localize('getSessionPlateholder', "Select an account for '{0}' to use or Esc to cancel", extensionName);327328return await new Promise((resolve, reject) => {329disposables.add(quickPick.onDidAccept(async _ => {330quickPick.dispose();331let session = quickPick.selectedItems[0].session;332if (!session) {333const account = quickPick.selectedItems[0].account;334try {335session = await this._authenticationService.createSession(providerId, scopeListOrRequest, { account });336} catch (e) {337reject(e);338return;339}340}341const accountName = session.account.label;342343this._authenticationAccessService.updateAllowedExtensions(providerId, accountName, [{ id: extensionId, name: extensionName, allowed: true }]);344this._updateAccountAndSessionPreferences(providerId, extensionId, session);345this.removeAccessRequest(providerId, extensionId);346347resolve(session);348}));349350disposables.add(quickPick.onDidHide(_ => {351if (!quickPick.selectedItems[0]) {352reject('User did not consent to account access');353}354disposables.dispose();355}));356357quickPick.show();358});359}360361private async completeSessionAccessRequest(provider: IAuthenticationProvider, extensionId: string, extensionName: string, scopeListOrRequest: ReadonlyArray<string> | IAuthenticationWWWAuthenticateRequest): Promise<void> {362const providerRequests = this._sessionAccessRequestItems.get(provider.id) || {};363const existingRequest = providerRequests[extensionId];364if (!existingRequest) {365return;366}367368if (!provider) {369return;370}371const possibleSessions = existingRequest.possibleSessions;372373let session: AuthenticationSession | undefined;374if (provider.supportsMultipleAccounts) {375try {376session = await this.selectSession(provider.id, extensionId, extensionName, scopeListOrRequest, possibleSessions);377} catch (_) {378// ignore cancel379}380} else {381const approved = await this.showGetSessionPrompt(provider, possibleSessions[0].account.label, extensionId, extensionName);382if (approved) {383session = possibleSessions[0];384}385}386387if (session) {388this._authenticationUsageService.addAccountUsage(provider.id, session.account.label, session.scopes, extensionId, extensionName);389}390}391392requestSessionAccess(providerId: string, extensionId: string, extensionName: string, scopeListOrRequest: ReadonlyArray<string> | IAuthenticationWWWAuthenticateRequest, possibleSessions: AuthenticationSession[]): void {393const providerRequests = this._sessionAccessRequestItems.get(providerId) || {};394const hasExistingRequest = providerRequests[extensionId];395if (hasExistingRequest) {396return;397}398399const provider = this._authenticationService.getProvider(providerId);400const menuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, {401group: '3_accessRequests',402command: {403id: `${providerId}${extensionId}Access`,404title: nls.localize({405key: 'accessRequest',406comment: [`The placeholder {0} will be replaced with an authentication provider''s label. {1} will be replaced with an extension name. (1) is to indicate that this menu item contributes to a badge count`]407},408"Grant access to {0} for {1}... (1)",409provider.label,410extensionName)411}412});413414const accessCommand = CommandsRegistry.registerCommand({415id: `${providerId}${extensionId}Access`,416handler: async (accessor) => {417this.completeSessionAccessRequest(provider, extensionId, extensionName, scopeListOrRequest);418}419});420421providerRequests[extensionId] = { possibleSessions, disposables: [menuItem, accessCommand] };422this._sessionAccessRequestItems.set(providerId, providerRequests);423this.updateBadgeCount();424}425426async requestNewSession(providerId: string, scopeListOrRequest: ReadonlyArray<string> | IAuthenticationWWWAuthenticateRequest, extensionId: string, extensionName: string): Promise<void> {427if (!this._authenticationService.isAuthenticationProviderRegistered(providerId)) {428// Activate has already been called for the authentication provider, but it cannot block on registering itself429// since this is sync and returns a disposable. So, wait for registration event to fire that indicates the430// provider is now in the map.431await new Promise<void>((resolve, _) => {432const dispose = this._authenticationService.onDidRegisterAuthenticationProvider(e => {433if (e.id === providerId) {434dispose.dispose();435resolve();436}437});438});439}440441let provider: IAuthenticationProvider;442try {443provider = this._authenticationService.getProvider(providerId);444} catch (_e) {445return;446}447448const providerRequests = this._signInRequestItems.get(providerId);449const signInRequestKey = isAuthenticationWWWAuthenticateRequest(scopeListOrRequest)450? `${scopeListOrRequest.wwwAuthenticate}:${scopeListOrRequest.scopes?.join(SCOPESLIST_SEPARATOR) ?? ''}`451: `${scopeListOrRequest.join(SCOPESLIST_SEPARATOR)}`;452const extensionHasExistingRequest = providerRequests453&& providerRequests[signInRequestKey]454&& providerRequests[signInRequestKey].requestingExtensionIds.includes(extensionId);455456if (extensionHasExistingRequest) {457return;458}459460// Construct a commandId that won't clash with others generated here, nor likely with an extension's command461const commandId = `${providerId}:${extensionId}:signIn${Object.keys(providerRequests || []).length}`;462const menuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, {463group: '2_signInRequests',464command: {465id: commandId,466title: nls.localize({467key: 'signInRequest',468comment: [`The placeholder {0} will be replaced with an authentication provider's label. {1} will be replaced with an extension name. (1) is to indicate that this menu item contributes to a badge count.`]469},470"Sign in with {0} to use {1} (1)",471provider.label,472extensionName)473}474});475476const signInCommand = CommandsRegistry.registerCommand({477id: commandId,478handler: async (accessor) => {479const authenticationService = accessor.get(IAuthenticationService);480const session = await authenticationService.createSession(providerId, scopeListOrRequest);481482this._authenticationAccessService.updateAllowedExtensions(providerId, session.account.label, [{ id: extensionId, name: extensionName, allowed: true }]);483this._updateAccountAndSessionPreferences(providerId, extensionId, session);484}485});486487488if (providerRequests) {489const existingRequest = providerRequests[signInRequestKey] || { disposables: [], requestingExtensionIds: [] };490491providerRequests[signInRequestKey] = {492disposables: [...existingRequest.disposables, menuItem, signInCommand],493requestingExtensionIds: [...existingRequest.requestingExtensionIds, extensionId]494};495this._signInRequestItems.set(providerId, providerRequests);496} else {497this._signInRequestItems.set(providerId, {498[signInRequestKey]: {499disposables: [menuItem, signInCommand],500requestingExtensionIds: [extensionId]501}502});503}504505this.updateBadgeCount();506}507}508509registerSingleton(IAuthenticationExtensionsService, AuthenticationExtensionsService, InstantiationType.Delayed);510511512