// ***************************************************************************** // Copyright (C) 2020 Red Hat, Inc. and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ // code copied and modified from https://github.com/microsoft/vscode/blob/1.47.3/src/vs/workbench/api/browser/mainThreadAuthentication.ts import { interfaces } from '@theia/core/shared/inversify'; import { AuthenticationExt, AuthenticationMain, MAIN_RPC_CONTEXT } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; import { MessageService } from '@theia/core/lib/common/message-service'; import { ConfirmDialog, Dialog, StorageService } from '@theia/core/lib/browser'; import { Disposable } from '@theia/core/lib/common/disposable'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { AuthenticationProvider, AuthenticationProviderSessionOptions, AuthenticationService, AuthenticationSession, AuthenticationSessionAccountInformation, readAllowedExtensions } from '@theia/core/lib/browser/authentication-service'; import { QuickPickService } from '@theia/core/lib/common/quick-pick-service'; import * as theia from '@theia/plugin'; import { QuickPickValue } from '@theia/core/lib/browser/quick-input/quick-input-service'; import { nls } from '@theia/core/lib/common/nls'; import { isObject } from '@theia/core'; export function getAuthenticationProviderActivationEvent(id: string): string { return `onAuthenticationRequest:${id}`; } export class AuthenticationMainImpl implements AuthenticationMain { private readonly proxy: AuthenticationExt; private readonly messageService: MessageService; private readonly storageService: StorageService; private readonly authenticationService: AuthenticationService; private readonly quickPickService: QuickPickService; private readonly providers: Map = new Map(); constructor(rpc: RPCProtocol, container: interfaces.Container) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.AUTHENTICATION_EXT); this.messageService = container.get(MessageService); this.storageService = container.get(StorageService); this.authenticationService = container.get(AuthenticationService); this.quickPickService = container.get(QuickPickService); this.authenticationService.onDidChangeSessions(e => { this.proxy.$onDidChangeAuthenticationSessions({ id: e.providerId, label: e.label }); }); } async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): Promise { const provider = new AuthenticationProviderImpl(this.proxy, id, label, supportsMultipleAccounts, this.storageService, this.messageService); this.providers.set(id, provider); this.authenticationService.registerAuthenticationProvider(id, provider); } async $unregisterAuthenticationProvider(id: string): Promise { this.authenticationService.unregisterAuthenticationProvider(id); const provider = this.providers.get(id); provider?.dispose(); this.providers.delete(id); } async $updateSessions(id: string, event: theia.AuthenticationProviderAuthenticationSessionsChangeEvent): Promise { this.authenticationService.updateSessions(id, event); } $logout(providerId: string, sessionId: string): Promise { return this.authenticationService.logout(providerId, sessionId); } $getAccounts(providerId: string): Thenable { return this.authenticationService.getSessions(providerId).then(sessions => sessions.map(session => session.account)); } async $getSession(providerId: string, scopeListOrRequest: ReadonlyArray | theia.AuthenticationWwwAuthenticateRequest, extensionId: string, extensionName: string, options: theia.AuthenticationGetSessionOptions): Promise { const sessions = await this.authenticationService.getSessions(providerId, scopeListOrRequest, options?.account); // 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'); } const supportsMultipleAccounts = this.authenticationService.supportsMultipleAccounts(providerId); // Check if the sessions we have are valid if (!options.forceNewSession && sessions.length) { if (supportsMultipleAccounts) { if (options.clearSessionPreference) { await this.storageService.setData(`authentication-session-${extensionName}-${providerId}`, undefined); } else { const existingSessionPreference = await this.storageService.getData(`authentication-session-${extensionName}-${providerId}`); if (existingSessionPreference) { const matchingSession = sessions.find(session => session.id === existingSessionPreference); if (matchingSession && await this.isAccessAllowed(providerId, matchingSession.account.label, extensionId)) { return matchingSession; } } } } else if (await this.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) { const providerName = this.authenticationService.getLabel(providerId); let detail: string | undefined; if (isAuthenticationGetSessionPresentationOptions(options.forceNewSession)) { detail = options.forceNewSession.detail; } else if (isAuthenticationGetSessionPresentationOptions(options.createIfNone)) { detail = options.createIfNone.detail; } const shouldForceNewSession = !!options.forceNewSession; const recreatingSession = shouldForceNewSession && !sessions.length; const isAllowed = await this.loginPrompt(providerName, extensionName, recreatingSession, detail); if (!isAllowed) { throw new Error('User did not consent to login.'); } const session = sessions?.length && !shouldForceNewSession && supportsMultipleAccounts ? await this.selectSession(providerId, providerName, extensionId, extensionName, sessions, scopeListOrRequest, !!options.clearSessionPreference) : await this.authenticationService.login(providerId, scopeListOrRequest); await this.setTrustedExtensionAndAccountPreference(providerId, session.account.label, extensionId, extensionName, session.id); return session; } // passive flows (silent or default) const validSession = sessions.find(s => this.isAccessAllowed(providerId, s.account.label, extensionId)); if (!options.silent && !validSession) { this.authenticationService.requestNewSession(providerId, scopeListOrRequest, extensionId, extensionName); } return validSession; } protected async selectSession(providerId: string, providerName: string, extensionId: string, extensionName: string, potentialSessions: Readonly, scopeListOrRequest: ReadonlyArray | theia.AuthenticationWwwAuthenticateRequest, clearSessionPreference: boolean): Promise { if (!potentialSessions.length) { throw new Error('No potential sessions found'); } return new Promise(async (resolve, reject) => { const items: QuickPickValue<{ session?: AuthenticationSession, account?: AuthenticationSessionAccountInformation }>[] = potentialSessions.map(session => ({ label: session.account.label, value: { session } })); items.push({ label: nls.localizeByDefault('Sign in to another account'), value: {} }); // VS Code has code here that pushes accounts that have no active sessions. However, since we do not store // any accounts that don't have sessions, we dont' do this. const selected = await this.quickPickService.show(items, { title: nls.localizeByDefault("The extension '{0}' wants to access a {1} account", extensionName, providerName), ignoreFocusOut: true }); if (selected) { // if we ever have accounts without sessions, pass the account to the login call const session = selected.value?.session ?? await this.authenticationService.login(providerId, scopeListOrRequest); const accountName = session.account.label; const allowList = await readAllowedExtensions(this.storageService, providerId, accountName); if (!allowList.find(allowed => allowed.id === extensionId)) { allowList.push({ id: extensionId, name: extensionName }); this.storageService.setData(`authentication-trusted-extensions-${providerId}-${accountName}`, JSON.stringify(allowList)); } this.storageService.setData(`authentication-session-${extensionName}-${providerId}`, session.id); resolve(session); } else { reject('User did not consent to account access'); } }); } protected async getSessionsPrompt(providerId: string, accountName: string, providerName: string, extensionId: string, extensionName: string): Promise { const allowList = await 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 choice = await this.messageService.info(`The extension '${extensionName}' wants to access the ${providerName} account '${accountName}'.`, 'Allow', 'Cancel'); const allow = choice === 'Allow'; if (allow) { await addAccountUsage(this.storageService, providerId, accountName, extensionId, extensionName); allowList.push({ id: extensionId, name: extensionName }); this.storageService.setData(`authentication-trusted-extensions-${providerId}-${accountName}`, JSON.stringify(allowList)); } return allow; } protected async loginPrompt(providerName: string, extensionName: string, recreatingSession: boolean, detail?: string): Promise { const msg = document.createElement('span'); msg.textContent = recreatingSession ? nls.localizeByDefault("The extension '{0}' wants you to sign in again using {1}.", extensionName, providerName) : nls.localizeByDefault("The extension '{0}' wants to sign in using {1}.", extensionName, providerName); if (detail) { const detailElement = document.createElement('p'); detailElement.textContent = detail; msg.appendChild(detailElement); } return !!await new ConfirmDialog({ title: nls.localize('theia/plugin-ext/authentication-main/loginTitle', 'Login'), msg, ok: nls.localizeByDefault('Allow'), cancel: Dialog.CANCEL }).open(); } protected async isAccessAllowed(providerId: string, accountName: string, extensionId: string): Promise { const allowList = await readAllowedExtensions(this.storageService, providerId, accountName); return !!allowList.find(allowed => allowed.id === extensionId); } protected async setTrustedExtensionAndAccountPreference(providerId: string, accountName: string, extensionId: string, extensionName: string, sessionId: string): Promise { const allowList = await readAllowedExtensions(this.storageService, providerId, accountName); if (!allowList.find(allowed => allowed.id === extensionId)) { allowList.push({ id: extensionId, name: extensionName }); this.storageService.setData(`authentication-trusted-extensions-${providerId}-${accountName}`, JSON.stringify(allowList)); } this.storageService.setData(`authentication-session-${extensionName}-${providerId}`, sessionId); } $onDidChangeSessions(providerId: string, event: theia.AuthenticationProviderAuthenticationSessionsChangeEvent): void { const provider = this.providers.get(providerId); if (provider) { provider.fireSessionsChanged(event); } else { console.warn(`No authentication provider found for id '${providerId}' when firing session change event.`); } } } function isAuthenticationGetSessionPresentationOptions(arg: unknown): arg is theia.AuthenticationGetSessionPresentationOptions { return isObject(arg) && typeof arg.detail === 'string'; } async function addAccountUsage(storageService: StorageService, providerId: string, accountName: string, extensionId: string, extensionName: string): Promise { const accountKey = `authentication-${providerId}-${accountName}-usages`; const usages = await 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() }); } await storageService.setData(accountKey, JSON.stringify(usages)); } interface AccountUsage { extensionId: string; extensionName: string; lastUsed: number; } export class AuthenticationProviderImpl implements AuthenticationProvider, Disposable { /** map from account name to session ids */ private accounts = new Map(); /** map from session id to account name */ private sessions = new Map(); private readonly onDidChangeSessionsEmitter = new Emitter(); readonly onDidChangeSessions: Event = this.onDidChangeSessionsEmitter.event; constructor( private readonly proxy: AuthenticationExt, public readonly id: string, public readonly label: string, public readonly supportsMultipleAccounts: boolean, private readonly storageService: StorageService, private readonly messageService: MessageService ) { } dispose(): void { this.onDidChangeSessionsEmitter.dispose(); } fireSessionsChanged(event: theia.AuthenticationProviderAuthenticationSessionsChangeEvent): void { this.onDidChangeSessionsEmitter.fire(event); } public hasSessions(): boolean { return !!this.sessions.size; } private registerSession(session: theia.AuthenticationSession): void { this.sessions.set(session.id, session.account.label); const existingSessionsForAccount = this.accounts.get(session.account.label); if (existingSessionsForAccount) { this.accounts.set(session.account.label, existingSessionsForAccount.concat(session.id)); return; } else { this.accounts.set(session.account.label, [session.id]); } } async signOut(accountName: string): Promise { const accountUsages = await readAccountUsages(this.storageService, this.id, accountName); const sessionsForAccount = this.accounts.get(accountName); const result = await this.messageService.info(accountUsages.length ? nls.localizeByDefault("The account '{0}' has been used by: \n\n{1}\n\n Sign out from these extensions?", accountName, accountUsages.map(usage => usage.extensionName).join(', ')) : nls.localizeByDefault("Sign out of '{0}'?", accountName), nls.localizeByDefault('Sign Out'), Dialog.CANCEL); if (result && result === nls.localizeByDefault('Sign Out') && sessionsForAccount) { sessionsForAccount.forEach(sessionId => this.removeSession(sessionId)); removeAccountUsage(this.storageService, this.id, accountName); } } async getSessions(scopes?: string[], account?: AuthenticationSessionAccountInformation): Promise> { return this.proxy.$getSessions(this.id, scopes, { account: account }); } async updateSessionItems(event: theia.AuthenticationProviderAuthenticationSessionsChangeEvent): Promise { const { added, removed } = event; const session = await this.proxy.$getSessions(this.id, undefined, {}); const addedSessions = added ? session.filter(s => added.some(addedSession => addedSession.id === s.id)) : []; removed?.forEach(removedSession => { const sessionId = removedSession.id; if (sessionId) { const accountName = this.sessions.get(sessionId); if (accountName) { this.sessions.delete(sessionId); const sessionsForAccount = this.accounts.get(accountName) || []; const sessionIndex = sessionsForAccount.indexOf(sessionId); sessionsForAccount.splice(sessionIndex); if (!sessionsForAccount.length) { this.accounts.delete(accountName); } } } }); addedSessions.forEach(s => this.registerSession(s)); } async login(scopes: string[], options: AuthenticationProviderSessionOptions): Promise { return this.createSession(scopes, options); } async logout(sessionId: string): Promise { return this.removeSession(sessionId); } createSession(scopes: string[], options: AuthenticationProviderSessionOptions): Thenable { return this.proxy.$createSession(this.id, scopes, options); } removeSession(sessionId: string): Thenable { return this.proxy.$removeSession(this.id, sessionId) .then(() => { this.messageService.info(nls.localize('theia/plugin-ext/authentication-main/signedOut', 'Successfully signed out.')); }); } } async function readAccountUsages(storageService: StorageService, providerId: string, accountName: string): Promise { const accountKey = `authentication-${providerId}-${accountName}-usages`; const storedUsages: string | undefined = await storageService.getData(accountKey); let usages: AccountUsage[] = []; if (storedUsages) { try { usages = JSON.parse(storedUsages); } catch (e) { console.log(e); } } return usages; } function removeAccountUsage(storageService: StorageService, providerId: string, accountName: string): void { const accountKey = `authentication-${providerId}-${accountName}-usages`; storageService.setData(accountKey, undefined); }