/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap } from 'vs/base/common/lifecycle'; import * as nls from 'vs/nls'; import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; import { IAuthenticationCreateSessionOptions, AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService } from 'vs/workbench/services/authentication/common/authentication'; import { ExtHostAuthenticationShape, ExtHostContext, MainContext, MainThreadAuthenticationShape } from '../common/extHost.protocol'; import { IDialogService, IPromptButton } from 'vs/platform/dialogs/common/dialogs'; import Severity from 'vs/base/common/severity'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { ActivationKind, IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { Emitter, Event } from 'vs/base/common/event'; import { IAuthenticationAccessService } from 'vs/workbench/services/authentication/browser/authenticationAccessService'; import { IAuthenticationUsageService } from 'vs/workbench/services/authentication/browser/authenticationUsageService'; import { getAuthenticationProviderActivationEvent } from 'vs/workbench/services/authentication/browser/authenticationService'; import { URI, UriComponents } from 'vs/base/common/uri'; import { IOpenerService } from 'vs/platform/opener/common/opener'; interface AuthenticationForceNewSessionOptions { detail?: string; learnMore?: UriComponents; sessionToRecreate?: AuthenticationSession; } interface AuthenticationGetSessionOptions { clearSessionPreference?: boolean; createIfNone?: boolean; forceNewSession?: boolean | AuthenticationForceNewSessionOptions; silent?: boolean; } export class MainThreadAuthenticationProvider extends Disposable implements IAuthenticationProvider { readonly onDidChangeSessions: Event; constructor( private readonly _proxy: ExtHostAuthenticationShape, public readonly id: string, public readonly label: string, public readonly supportsMultipleAccounts: boolean, private readonly notificationService: INotificationService, onDidChangeSessionsEmitter: Emitter, ) { super(); this.onDidChangeSessions = onDidChangeSessionsEmitter.event; } async getSessions(scopes?: string[]) { return this._proxy.$getSessions(this.id, scopes); } createSession(scopes: string[], options: IAuthenticationCreateSessionOptions): Promise { return this._proxy.$createSession(this.id, scopes, options); } async removeSession(sessionId: string): Promise { await this._proxy.$removeSession(this.id, sessionId); this.notificationService.info(nls.localize('signedOut', "Successfully signed out.")); } } @extHostNamedCustomer(MainContext.MainThreadAuthentication) export class MainThreadAuthentication extends Disposable implements MainThreadAuthenticationShape { private readonly _proxy: ExtHostAuthenticationShape; private readonly _registrations = this._register(new DisposableMap()); constructor( extHostContext: IExtHostContext, @IAuthenticationService private readonly authenticationService: IAuthenticationService, @IAuthenticationExtensionsService private readonly authenticationExtensionsService: IAuthenticationExtensionsService, @IAuthenticationAccessService private readonly authenticationAccessService: IAuthenticationAccessService, @IAuthenticationUsageService private readonly authenticationUsageService: IAuthenticationUsageService, @IDialogService private readonly dialogService: IDialogService, @INotificationService private readonly notificationService: INotificationService, @IExtensionService private readonly extensionService: IExtensionService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IOpenerService private readonly openerService: IOpenerService ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAuthentication); this._register(this.authenticationService.onDidChangeSessions(e => { this._proxy.$onDidChangeAuthenticationSessions(e.providerId, e.label); })); } async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): Promise { const emitter = new Emitter(); this._registrations.set(id, emitter); const provider = new MainThreadAuthenticationProvider(this._proxy, id, label, supportsMultipleAccounts, this.notificationService, emitter); this.authenticationService.registerAuthenticationProvider(id, provider); } $unregisterAuthenticationProvider(id: string): void { this._registrations.deleteAndDispose(id); this.authenticationService.unregisterAuthenticationProvider(id); } async $ensureProvider(id: string): Promise { if (!this.authenticationService.isAuthenticationProviderRegistered(id)) { return await this.extensionService.activateByEvent(getAuthenticationProviderActivationEvent(id), ActivationKind.Immediate); } } $sendDidChangeSessions(providerId: string, event: AuthenticationSessionsChangeEvent): void { const obj = this._registrations.get(providerId); if (obj instanceof Emitter) { obj.fire(event); } } $removeSession(providerId: string, sessionId: string): Promise { return this.authenticationService.removeSession(providerId, sessionId); } private async loginPrompt(providerName: string, extensionName: string, recreatingSession: boolean, options?: AuthenticationForceNewSessionOptions): Promise { const message = recreatingSession ? nls.localize('confirmRelogin', "The extension '{0}' wants you to sign in again using {1}.", extensionName, providerName) : nls.localize('confirmLogin', "The extension '{0}' wants to sign in using {1}.", extensionName, providerName); const buttons: IPromptButton[] = [ { label: nls.localize({ key: 'allow', comment: ['&& denotes a mnemonic'] }, "&&Allow"), run() { return true; }, } ]; if (options?.learnMore) { buttons.push({ label: nls.localize('learnMore', "Learn more"), run: async () => { const result = this.loginPrompt(providerName, extensionName, recreatingSession, options); await this.openerService.open(URI.revive(options.learnMore!), { allowCommands: true }); return await result; } }); } const { result } = await this.dialogService.prompt({ type: Severity.Info, message, buttons, detail: options?.detail, cancelButton: true, }); return result ?? false; } private async doGetSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise { const sessions = await this.authenticationService.getSessions(providerId, scopes, true); const provider = this.authenticationService.getProvider(providerId); // Error cases if (options.forceNewSession && options.createIfNone) { throw new Error('Invalid combination of options. Please remove one of the following: forceNewSession, createIfNone'); } if (options.forceNewSession && options.silent) { throw new Error('Invalid combination of options. Please remove one of the following: forceNewSession, silent'); } if (options.createIfNone && options.silent) { throw new Error('Invalid combination of options. Please remove one of the following: createIfNone, silent'); } // Check if the sessions we have are valid if (!options.forceNewSession && sessions.length) { if (provider.supportsMultipleAccounts) { 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); } else { // 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)) { return sessions[0]; } } // We may need to prompt because we don't have a valid session // modal flows if (options.createIfNone || options.forceNewSession) { let uiOptions: AuthenticationForceNewSessionOptions | undefined; if (typeof options.forceNewSession === 'object') { uiOptions = options.forceNewSession; } // We only want to show the "recreating session" prompt if we are using forceNewSession & there are sessions // that we will be "forcing through". const recreatingSession = !!(options.forceNewSession && sessions.length); const isAllowed = await this.loginPrompt(provider.label, extensionName, recreatingSession, uiOptions); if (!isAllowed) { throw new Error('User did not consent to login.'); } let session; if (sessions?.length && !options.forceNewSession) { session = provider.supportsMultipleAccounts ? await this.authenticationExtensionsService.selectSession(providerId, extensionId, extensionName, scopes, sessions) : sessions[0]; } else { let sessionToRecreate: AuthenticationSession | undefined; if (typeof options.forceNewSession === 'object' && options.forceNewSession.sessionToRecreate) { sessionToRecreate = options.forceNewSession.sessionToRecreate as AuthenticationSession; } else { const sessionIdToRecreate = this.authenticationExtensionsService.getSessionPreference(providerId, extensionId, scopes); sessionToRecreate = sessionIdToRecreate ? sessions.find(session => session.id === sessionIdToRecreate) : undefined; } session = await this.authenticationService.createSession(providerId, scopes, { activateImmediate: true, sessionToRecreate }); } this.authenticationAccessService.updateAllowedExtensions(providerId, session.account.label, [{ id: extensionId, name: extensionName, allowed: true }]); this.authenticationExtensionsService.updateSessionPreference(providerId, extensionId, 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; } // passive flows (silent or default) if (!options.silent) { // If there is a potential session, but the extension doesn't have access to it, use the "grant access" flow, // otherwise request a new one. sessions.length ? this.authenticationExtensionsService.requestSessionAccess(providerId, extensionId, extensionName, scopes, sessions) : await this.authenticationExtensionsService.requestNewSession(providerId, scopes, extensionId, extensionName); } return undefined; } async $getSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise { const session = await this.doGetSession(providerId, scopes, extensionId, extensionName, options); if (session) { this.sendProviderUsageTelemetry(extensionId, providerId); this.authenticationUsageService.addAccountUsage(providerId, session.account.label, extensionId, extensionName); } return session; } async $getSessions(providerId: string, scopes: readonly string[], extensionId: string, extensionName: string): Promise { const sessions = await this.authenticationService.getSessions(providerId, [...scopes], true); const accessibleSessions = sessions.filter(s => this.authenticationAccessService.isAccessAllowed(providerId, s.account.label, extensionId)); if (accessibleSessions.length) { this.sendProviderUsageTelemetry(extensionId, providerId); for (const session of accessibleSessions) { this.authenticationUsageService.addAccountUsage(providerId, session.account.label, extensionId, extensionName); } } return accessibleSessions; } private sendProviderUsageTelemetry(extensionId: string, providerId: string): void { type AuthProviderUsageClassification = { owner: 'TylerLeonhardt'; comment: 'Used to see which extensions are using which providers'; extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id.' }; providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider id.' }; }; this.telemetryService.publicLog2<{ extensionId: string; providerId: string }, AuthProviderUsageClassification>('authentication.providerUsage', { providerId, extensionId }); } }