diff --git a/src/vs/vscode.d.ts b/src/vs/vscode.d.ts index 7c5c6828b6a..b8f07070bde 100644 --- a/src/vs/vscode.d.ts +++ b/src/vs/vscode.d.ts @@ -12287,6 +12287,9 @@ declare module 'vscode' { * on the accounts activity bar icon. An entry for the extension will be added under the menu to sign in. This * allows quietly prompting the user to sign in. * + * If there is a matching session but the extension has not been granted access to it, setting this to true + * will also result in an immediate modal dialog, and false will add a numbered badge to the accounts icon. + * * Defaults to false. */ createIfNone?: boolean; diff --git a/src/vs/workbench/api/browser/mainThreadAuthentication.ts b/src/vs/workbench/api/browser/mainThreadAuthentication.ts index 98bb98d8cee..4ed2572a8f7 100644 --- a/src/vs/workbench/api/browser/mainThreadAuthentication.ts +++ b/src/vs/workbench/api/browser/mainThreadAuthentication.ts @@ -7,67 +7,15 @@ import { Disposable } from 'vs/base/common/lifecycle'; import * as modes from 'vs/editor/common/modes'; import * as nls from 'vs/nls'; import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; -import { IAuthenticationService, AllowedExtension, readAllowedExtensions, getAuthenticationProviderActivationEvent } from 'vs/workbench/services/authentication/browser/authenticationService'; +import { IAuthenticationService, AllowedExtension, readAllowedExtensions, getAuthenticationProviderActivationEvent, addAccountUsage, readAccountUsages, removeAccountUsage } from 'vs/workbench/services/authentication/browser/authenticationService'; import { ExtHostAuthenticationShape, ExtHostContext, IExtHostContext, MainContext, MainThreadAuthenticationShape } from '../common/extHost.protocol'; import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import Severity from 'vs/base/common/severity'; import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { INotificationService } from 'vs/platform/notification/common/notification'; -import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; import { fromNow } from 'vs/base/common/date'; import { ActivationKind, IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; -import { isWeb } from 'vs/base/common/platform'; - -const VSO_ALLOWED_EXTENSIONS = ['github.vscode-pull-request-github', 'github.vscode-pull-request-github-insiders', 'vscode.git', 'ms-vsonline.vsonline', 'vscode.github-browser', 'ms-vscode.github-browser', 'github.codespaces']; - -interface IAccountUsage { - extensionId: string; - extensionName: string; - lastUsed: number; -} - -function readAccountUsages(storageService: IStorageService, providerId: string, accountName: string,): IAccountUsage[] { - const accountKey = `${providerId}-${accountName}-usages`; - const storedUsages = storageService.get(accountKey, StorageScope.GLOBAL); - let usages: IAccountUsage[] = []; - if (storedUsages) { - try { - usages = JSON.parse(storedUsages); - } catch (e) { - // ignore - } - } - - return usages; -} - -function removeAccountUsage(storageService: IStorageService, providerId: string, accountName: string): void { - const accountKey = `${providerId}-${accountName}-usages`; - storageService.remove(accountKey, StorageScope.GLOBAL); -} - -function addAccountUsage(storageService: IStorageService, providerId: string, accountName: string, extensionId: string, extensionName: string) { - const accountKey = `${providerId}-${accountName}-usages`; - const usages = readAccountUsages(storageService, providerId, accountName); - - const existingUsageIndex = usages.findIndex(usage => usage.extensionId === extensionId); - if (existingUsageIndex > -1) { - usages.splice(existingUsageIndex, 1, { - extensionId, - extensionName, - lastUsed: Date.now() - }); - } else { - usages.push({ - extensionId, - extensionName, - lastUsed: Date.now() - }); - } - - storageService.store(accountKey, JSON.stringify(usages), StorageScope.GLOBAL, StorageTarget.MACHINE); -} export class MainThreadAuthenticationProvider extends Disposable { private _accounts = new Map(); // Map account name to session ids @@ -220,7 +168,6 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu @IDialogService private readonly dialogService: IDialogService, @IStorageService private readonly storageService: IStorageService, @INotificationService private readonly notificationService: INotificationService, - @IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService, @IQuickInputService private readonly quickInputService: IQuickInputService, @IExtensionService private readonly extensionService: IExtensionService ) { @@ -246,10 +193,6 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu })); } - $getProviderIds(): Promise { - return Promise.resolve(this.authenticationService.getProviderIds()); - } - async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): Promise { const provider = new MainThreadAuthenticationProvider(this._proxy, id, label, supportsMultipleAccounts, this.notificationService, this.storageService, this.quickInputService, this.dialogService); await provider.initialize(); @@ -268,172 +211,17 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu this.authenticationService.sessionsUpdate(id, event); } - $getSessions(id: string): Promise> { - return this.authenticationService.getSessions(id); - } - - $login(providerId: string, scopes: string[]): Promise { - return this.authenticationService.login(providerId, scopes); - } - $logout(providerId: string, sessionId: string): Promise { return this.authenticationService.logout(providerId, sessionId); } - async $requestNewSession(providerId: string, scopes: string[], extensionId: string, extensionName: string): Promise { - return this.authenticationService.requestNewSession(providerId, scopes, extensionId, extensionName); - } - - async $getSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, options: { createIfNone: boolean, clearSessionPreference: boolean }): Promise { - const orderedScopes = scopes.sort().join(' '); - const sessions = (await this.$getSessions(providerId)).filter(session => session.scopes.slice().sort().join(' ') === orderedScopes); - const label = this.authenticationService.getLabel(providerId); - - if (sessions.length) { - if (!this.authenticationService.supportsMultipleAccounts(providerId)) { - const session = sessions[0]; - const allowed = await this.$getSessionsPrompt(providerId, session.account.label, label, extensionId, extensionName); - if (allowed) { - return session; - } else { - throw new Error('User did not consent to login.'); - } - } - - // On renderer side, confirm consent, ask user to choose between accounts if multiple sessions are valid - const selected = await this.$selectSession(providerId, label, extensionId, extensionName, sessions, scopes, !!options.clearSessionPreference); - return sessions.find(session => session.id === selected.id); - } else { - if (options.createIfNone) { - const isAllowed = await this.$loginPrompt(label, extensionName); - if (!isAllowed) { - throw new Error('User did not consent to login.'); - } - - const session = await this.authenticationService.login(providerId, scopes); - await this.$setTrustedExtensionAndAccountPreference(providerId, session.account.label, extensionId, extensionName, session.id); - return session; - } else { - await this.$requestNewSession(providerId, scopes, extensionId, extensionName); - return undefined; - } - } - } - - async $selectSession(providerId: string, providerName: string, extensionId: string, extensionName: string, potentialSessions: modes.AuthenticationSession[], scopes: string[], clearSessionPreference: boolean): Promise { - if (!potentialSessions.length) { - throw new Error('No potential sessions found'); - } - - if (clearSessionPreference) { - this.storageService.remove(`${extensionName}-${providerId}`, StorageScope.GLOBAL); - } else { - const existingSessionPreference = this.storageService.get(`${extensionName}-${providerId}`, StorageScope.GLOBAL); - if (existingSessionPreference) { - const matchingSession = potentialSessions.find(session => session.id === existingSessionPreference); - if (matchingSession) { - const allowed = await this.$getSessionsPrompt(providerId, matchingSession.account.label, providerName, extensionId, extensionName); - if (allowed) { - return matchingSession; - } - } - } - } - - return new Promise((resolve, reject) => { - const quickPick = this.quickInputService.createQuickPick<{ label: string, session?: modes.AuthenticationSession }>(); - quickPick.ignoreFocusOut = true; - const items: { label: string, session?: modes.AuthenticationSession }[] = potentialSessions.map(session => { - return { - label: session.account.label, - session - }; - }); - - items.push({ - label: nls.localize('useOtherAccount', "Sign in to another account") - }); - - quickPick.items = items; - quickPick.title = nls.localize( - { - key: 'selectAccount', - comment: ['The placeholder {0} is the name of an extension. {1} is the name of the type of account, such as Microsoft or GitHub.'] - }, - "The extension '{0}' wants to access a {1} account", - extensionName, - providerName); - quickPick.placeholder = nls.localize('getSessionPlateholder', "Select an account for '{0}' to use or Esc to cancel", extensionName); - - quickPick.onDidAccept(async _ => { - const selected = quickPick.selectedItems[0]; - - const session = selected.session ?? await this.authenticationService.login(providerId, scopes); - - const accountName = session.account.label; - - const allowList = readAllowedExtensions(this.storageService, providerId, accountName); - if (!allowList.find(allowed => allowed.id === extensionId)) { - allowList.push({ id: extensionId, name: extensionName }); - this.storageService.store(`${providerId}-${accountName}`, JSON.stringify(allowList), StorageScope.GLOBAL, StorageTarget.USER); - } - - this.storageService.store(`${extensionName}-${providerId}`, session.id, StorageScope.GLOBAL, StorageTarget.MACHINE); - - quickPick.dispose(); - resolve(session); - }); - - quickPick.onDidHide(_ => { - if (!quickPick.selectedItems[0]) { - reject('User did not consent to account access'); - } - - quickPick.dispose(); - }); - - quickPick.show(); - }); - } - - async $getSessionsPrompt(providerId: string, accountName: string, providerName: string, extensionId: string, extensionName: string): Promise { + private isAccessAllowed(providerId: string, accountName: string, extensionId: string): boolean { const allowList = readAllowedExtensions(this.storageService, providerId, accountName); const extensionData = allowList.find(extension => extension.id === extensionId); - if (extensionData) { - addAccountUsage(this.storageService, providerId, accountName, extensionId, extensionName); - return true; - } - - const remoteConnection = this.remoteAgentService.getConnection(); - const isVSO = remoteConnection !== null - ? remoteConnection.remoteAuthority.startsWith('vsonline') || remoteConnection.remoteAuthority.startsWith('codespaces') - : isWeb; - - if (isVSO && VSO_ALLOWED_EXTENSIONS.includes(extensionId)) { - addAccountUsage(this.storageService, providerId, accountName, extensionId, extensionName); - return true; - } - - const { choice } = await this.dialogService.show( - Severity.Info, - nls.localize('confirmAuthenticationAccess', "The extension '{0}' wants to access the {1} account '{2}'.", extensionName, providerName, accountName), - [nls.localize('allow', "Allow"), nls.localize('cancel', "Cancel")], - { - cancelId: 1 - } - ); - - const allow = choice === 0; - if (allow) { - addAccountUsage(this.storageService, providerId, accountName, extensionId, extensionName); - allowList.push({ id: extensionId, name: extensionName }); - this.storageService.store(`${providerId}-${accountName}`, JSON.stringify(allowList), StorageScope.GLOBAL, StorageTarget.USER); - } - - return allow; + return !!extensionData; } - async $loginPrompt(providerName: string, extensionName: string): Promise { + private async loginPrompt(providerName: string, extensionName: string): Promise { const { choice } = await this.dialogService.show( Severity.Info, nls.localize('confirmLogin', "The extension '{0}' wants to sign in using {1}.", extensionName, providerName), @@ -446,7 +234,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu return choice === 0; } - async $setTrustedExtensionAndAccountPreference(providerId: string, accountName: string, extensionId: string, extensionName: string, sessionId: string): Promise { + private async setTrustedExtensionAndAccountPreference(providerId: string, accountName: string, extensionId: string, extensionName: string, sessionId: string): Promise { const allowList = readAllowedExtensions(this.storageService, providerId, accountName); if (!allowList.find(allowed => allowed.id === extensionId)) { allowList.push({ id: extensionId, name: extensionName }); @@ -454,6 +242,77 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu } this.storageService.store(`${extensionName}-${providerId}`, sessionId, StorageScope.GLOBAL, StorageTarget.MACHINE); - addAccountUsage(this.storageService, providerId, accountName, extensionId, extensionName); + + } + + private async selectSession(providerId: string, extensionId: string, extensionName: string, potentialSessions: modes.AuthenticationSession[], clearSessionPreference: boolean): Promise { + if (!potentialSessions.length) { + throw new Error('No potential sessions found'); + } + + if (clearSessionPreference) { + this.storageService.remove(`${extensionName}-${providerId}`, StorageScope.GLOBAL); + } else { + const existingSessionPreference = this.storageService.get(`${extensionName}-${providerId}`, StorageScope.GLOBAL); + if (existingSessionPreference) { + const matchingSession = potentialSessions.find(session => session.id === existingSessionPreference); + if (matchingSession) { + const allowed = await this.authenticationService.showGetSessionPrompt(providerId, matchingSession.account.label, extensionId, extensionName); + if (allowed) { + return matchingSession; + } + } + } + } + + return this.authenticationService.selectSession(providerId, extensionId, extensionName, potentialSessions); + } + + async $getSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, options: { createIfNone: boolean, clearSessionPreference: boolean }): Promise { + const orderedScopes = scopes.sort().join(' '); + const sessions = (await this.authenticationService.getSessions(providerId)).filter(session => session.scopes.slice().sort().join(' ') === orderedScopes); + + const silent = !options.createIfNone; + let session: modes.AuthenticationSession | undefined; + if (sessions.length) { + if (!this.authenticationService.supportsMultipleAccounts(providerId)) { + session = sessions[0]; + const allowed = this.isAccessAllowed(providerId, session.account.label, extensionId); + if (!allowed) { + if (!silent) { + const didAcceptPrompt = await this.authenticationService.showGetSessionPrompt(providerId, session.account.label, extensionId, extensionName); + if (!didAcceptPrompt) { + throw new Error('User did not consent to login.'); + } + } else { + this.authenticationService.requestSessionAccess(providerId, extensionId, extensionName, [session]); + } + } + } else { + if (!silent) { + session = await this.selectSession(providerId, extensionId, extensionName, sessions, !!options.clearSessionPreference); + } else { + this.authenticationService.requestSessionAccess(providerId, extensionId, extensionName, sessions); + } + } + } else { + if (!silent) { + const isAllowed = await this.loginPrompt(providerId, extensionName); + if (!isAllowed) { + throw new Error('User did not consent to login.'); + } + + session = await this.authenticationService.login(providerId, scopes); + await this.setTrustedExtensionAndAccountPreference(providerId, session.account.label, extensionId, extensionName, session.id); + } else { + await this.authenticationService.requestNewSession(providerId, scopes, extensionId, extensionName); + } + } + + if (session) { + addAccountUsage(this.storageService, providerId, session.account.label, extensionId, extensionName); + } + + return session; } } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 3488d18f5e8..e2c1e6fbf8f 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -165,17 +165,8 @@ export interface MainThreadAuthenticationShape extends IDisposable { $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): void; $unregisterAuthenticationProvider(id: string): void; $ensureProvider(id: string): Promise; - $getProviderIds(): Promise; $sendDidChangeSessions(providerId: string, event: modes.AuthenticationSessionsChangeEvent): void; $getSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, options: { createIfNone?: boolean, clearSessionPreference?: boolean }): Promise; - $selectSession(providerId: string, providerName: string, extensionId: string, extensionName: string, potentialSessions: modes.AuthenticationSession[], scopes: string[], clearSessionPreference: boolean): Promise; - $getSessionsPrompt(providerId: string, accountName: string, providerName: string, extensionId: string, extensionName: string): Promise; - $loginPrompt(providerName: string, extensionName: string): Promise; - $setTrustedExtensionAndAccountPreference(providerId: string, accountName: string, extensionId: string, extensionName: string, sessionId: string): Promise; - $requestNewSession(providerId: string, scopes: string[], extensionId: string, extensionName: string): Promise; - - $getSessions(providerId: string): Promise>; - $login(providerId: string, scopes: string[]): Promise; $logout(providerId: string, sessionId: string): Promise; } diff --git a/src/vs/workbench/api/common/extHostAuthentication.ts b/src/vs/workbench/api/common/extHostAuthentication.ts index 7376e9c66cf..5a3aab45657 100644 --- a/src/vs/workbench/api/common/extHostAuthentication.ts +++ b/src/vs/workbench/api/common/extHostAuthentication.ts @@ -83,45 +83,8 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { private async _getSession(requestingExtension: IExtensionDescription, extensionId: string, providerId: string, scopes: string[], options: vscode.AuthenticationGetSessionOptions = {}): Promise { await this._proxy.$ensureProvider(providerId); - const providerData = this._authenticationProviders.get(providerId); const extensionName = requestingExtension.displayName || requestingExtension.name; - - if (!providerData) { - return this._proxy.$getSession(providerId, scopes, extensionId, extensionName, options); - } - - const orderedScopes = scopes.sort().join(' '); - const sessions = (await providerData.provider.getSessions()).filter(session => session.scopes.slice().sort().join(' ') === orderedScopes); - - let session: vscode.AuthenticationSession | undefined = undefined; - if (sessions.length) { - if (!providerData.options.supportsMultipleAccounts) { - session = sessions[0]; - const allowed = await this._proxy.$getSessionsPrompt(providerId, session.account.label, providerData.label, extensionId, extensionName); - if (!allowed) { - throw new Error('User did not consent to login.'); - } - } else { - // On renderer side, confirm consent, ask user to choose between accounts if multiple sessions are valid - const selected = await this._proxy.$selectSession(providerId, providerData.label, extensionId, extensionName, sessions, scopes, !!options.clearSessionPreference); - session = sessions.find(session => session.id === selected.id); - } - - } else { - if (options.createIfNone) { - const isAllowed = await this._proxy.$loginPrompt(providerData.label, extensionName); - if (!isAllowed) { - throw new Error('User did not consent to login.'); - } - - session = await providerData.provider.login(scopes); - await this._proxy.$setTrustedExtensionAndAccountPreference(providerId, session.account.label, extensionId, extensionName, session.id); - } else { - await this._proxy.$requestNewSession(providerId, scopes, extensionId, extensionName); - } - } - - return session; + return this._proxy.$getSession(providerId, scopes, extensionId, extensionName, options); } async logout(providerId: string, sessionId: string): Promise { diff --git a/src/vs/workbench/services/authentication/browser/authenticationService.ts b/src/vs/workbench/services/authentication/browser/authenticationService.ts index f28d3149cf2..e8b2623a6da 100644 --- a/src/vs/workbench/services/authentication/browser/authenticationService.ts +++ b/src/vs/workbench/services/authentication/browser/authenticationService.ts @@ -23,9 +23,64 @@ import { IJSONSchema } from 'vs/base/common/jsonSchema'; import { flatten } from 'vs/base/common/arrays'; import { isFalsyOrWhitespace } from 'vs/base/common/strings'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { Severity } from 'vs/platform/notification/common/notification'; +import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; +import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; +import { isWeb } from 'vs/base/common/platform'; export function getAuthenticationProviderActivationEvent(id: string): string { return `onAuthenticationRequest:${id}`; } +export interface IAccountUsage { + extensionId: string; + extensionName: string; + lastUsed: number; +} + +const VSO_ALLOWED_EXTENSIONS = ['github.vscode-pull-request-github', 'github.vscode-pull-request-github-insiders', 'vscode.git', 'ms-vsonline.vsonline', 'vscode.github-browser', 'ms-vscode.github-browser', 'github.codespaces']; + +export function readAccountUsages(storageService: IStorageService, providerId: string, accountName: string,): IAccountUsage[] { + const accountKey = `${providerId}-${accountName}-usages`; + const storedUsages = storageService.get(accountKey, StorageScope.GLOBAL); + let usages: IAccountUsage[] = []; + if (storedUsages) { + try { + usages = JSON.parse(storedUsages); + } catch (e) { + // ignore + } + } + + return usages; +} + +export function removeAccountUsage(storageService: IStorageService, providerId: string, accountName: string): void { + const accountKey = `${providerId}-${accountName}-usages`; + storageService.remove(accountKey, StorageScope.GLOBAL); +} + +export function addAccountUsage(storageService: IStorageService, providerId: string, accountName: string, extensionId: string, extensionName: string) { + const accountKey = `${providerId}-${accountName}-usages`; + const usages = readAccountUsages(storageService, providerId, accountName); + + const existingUsageIndex = usages.findIndex(usage => usage.extensionId === extensionId); + if (existingUsageIndex > -1) { + usages.splice(existingUsageIndex, 1, { + extensionId, + extensionName, + lastUsed: Date.now() + }); + } else { + usages.push({ + extensionId, + extensionName, + lastUsed: Date.now() + }); + } + + storageService.store(accountKey, JSON.stringify(usages), StorageScope.GLOBAL, StorageTarget.MACHINE); +} + export type AuthenticationSessionInfo = { readonly id: string, readonly accessToken: string, readonly providerId: string, readonly canSignOut?: boolean }; export async function getCurrentAuthenticationSessionInfo(environmentService: IWorkbenchEnvironmentService, productService: IProductService): Promise { if (environmentService.options?.credentialsProvider) { @@ -53,7 +108,11 @@ export interface IAuthenticationService { getProviderIds(): string[]; registerAuthenticationProvider(id: string, provider: MainThreadAuthenticationProvider): void; unregisterAuthenticationProvider(id: string): void; - requestNewSession(id: string, scopes: string[], extensionId: string, extensionName: string): void; + showGetSessionPrompt(providerId: string, accountName: string, extensionId: string, extensionName: string): Promise; + selectSession(providerId: string, extensionId: string, extensionName: string, possibleSessions: AuthenticationSession[]): Promise; + requestSessionAccess(providerId: string, extensionId: string, extensionName: string, possibleSessions: AuthenticationSession[]): void; + completeSessionAccessRequest(providerId: string, extensionId: string, extensionName: string): Promise + requestNewSession(providerId: string, scopes: string[], extensionId: string, extensionName: string): Promise; sessionsUpdate(providerId: string, event: AuthenticationSessionsChangeEvent): void; readonly onDidRegisterAuthenticationProvider: Event; @@ -134,6 +193,7 @@ export class AuthenticationService extends Disposable implements IAuthentication private _placeholderMenuItem: IDisposable | undefined; private _noAccountsMenuItem: IDisposable | undefined; private _signInRequestItems = new Map(); + private _sessionAccessRequestItems = new Map(); private _accountBadgeDisposable = this._register(new MutableDisposable()); private _authenticationProviders: Map = new Map(); @@ -157,7 +217,11 @@ export class AuthenticationService extends Disposable implements IAuthentication constructor( @IActivityService private readonly activityService: IActivityService, - @IExtensionService private readonly extensionService: IExtensionService + @IExtensionService private readonly extensionService: IExtensionService, + @IStorageService private readonly storageService: IStorageService, + @IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService, + @IDialogService private readonly dialogService: IDialogService, + @IQuickInputService private readonly quickInputService: IQuickInputService ) { super(); this._placeholderMenuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, { @@ -255,6 +319,13 @@ export class AuthenticationService extends Disposable implements IAuthentication this._authenticationProviders.delete(id); this._onDidUnregisterAuthenticationProvider.fire({ id, label: provider.label }); this.updateAccountsMenuItem(); + + const accessRequests = this._sessionAccessRequestItems.get(id) || {}; + Object.keys(accessRequests).forEach(extensionId => { + this.removeAccessRequest(id, extensionId); + }); + + this.updateBadgeCount(); } if (!this._authenticationProviders.size) { @@ -278,6 +349,12 @@ export class AuthenticationService extends Disposable implements IAuthentication if (event.added) { await this.updateNewSessionRequests(provider); } + + if (event.removed) { + await this.updateAccessRequests(id, event.removed); + } + + this.updateBadgeCount(); } } @@ -288,12 +365,9 @@ export class AuthenticationService extends Disposable implements IAuthentication } const sessions = await provider.getSessions(); - let changed = false; Object.keys(existingRequestsForProvider).forEach(requestedScopes => { if (sessions.some(session => session.scopes.slice().sort().join('') === requestedScopes)) { - // Request has been completed - changed = true; const sessionRequest = existingRequestsForProvider[requestedScopes]; sessionRequest?.disposables.forEach(item => item.dispose()); @@ -305,22 +379,214 @@ export class AuthenticationService extends Disposable implements IAuthentication } } }); + } - if (changed) { - this._accountBadgeDisposable.clear(); - - if (this._signInRequestItems.size > 0) { - let numberOfRequests = 0; - this._signInRequestItems.forEach(providerRequests => { - Object.keys(providerRequests).forEach(request => { - numberOfRequests += providerRequests[request].requestingExtensionIds.length; - }); + private async updateAccessRequests(providerId: string, removedSessionIds: readonly string[]) { + const providerRequests = this._sessionAccessRequestItems.get(providerId); + if (providerRequests) { + Object.keys(providerRequests).forEach(extensionId => { + removedSessionIds.forEach(removedId => { + const indexOfSession = providerRequests[extensionId].possibleSessions.findIndex(session => session.id === removedId); + if (indexOfSession) { + providerRequests[extensionId].possibleSessions.splice(indexOfSession, 1); + } }); - const badge = new NumberBadge(numberOfRequests, () => nls.localize('sign in', "Sign in requested")); - this._accountBadgeDisposable.value = this.activityService.showAccountsActivity({ badge }); + if (!providerRequests[extensionId].possibleSessions.length) { + this.removeAccessRequest(providerId, extensionId); + } + }); + } + } + + private updateBadgeCount(): void { + this._accountBadgeDisposable.clear(); + + let numberOfRequests = 0; + this._signInRequestItems.forEach(providerRequests => { + Object.keys(providerRequests).forEach(request => { + numberOfRequests += providerRequests[request].requestingExtensionIds.length; + }); + }); + + this._sessionAccessRequestItems.forEach(accessRequest => { + numberOfRequests += Object.keys(accessRequest).length; + }); + + if (numberOfRequests > 0) { + const badge = new NumberBadge(numberOfRequests, () => nls.localize('sign in', "Sign in requested")); + this._accountBadgeDisposable.value = this.activityService.showAccountsActivity({ badge }); + } + } + + private removeAccessRequest(providerId: string, extensionId: string): void { + const providerRequests = this._sessionAccessRequestItems.get(providerId) || {}; + if (providerRequests[extensionId]) { + providerRequests[extensionId].disposables.forEach(d => d.dispose()); + delete providerRequests[extensionId]; + } + } + + async showGetSessionPrompt(providerId: string, accountName: string, extensionId: string, extensionName: string): Promise { + const allowList = readAllowedExtensions(this.storageService, providerId, accountName); + const extensionData = allowList.find(extension => extension.id === extensionId); + if (extensionData) { + return true; + } + + const remoteConnection = this.remoteAgentService.getConnection(); + const isVSO = remoteConnection !== null + ? remoteConnection.remoteAuthority.startsWith('vsonline') || remoteConnection.remoteAuthority.startsWith('codespaces') + : isWeb; + + if (isVSO && VSO_ALLOWED_EXTENSIONS.includes(extensionId)) { + return true; + } + + const providerName = this.getLabel(providerId); + const { choice } = await this.dialogService.show( + Severity.Info, + nls.localize('confirmAuthenticationAccess', "The extension '{0}' wants to access the {1} account '{2}'.", extensionName, providerName, accountName), + [nls.localize('allow', "Allow"), nls.localize('cancel', "Cancel")], + { + cancelId: 1 + } + ); + + const allow = choice === 0; + if (allow) { + allowList.push({ id: extensionId, name: extensionName }); + this.storageService.store(`${providerId}-${accountName}`, JSON.stringify(allowList), StorageScope.GLOBAL, StorageTarget.USER); + this.removeAccessRequest(providerId, extensionId); + } + + return allow; + } + + async selectSession(providerId: string, extensionId: string, extensionName: string, availableSessions: AuthenticationSession[]): Promise { + return new Promise((resolve, reject) => { + // This function should be used only when there are sessions to disambiguate. + if (!availableSessions.length) { + reject('No available sessions'); + } + + const quickPick = this.quickInputService.createQuickPick<{ label: string, session?: AuthenticationSession }>(); + quickPick.ignoreFocusOut = true; + const items: { label: string, session?: AuthenticationSession }[] = availableSessions.map(session => { + return { + label: session.account.label, + session: session + }; + }); + + items.push({ + label: nls.localize('useOtherAccount', "Sign in to another account") + }); + + const providerName = this.getLabel(providerId); + + quickPick.items = items; + + quickPick.title = nls.localize( + { + key: 'selectAccount', + comment: ['The placeholder {0} is the name of an extension. {1} is the name of the type of account, such as Microsoft or GitHub.'] + }, + "The extension '{0}' wants to access a {1} account", + extensionName, + providerName); + quickPick.placeholder = nls.localize('getSessionPlateholder', "Select an account for '{0}' to use or Esc to cancel", extensionName); + + quickPick.onDidAccept(async _ => { + const session = quickPick.selectedItems[0].session ?? await this.login(providerId, availableSessions[0].scopes as string[]); + const accountName = session.account.label; + + const allowList = readAllowedExtensions(this.storageService, providerId, accountName); + if (!allowList.find(allowed => allowed.id === extensionId)) { + allowList.push({ id: extensionId, name: extensionName }); + this.storageService.store(`${providerId}-${accountName}`, JSON.stringify(allowList), StorageScope.GLOBAL, StorageTarget.USER); + this.removeAccessRequest(providerId, extensionId); + } + + this.storageService.store(`${extensionName}-${providerId}`, session.id, StorageScope.GLOBAL, StorageTarget.MACHINE); + + quickPick.dispose(); + resolve(session); + }); + + quickPick.onDidHide(_ => { + if (!quickPick.selectedItems[0]) { + reject('User did not consent to account access'); + } + + quickPick.dispose(); + }); + + quickPick.show(); + }); + } + + async completeSessionAccessRequest(providerId: string, extensionId: string, extensionName: string): Promise { + const providerRequests = this._sessionAccessRequestItems.get(providerId) || {}; + const existingRequest = providerRequests[extensionId]; + if (!existingRequest) { + return; + } + + const possibleSessions = existingRequest.possibleSessions; + const supportsMultipleAccounts = this.supportsMultipleAccounts(providerId); + + let session: AuthenticationSession | undefined; + if (supportsMultipleAccounts) { + try { + session = await this.selectSession(providerId, extensionId, extensionName, possibleSessions); + } catch (_) { + // ignore cancel + } + } else { + const approved = await this.showGetSessionPrompt(providerId, possibleSessions[0].account.label, extensionId, extensionName); + if (approved) { + session = possibleSessions[0]; } } + + if (session) { + addAccountUsage(this.storageService, providerId, session.account.label, extensionId, extensionName); + const providerName = this.getLabel(providerId); + this._onDidChangeSessions.fire({ providerId, label: providerName, event: { added: [], removed: [], changed: [session.id] } }); + } + } + + requestSessionAccess(providerId: string, extensionId: string, extensionName: string, possibleSessions: AuthenticationSession[]): void { + const providerRequests = this._sessionAccessRequestItems.get(providerId) || {}; + const hasExistingRequest = providerRequests[extensionId]; + if (hasExistingRequest) { + return; + } + + const menuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, { + group: '3_accessRequests', + command: { + id: `${providerId}${extensionId}Access`, + title: nls.localize({ + key: 'accessRequest', + comment: ['The placeholder {0} will be replaced with an extension name. (1) is to indicate that this menu item contributes to a badge count'] + }, + "Grant access to {0}... (1)", extensionName) + } + }); + + const accessCommand = CommandsRegistry.registerCommand({ + id: `${providerId}${extensionId}Access`, + handler: async (accessor) => { + const authenticationService = accessor.get(IAuthenticationService); + authenticationService.completeSessionAccessRequest(providerId, extensionId, extensionName); + } + }); + + providerRequests[extensionId] = { possibleSessions, disposables: [menuItem, accessCommand] }; + this._sessionAccessRequestItems.set(providerId, providerRequests); + this.updateBadgeCount(); } async requestNewSession(providerId: string, scopes: string[], extensionId: string, extensionName: string): Promise {