From df2cae410af8d6ed02a293fdbd2c633cb9cd1827 Mon Sep 17 00:00:00 2001 From: Philippe Vienne Date: Wed, 19 May 2021 15:52:11 +0200 Subject: [PATCH] Add support for newer authentication API matching VS Code 1.63.1 - Stay compatible with newer and previous authentication API -- Keep prev authentication API matching VS Code 1.53.0-next.ea1b3f27db -- Add newer, stable authentication API to existing types -- Remove matching stable API from proposed API -- Bridge API version gap in plugin context and with merged types - Add support for 'onAuthenticationRequest' activation event -- Allow dedicated trigger through 'ensureProvider' call -- Trigger activation if session of provider is requested Co-authored-by: Philippe Vienne Co-authored-by: Martin Fleck --- .../src/browser/authentication-service.ts | 158 ++++++++----- packages/core/src/common/promise-util.ts | 16 ++ .../src/browser/monaco-editor-provider.ts | 3 +- .../src/common/plugin-api-rpc-model.ts | 24 +- .../plugin-ext/src/common/plugin-api-rpc.ts | 30 +-- .../src/main/browser/authentication-main.ts | 218 +++++++++++------- .../browser/plugin-authentication-service.ts | 71 ++++++ .../browser/plugin-ext-frontend-module.ts | 5 + .../src/plugin/authentication-ext.ts | 74 +++--- .../plugin-ext/src/plugin/plugin-context.ts | 27 ++- .../plugin-ext/src/plugin/plugin-manager.ts | 3 +- packages/plugin/src/theia-proposed.d.ts | 6 - packages/plugin/src/theia.d.ts | 134 +++++++++++ 13 files changed, 563 insertions(+), 206 deletions(-) create mode 100644 packages/plugin-ext/src/main/browser/plugin-authentication-service.ts diff --git a/packages/core/src/browser/authentication-service.ts b/packages/core/src/browser/authentication-service.ts index 6f97813cb7cad..79497beba67cb 100644 --- a/packages/core/src/browser/authentication-service.ts +++ b/packages/core/src/browser/authentication-service.ts @@ -26,20 +26,17 @@ import { StorageService } from '../browser/storage-service'; import { Disposable, DisposableCollection } from '../common/disposable'; import { ACCOUNTS_MENU, ACCOUNTS_SUBMENU, MenuModelRegistry } from '../common/menu'; import { Command, CommandRegistry } from '../common/command'; +import { nls } from '../common/nls'; -export interface AuthenticationSessionsChangeEvent { - added: ReadonlyArray; - removed: ReadonlyArray; - changed: ReadonlyArray; +export interface AuthenticationSessionAccountInformation { + readonly id: string; + readonly label: string; } export interface AuthenticationSession { id: string; accessToken: string; - account: { - label: string; - id: string; - } + account: AuthenticationSessionAccountInformation; scopes: ReadonlyArray; } @@ -48,6 +45,13 @@ export interface AuthenticationProviderInformation { label: string; } +/** Should match the definition from the theia/vscode types */ +export interface AuthenticationProviderAuthenticationSessionsChangeEvent { + readonly added: ReadonlyArray; + readonly removed: ReadonlyArray; + readonly changed: ReadonlyArray; +} + export interface SessionRequest { disposables: Disposable[]; requestingExtensionIds: string[]; @@ -57,6 +61,7 @@ export interface SessionRequestInfo { [scopes: string]: SessionRequest; } +/** Should match the definition from the theia/vscode types */ export interface AuthenticationProvider { id: string; @@ -68,13 +73,40 @@ export interface AuthenticationProvider { signOut(accountName: string): Promise; - getSessions(): Promise>; + getSessions(scopes?: string[]): Promise>; - updateSessionItems(event: AuthenticationSessionsChangeEvent): Promise; + updateSessionItems(event: AuthenticationProviderAuthenticationSessionsChangeEvent): Promise; login(scopes: string[]): Promise; logout(sessionId: string): Promise; + + /** + * An [event](#Event) which fires when the array of sessions has changed, or data + * within a session has changed. + */ + readonly onDidChangeSessions: Omit, 'maxListeners'>; + + /** + * Get a list of sessions. + * @param scopes An optional list of scopes. If provided, the sessions returned should match + * these permissions, otherwise all sessions should be returned. + * @returns A promise that resolves to an array of authentication sessions. + */ + getSessions(scopes?: string[]): Thenable>; + + /** + * Prompts a user to login. + * @param scopes A list of scopes, permissions, that the new session should be created with. + * @returns A promise that resolves to an authentication session. + */ + createSession(scopes: string[]): Thenable; + + /** + * Removes the session corresponding to session id. + * @param sessionId The id of the session to remove. + */ + removeSession(sessionId: string): Thenable; } export const AuthenticationService = Symbol('AuthenticationService'); @@ -84,13 +116,13 @@ export interface AuthenticationService { registerAuthenticationProvider(id: string, provider: AuthenticationProvider): void; unregisterAuthenticationProvider(id: string): void; requestNewSession(id: string, scopes: string[], extensionId: string, extensionName: string): void; - updateSessions(providerId: string, event: AuthenticationSessionsChangeEvent): void; + updateSessions(providerId: string, event: AuthenticationProviderAuthenticationSessionsChangeEvent): void; readonly onDidRegisterAuthenticationProvider: Event; readonly onDidUnregisterAuthenticationProvider: Event; - readonly onDidChangeSessions: Event<{ providerId: string, label: string, event: AuthenticationSessionsChangeEvent }>; - getSessions(providerId: string): Promise>; + readonly onDidChangeSessions: Event<{ providerId: string, label: string, event: AuthenticationProviderAuthenticationSessionsChangeEvent }>; + getSessions(providerId: string, scopes?: string[]): Promise>; getLabel(providerId: string): string; supportsMultipleAccounts(providerId: string): boolean; login(providerId: string, scopes: string[]): Promise; @@ -99,13 +131,20 @@ export interface AuthenticationService { signOutOfAccount(providerId: string, accountName: string): Promise; } +export interface SessionChangeEvent { + providerId: string, + label: string, + event: AuthenticationProviderAuthenticationSessionsChangeEvent +} + @injectable() export class AuthenticationServiceImpl implements AuthenticationService { private noAccountsMenuItem: Disposable | undefined; private noAccountsCommand: Command = { id: 'noAccounts' }; private signInRequestItems = new Map(); + private sessionMap = new Map(); - private authenticationProviders: Map = new Map(); + protected authenticationProviders: Map = new Map(); private onDidRegisterAuthenticationProviderEmitter: Emitter = new Emitter(); readonly onDidRegisterAuthenticationProvider: Event = this.onDidRegisterAuthenticationProviderEmitter.event; @@ -113,9 +152,8 @@ export class AuthenticationServiceImpl implements AuthenticationService { private onDidUnregisterAuthenticationProviderEmitter: Emitter = new Emitter(); readonly onDidUnregisterAuthenticationProvider: Event = this.onDidUnregisterAuthenticationProviderEmitter.event; - private onDidChangeSessionsEmitter: Emitter<{ providerId: string, label: string, event: AuthenticationSessionsChangeEvent }> = - new Emitter<{ providerId: string, label: string, event: AuthenticationSessionsChangeEvent }>(); - readonly onDidChangeSessions: Event<{ providerId: string, label: string, event: AuthenticationSessionsChangeEvent }> = this.onDidChangeSessionsEmitter.event; + private onDidChangeSessionsEmitter: Emitter = new Emitter(); + readonly onDidChangeSessions: Event = this.onDidChangeSessionsEmitter.event; @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry; @inject(CommandRegistry) protected readonly commands: CommandRegistry; @@ -123,48 +161,54 @@ export class AuthenticationServiceImpl implements AuthenticationService { @postConstruct() init(): void { - const disposableMap = new Map(); - this.onDidChangeSessions(async e => { - if (e.event.added.length > 0) { - const sessions = await this.getSessions(e.providerId); - sessions.forEach(session => { - if (sessions.find(s => disposableMap.get(s.id))) { - return; - } - const disposables = new DisposableCollection(); - const commandId = `account-sign-out-${e.providerId}-${session.id}`; - const command = this.commands.registerCommand({ id: commandId }, { - execute: async () => { - this.signOutOfAccount(e.providerId, session.account.label); - } - }); - const subSubMenuPath = [...ACCOUNTS_SUBMENU, 'account-sub-menu']; - this.menus.registerSubmenu(subSubMenuPath, `${session.account.label} (${e.label})`); - const menuAction = this.menus.registerMenuAction(subSubMenuPath, { - label: 'Sign Out', - commandId - }); - disposables.push(menuAction); - disposables.push(command); - disposableMap.set(session.id, disposables); - }); - } - if (e.event.removed.length > 0) { - e.event.removed.forEach(removed => { - const toDispose = disposableMap.get(removed); - if (toDispose) { - toDispose.dispose(); - disposableMap.delete(removed); - } - }); - } - }); + this.onDidChangeSessions(event => this.handleSessionChange(event)); this.commands.registerCommand(this.noAccountsCommand, { execute: () => { }, isEnabled: () => false }); } + protected async handleSessionChange(changeEvent: SessionChangeEvent): Promise { + if (changeEvent.event.added.length > 0) { + const sessions = await this.getSessions(changeEvent.providerId); + sessions.forEach(session => { + if (!this.sessionMap.get(session.id)) { + this.sessionMap.set(session.id, this.createAccountUi(changeEvent.providerId, changeEvent.label, session)); + } + }); + } + for (const removed of changeEvent.event.removed) { + const sessionId = typeof removed === 'string' ? removed : removed?.id; + if (sessionId) { + this.sessionMap.get(sessionId)?.dispose(); + this.sessionMap.delete(sessionId); + } + } + } + + protected createAccountUi(providerId: string, providerLabel: string, session: AuthenticationSession): DisposableCollection { + // unregister old commands and menus if present (there is only one per account but there may be several sessions per account) + const providerAccountId = `account-sign-out-${providerId}-${session.account.id}`; + this.commands.unregisterCommand(providerAccountId); + + const providerAccountSubmenu = [...ACCOUNTS_SUBMENU, providerAccountId]; + this.menus.unregisterMenuAction({ commandId: providerAccountId }, providerAccountSubmenu); + + // register new command and menu entry for the sessions account + const disposables = new DisposableCollection(); + disposables.push(this.commands.registerCommand({ id: providerAccountId }, { + execute: async () => { + this.signOutOfAccount(providerId, session.account.label); + } + })); + this.menus.registerSubmenu(providerAccountSubmenu, `${session.account.label} (${providerLabel})`); + disposables.push(this.menus.registerMenuAction(providerAccountSubmenu, { + label: nls.localizeByDefault('Sign Out'), + commandId: providerAccountId + })); + return disposables; + } + getProviderIds(): string[] { const providerIds: string[] = []; this.authenticationProviders.forEach(provider => { @@ -219,7 +263,7 @@ export class AuthenticationServiceImpl implements AuthenticationService { } } - async updateSessions(id: string, event: AuthenticationSessionsChangeEvent): Promise { + async updateSessions(id: string, event: AuthenticationProviderAuthenticationSessionsChangeEvent): Promise { const provider = this.authenticationProviders.get(id); if (provider) { await provider.updateSessionItems(event); @@ -268,7 +312,7 @@ export class AuthenticationServiceImpl implements AuthenticationService { this.onDidRegisterAuthenticationProvider(e => { if (e.id === providerId) { provider = this.authenticationProviders.get(providerId); - resolve(); + resolve(undefined); } }); }); @@ -344,10 +388,10 @@ export class AuthenticationServiceImpl implements AuthenticationService { } } - async getSessions(id: string): Promise> { + async getSessions(id: string, scopes?: string[]): Promise> { const authProvider = this.authenticationProviders.get(id); if (authProvider) { - return authProvider.getSessions(); + return authProvider.getSessions(scopes); } else { throw new Error(`No authentication provider '${id}' is currently registered.`); } diff --git a/packages/core/src/common/promise-util.ts b/packages/core/src/common/promise-util.ts index bed47f40a2539..a3c4013ffbdb9 100644 --- a/packages/core/src/common/promise-util.ts +++ b/packages/core/src/common/promise-util.ts @@ -57,6 +57,22 @@ export function timeout(ms: number, token = CancellationToken.None): Promise(ms: number, message?: string): Promise { + const deferred = new Deferred(); + setTimeout(() => deferred.reject(new Error(message)), ms); + return deferred.promise; +} + export async function retry(task: () => Promise, retryDelay: number, retries: number): Promise { let lastError: Error | undefined; diff --git a/packages/monaco/src/browser/monaco-editor-provider.ts b/packages/monaco/src/browser/monaco-editor-provider.ts index 610ee2e93e401..b6e9dd6e5b58b 100644 --- a/packages/monaco/src/browser/monaco-editor-provider.ts +++ b/packages/monaco/src/browser/monaco-editor-provider.ts @@ -42,6 +42,7 @@ import { MonacoToProtocolConverter } from './monaco-to-protocol-converter'; import { ProtocolToMonacoConverter } from './protocol-to-monaco-converter'; import { FileSystemPreferences } from '@theia/filesystem/lib/browser'; import { MonacoQuickInputImplementation } from './monaco-quick-input-service'; +import { timeoutReject } from '@theia/core/lib/common/promise-util'; export const MonacoEditorFactory = Symbol('MonacoEditorFactory'); export interface MonacoEditorFactory { @@ -304,7 +305,7 @@ export class MonacoEditorProvider { if (formatOnSave) { const formatOnSaveTimeout = this.editorPreferences.get({ preferenceName: 'editor.formatOnSaveTimeout', overrideIdentifier }, undefined, uri)!; await Promise.race([ - new Promise((_, reject) => setTimeout(() => reject(new Error(`Aborted format on save after ${formatOnSaveTimeout}ms`)), formatOnSaveTimeout)), + timeoutReject(formatOnSaveTimeout, `Aborted format on save after ${formatOnSaveTimeout}ms`), editor.runAction('editor.action.formatDocument') ]); } diff --git a/packages/plugin-ext/src/common/plugin-api-rpc-model.ts b/packages/plugin-ext/src/common/plugin-api-rpc-model.ts index ee7df56efad68..d50abb5f5ad0a 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc-model.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc-model.ts @@ -569,22 +569,22 @@ export interface LinePreview { character: number; } -export interface AuthenticationSession { - id: string; - accessToken: string; - account: { id: string, label: string }; - scopes: ReadonlyArray; +/** + * @deprecated Use {@link theia.AuthenticationSession} instead. + */ +export interface AuthenticationSession extends theia.AuthenticationSession { } -export interface AuthenticationSessionsChangeEvent { - added: ReadonlyArray; - removed: ReadonlyArray; - changed: ReadonlyArray; +/** + * @deprecated Use {@link theia.AuthenticationProviderAuthenticationSessionsChangeEvent} instead. + */ +export interface AuthenticationSessionsChangeEvent extends theia.AuthenticationProviderAuthenticationSessionsChangeEvent { } -export interface AuthenticationProviderInformation { - id: string; - label: string; +/** + * @deprecated Use {@link theia.AuthenticationProviderInformation} instead. + */ +export interface AuthenticationProviderInformation extends theia.AuthenticationProviderInformation { } export interface CommentOptions { diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index ff6ac37658d8a..ae4026cda5323 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -65,9 +65,6 @@ import { CallHierarchyDefinition, CallHierarchyReference, SearchInWorkspaceResult, - AuthenticationSession, - AuthenticationSessionsChangeEvent, - AuthenticationProviderInformation, Comment, CommentOptions, CommentThreadCollapsibleState, @@ -76,7 +73,12 @@ import { } from './plugin-api-rpc-model'; import { ExtPluginApi } from './plugin-ext-api-contribution'; import { KeysToAnyValues, KeysToKeysToAnyValue } from './types'; -import { CancellationToken, Progress, ProgressOptions } from '@theia/plugin'; +import { + AuthenticationProviderAuthenticationSessionsChangeEvent, + CancellationToken, + Progress, + ProgressOptions, +} from '@theia/plugin'; import { DebuggerDescription } from '@theia/debug/lib/common/debug-service'; import { DebugProtocol } from 'vscode-debugprotocol'; import { SymbolInformation } from '@theia/core/shared/vscode-languageserver-protocol'; @@ -1837,21 +1839,23 @@ export interface TasksMain { } export interface AuthenticationExt { - $getSessions(id: string): Promise>; - $login(id: string, scopes: string[]): Promise; - $logout(id: string, sessionId: string): Promise; - $onDidChangeAuthenticationSessions(id: string, label: string, event: AuthenticationSessionsChangeEvent): Promise; - $onDidChangeAuthenticationProviders(added: AuthenticationProviderInformation[], removed: AuthenticationProviderInformation[]): Promise; + $getSessions(id: string, scopes?: string[]): Promise>; + $createSession(id: string, scopes: string[]): Promise; + $removeSession(id: string, sessionId: string): Promise; + $onDidChangeAuthenticationSessions(id: string, label: string): Promise; + $onDidChangeAuthenticationProviders(added: theia.AuthenticationProviderInformation[], removed: theia.AuthenticationProviderInformation[]): Promise; + $setProviders(providers: theia.AuthenticationProviderInformation[]): Promise; } export interface AuthenticationMain { $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): void; $unregisterAuthenticationProvider(id: string): void; $getProviderIds(): Promise; - $updateSessions(providerId: string, event: AuthenticationSessionsChangeEvent): void; - $getSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, - options: { createIfNone?: boolean, clearSessionPreference?: boolean }): Promise; - $logout(providerId: string, sessionId: string): Promise; + $ensureProvider(id: string): Promise; + $sendDidChangeSessions(providerId: string, event: AuthenticationProviderAuthenticationSessionsChangeEvent): void; + $getSession(providerId: string, scopes: readonly string[], extensionId: string, extensionName: string, + options: theia.AuthenticationGetSessionOptions): Promise; + $removeSession(providerId: string, sessionId: string): Promise; } export interface RawColorInfo { diff --git a/packages/plugin-ext/src/main/browser/authentication-main.ts b/packages/plugin-ext/src/main/browser/authentication-main.ts index 6f779a73b9a3f..3b34515e8a1a8 100644 --- a/packages/plugin-ext/src/main/browser/authentication-main.ts +++ b/packages/plugin-ext/src/main/browser/authentication-main.ts @@ -21,21 +21,21 @@ // 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 { AuthenticationExt, AuthenticationMain, PluginManagerExt, MAIN_RPC_CONTEXT } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; import { MessageService } from '@theia/core/lib/common/message-service'; -import { StorageService } from '@theia/core/lib/browser'; +import { Dialog, StorageService } from '@theia/core/lib/browser'; import { AuthenticationProvider, AuthenticationService, readAllowedExtensions } from '@theia/core/lib/browser/authentication-service'; import { QuickPickService } from '@theia/core/lib/common/quick-pick-service'; -import { - AuthenticationSession, - AuthenticationSessionsChangeEvent -} from '../../common/plugin-api-rpc-model'; +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'; + +export function getAuthenticationProviderActivationEvent(id: string): string { return `onAuthenticationRequest:${id}`; } export class AuthenticationMainImpl implements AuthenticationMain { private readonly proxy: AuthenticationExt; @@ -43,15 +43,17 @@ export class AuthenticationMainImpl implements AuthenticationMain { private readonly storageService: StorageService; private readonly authenticationService: AuthenticationService; private readonly quickPickService: QuickPickService; + private readonly extensionService: PluginManagerExt; 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.extensionService = rpc.getProxy(MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT); this.authenticationService.onDidChangeSessions(e => { - this.proxy.$onDidChangeAuthenticationSessions(e.providerId, e.label, e.event); + this.proxy.$onDidChangeAuthenticationSessions(e.providerId, e.label); }); this.authenticationService.onDidRegisterAuthenticationProvider(info => { this.proxy.$onDidChangeAuthenticationProviders([info], []); @@ -73,7 +75,7 @@ export class AuthenticationMainImpl implements AuthenticationMain { this.authenticationService.unregisterAuthenticationProvider(id); } - async $updateSessions(id: string, event: AuthenticationSessionsChangeEvent): Promise { + async $updateSessions(id: string, event: theia.AuthenticationProviderAuthenticationSessionsChangeEvent): Promise { this.authenticationService.updateSessions(id, event); } @@ -86,75 +88,85 @@ export class AuthenticationMainImpl implements AuthenticationMain { } 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 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; + options: theia.AuthenticationGetSessionOptions): Promise { + const sessions = await this.authenticationService.getSessions(providerId, scopes); + + // Error cases + if (options.forceNewSession && !sessions.length) { + throw new Error('No existing sessions found.'); + } + 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 { - throw new Error('User did not consent to login.'); + 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]; } + } - // 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; + // 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); + const detail = (typeof options.forceNewSession === 'object') ? options.forceNewSession!.detail : undefined; + const isAllowed = await this.loginPrompt(providerName, extensionName, !!options.forceNewSession, detail); + if (!isAllowed) { + throw new Error('User did not consent to login.'); } + + const session = sessions?.length && !options.forceNewSession && supportsMultipleAccounts + ? await this.selectSession(providerId, providerName, extensionId, extensionName, sessions, scopes, !!options.clearSessionPreference) + : await this.authenticationService.login(providerId, scopes); + 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, scopes, extensionId, extensionName); } + return validSession; } protected async selectSession(providerId: string, providerName: string, extensionId: string, extensionName: string, - potentialSessions: AuthenticationSession[], scopes: string[], clearSessionPreference: boolean): Promise { + potentialSessions: Readonly, scopes: string[], clearSessionPreference: boolean): Promise { if (!potentialSessions.length) { throw new Error('No potential sessions found'); } - if (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 = 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(async (resolve, reject) => { - const items: QuickPickValue<{ session?: AuthenticationSession }>[] = potentialSessions.map(session => ({ + const items: QuickPickValue<{ session?: theia.AuthenticationSession }>[] = potentialSessions.map(session => ({ label: session.account.label, value: { session } })); items.push({ - label: 'Sign in to another account', + label: nls.localizeByDefault('Sign in to another account'), value: { session: undefined } }); const selected = await this.quickPickService.show(items, { - title: `The extension '${extensionName}' wants to access a ${providerName} account`, + title: nls.localizeByDefault("The extension '{0}' wants to access a {1} account", extensionName, providerName), ignoreFocusOut: true }); if (selected) { @@ -196,20 +208,39 @@ export class AuthenticationMainImpl implements AuthenticationMain { return allow; } - protected async loginPrompt(providerName: string, extensionName: string): Promise { - const choice = await this.messageService.info(`The extension '${extensionName}' wants to sign in using ${providerName}.`, 'Allow', 'Cancel'); + protected async loginPrompt(providerName: string, extensionName: string, recreatingSession: boolean, _detail?: string): Promise { + const message = 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); + const choice = await this.messageService.info(message, 'Allow', 'Cancel'); return choice === 'Allow'; } + 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); } + + $ensureProvider(id: string): Promise { + return this.extensionService.$activateByEvent(getAuthenticationProviderActivationEvent(id)); + } + + $removeSession(providerId: string, sessionId: string): Promise { + return this.authenticationService.logout(providerId, sessionId); + } + + $sendDidChangeSessions(providerId: string, event: theia.AuthenticationProviderAuthenticationSessionsChangeEvent): void { + this.authenticationService.updateSessions(providerId, event); + } } async function addAccountUsage(storageService: StorageService, providerId: string, accountName: string, extensionId: string, extensionName: string): Promise { @@ -244,6 +275,8 @@ export class AuthenticationProviderImpl implements AuthenticationProvider { private accounts = new Map(); // Map account name to session ids private sessions = new Map(); // Map account id to name + readonly onDidChangeSessions: theia.Event; + constructor( private readonly proxy: AuthenticationExt, public readonly id: string, @@ -257,7 +290,7 @@ export class AuthenticationProviderImpl implements AuthenticationProvider { return !!this.sessions.size; } - private registerSession(session: AuthenticationSession): void { + private registerSession(session: theia.AuthenticationSession): void { this.sessions.set(session.id, session.account.label); const existingSessionsForAccount = this.accounts.get(session.account.label); @@ -272,34 +305,41 @@ export class AuthenticationProviderImpl implements AuthenticationProvider { 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 ? `The account ${accountName} has been used by: - ${accountUsages.map(usage => usage.extensionName).join(', ')}. Sign out of these features?` : `Sign out of ${accountName}?`, 'Yes'); - - if (result && result === 'Yes' && sessionsForAccount) { + 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.logout(sessionId)); removeAccountUsage(this.storageService, this.id, accountName); } } - async getSessions(): Promise> { - return this.proxy.$getSessions(this.id); + async getSessions(scopes?: string[]): Promise> { + return this.proxy.$getSessions(this.id, scopes); } - async updateSessionItems(event: AuthenticationSessionsChangeEvent): Promise { + async updateSessionItems(event: theia.AuthenticationProviderAuthenticationSessionsChangeEvent): Promise { const { added, removed } = event; const session = await this.proxy.$getSessions(this.id); - const addedSessions = session.filter(s => added.some(id => id === s.id)); - - removed.forEach(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); + const addedSessions = session.filter(s => added.some(addedSession => this.getSessionId(addedSession) === s.id)); + + removed.forEach(removedSession => { + const sessionId = this.getSessionId(removedSession); + 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); + } } } }); @@ -307,14 +347,30 @@ export class AuthenticationProviderImpl implements AuthenticationProvider { addedSessions.forEach(s => this.registerSession(s)); } - login(scopes: string[]): Promise { - return this.proxy.$login(this.id, scopes); + login(scopes: string[]): Promise { + return this.proxy.$createSession(this.id, scopes); } async logout(sessionId: string): Promise { - await this.proxy.$logout(this.id, sessionId); + await this.proxy.$removeSession(this.id, sessionId); this.messageService.info('Successfully signed out.'); } + + createSession(scopes: string[]): Thenable { + return this.login(scopes); + } + + removeSession(sessionId: string): Thenable { + return this.logout(sessionId); + } + + // utility method to be backwards compatible with the old AuthenticationProviderAuthenticationSessionsChangeEvent containing only the session id string + private getSessionId(obj: string | theia.AuthenticationSession | undefined): string | undefined { + if (!obj || typeof obj === 'string') { + return obj; + } + return obj.id; + } } async function readAccountUsages(storageService: StorageService, providerId: string, accountName: string): Promise { diff --git a/packages/plugin-ext/src/main/browser/plugin-authentication-service.ts b/packages/plugin-ext/src/main/browser/plugin-authentication-service.ts new file mode 100644 index 0000000000000..752fac3ea19ce --- /dev/null +++ b/packages/plugin-ext/src/main/browser/plugin-authentication-service.ts @@ -0,0 +1,71 @@ +/******************************************************************************** + * Copyright (C) 2022 EclipseSource 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 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { AuthenticationProvider, AuthenticationService, AuthenticationServiceImpl, AuthenticationSession } from '@theia/core/lib/browser/authentication-service'; +import { inject } from '@theia/core/shared/inversify'; +import { Deferred, timeoutReject } from '@theia/core/lib/common/promise-util'; +import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; + +export function getAuthenticationProviderActivationEvent(id: string): string { return `onAuthenticationRequest:${id}`; } + +/** + * Plugin authentiction service that aims to activate additional plugins if sessions are created or queried. + */ +export class PluginAuthenticationServiceImpl extends AuthenticationServiceImpl implements AuthenticationService { + @inject(HostedPluginSupport) protected readonly pluginService: HostedPluginSupport; + + async getSessions(id: string, scopes?: string[]): Promise> { + await this.tryActivateProvider(id); + return super.getSessions(id, scopes); + } + + async login(id: string, scopes: string[]): Promise { + await this.tryActivateProvider(id); + return super.login(id, scopes); + } + + protected async tryActivateProvider(providerId: string): Promise { + this.pluginService.activateByEvent(getAuthenticationProviderActivationEvent(providerId)); + + const provider = this.authenticationProviders.get(providerId); + if (provider) { + return provider; + } + + // When activate has completed, the extension has made the call to `registerAuthenticationProvider`. + // However, activate cannot block on this, so the renderer may not have gotten the event yet. + return Promise.race([ + this.waitForProviderRegistration(providerId), + timeoutReject(5000, 'Timed out waiting for authentication provider to register') + ]); + } + + protected async waitForProviderRegistration(providerId: string): Promise { + const waitForRegistration = new Deferred(); + const registration = this.onDidRegisterAuthenticationProvider(info => { + if (info.id === providerId) { + registration.dispose(); + const provider = this.authenticationProviders.get(providerId); + if (provider) { + waitForRegistration.resolve(provider); + } else { + waitForRegistration.reject(new Error(`No authentication provider '${providerId}' is currently registered.`)); + } + } + }); + return waitForRegistration.promise; + } +} diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index cc3bd7f8be6a1..a3697490d8bf0 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -76,6 +76,8 @@ import { CustomEditorWidget } from './custom-editors/custom-editor-widget'; import { CustomEditorService } from './custom-editors/custom-editor-service'; import { UndoRedoService } from './custom-editors/undo-redo-service'; import { WebviewFrontendSecurityWarnings } from './webview/webview-frontend-security-warnings'; +import { PluginAuthenticationServiceImpl } from './plugin-authentication-service'; +import { AuthenticationService } from '@theia/core/lib/browser/authentication-service'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -234,4 +236,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(WebviewFrontendSecurityWarnings).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(WebviewFrontendSecurityWarnings); + + bind(PluginAuthenticationServiceImpl).toSelf().inSingletonScope(); + rebind(AuthenticationService).toService(PluginAuthenticationServiceImpl); }); diff --git a/packages/plugin-ext/src/plugin/authentication-ext.ts b/packages/plugin-ext/src/plugin/authentication-ext.ts index 82c07b50349e6..ebaba75cbdb36 100644 --- a/packages/plugin-ext/src/plugin/authentication-ext.ts +++ b/packages/plugin-ext/src/plugin/authentication-ext.ts @@ -28,8 +28,7 @@ import { } from '../common/plugin-api-rpc'; import { RPCProtocol } from '../common/rpc-protocol'; import { Emitter, Event } from '@theia/core/lib/common/event'; -import * as theia from '@theia/plugin'; -import { AuthenticationSession, AuthenticationSessionsChangeEvent } from '../common/plugin-api-rpc-model'; +import * as theia from '@theia/plugin/'; export class AuthenticationExtImpl implements AuthenticationExt { private proxy: AuthenticationMain; @@ -61,82 +60,88 @@ export class AuthenticationExtImpl implements AuthenticationExt { return Object.freeze(this._providers.slice()); } - async getSession(requestingExtension: InternalPlugin, providerId: string, scopes: string[], - options: theia.AuthenticationGetSessionOptions & { createIfNone: true }): Promise; - async getSession(requestingExtension: InternalPlugin, providerId: string, scopes: string[], - options: theia.AuthenticationGetSessionOptions = {}): Promise { + async getSession(requestingExtension: InternalPlugin, providerId: string, scopes: readonly string[], + options: theia.AuthenticationGetSessionOptions & ({ createIfNone: true } | { forceNewSession: true } | { forceNewSession: { detail: string } })): + Promise; + async getSession(requestingExtension: InternalPlugin, providerId: string, scopes: readonly string[], + options: theia.AuthenticationGetSessionOptions & { forceNewSession: true }): Promise; + async getSession(requestingExtension: InternalPlugin, providerId: string, scopes: readonly string[], + options: theia.AuthenticationGetSessionOptions & { forceNewSession: { detail: string } }): Promise; + async getSession(requestingExtension: InternalPlugin, providerId: string, scopes: readonly string[], + options: theia.AuthenticationGetSessionOptions): Promise; + async getSession(requestingExtension: InternalPlugin, providerId: string, scopes: readonly string[], + options: theia.AuthenticationGetSessionOptions = {}): Promise { const extensionName = requestingExtension.model.displayName || requestingExtension.model.name; const extensionId = requestingExtension.model.id.toLowerCase(); - return this.proxy.$getSession(providerId, scopes, extensionId, extensionName, options); } async logout(providerId: string, sessionId: string): Promise { - return this.proxy.$logout(providerId, sessionId); + return this.proxy.$removeSession(providerId, sessionId); } - registerAuthenticationProvider(provider: theia.AuthenticationProvider): theia.Disposable { - if (this.authenticationProviders.get(provider.id)) { - throw new Error(`An authentication provider with id '${provider.id}' is already registered.`); + registerAuthenticationProvider(id: string, label: string, provider: theia.AuthenticationProvider, options?: theia.AuthenticationProviderOptions): theia.Disposable { + if (this.authenticationProviders.get(id)) { + throw new Error(`An authentication provider with id '${id}' is already registered.`); } - this.authenticationProviders.set(provider.id, provider); - if (this._providerIds.indexOf(provider.id) === -1) { - this._providerIds.push(provider.id); + this.authenticationProviders.set(id, provider); + if (this._providerIds.indexOf(id) === -1) { + this._providerIds.push(id); } - if (!this._providers.find(p => p.id === provider.id)) { + if (!this._providers.find(p => p.id === id)) { this._providers.push({ - id: provider.id, - label: provider.label + id, + label }); } const listener = provider.onDidChangeSessions(e => { - this.proxy.$updateSessions(provider.id, e); + this.proxy.$sendDidChangeSessions(id, e); }); - this.proxy.$registerAuthenticationProvider(provider.id, provider.label, provider.supportsMultipleAccounts); + this.proxy.$registerAuthenticationProvider(id, label, !!options?.supportsMultipleAccounts); return new Disposable(() => { listener.dispose(); - this.authenticationProviders.delete(provider.id); - const index = this._providerIds.findIndex(id => id === provider.id); + this.authenticationProviders.delete(id); + const index = this._providerIds.findIndex(pid => id === pid); if (index > -1) { this._providerIds.splice(index); } - const i = this._providers.findIndex(p => p.id === provider.id); + const i = this._providers.findIndex(p => p.id === id); if (i > -1) { this._providers.splice(i); } - this.proxy.$unregisterAuthenticationProvider(provider.id); + this.proxy.$unregisterAuthenticationProvider(id); }); } - $login(providerId: string, scopes: string[]): Promise { + $createSession(providerId: string, scopes: string[]): Promise { const authProvider = this.authenticationProviders.get(providerId); if (authProvider) { - return Promise.resolve(authProvider.login(scopes)); + return Promise.resolve(authProvider.createSession(scopes)); } throw new Error(`Unable to find authentication provider with handle: ${providerId}`); } - $logout(providerId: string, sessionId: string): Promise { + $removeSession(providerId: string, sessionId: string): Promise { const authProvider = this.authenticationProviders.get(providerId); if (authProvider) { - return Promise.resolve(authProvider.logout(sessionId)); + return Promise.resolve(authProvider.removeSession(sessionId)); } throw new Error(`Unable to find authentication provider with handle: ${providerId}`); } - async $getSessions(providerId: string): Promise> { + async $getSessions(providerId: string, scopes?: string[]): Promise> { const authProvider = this.authenticationProviders.get(providerId); if (authProvider) { - const sessions = await authProvider.getSessions(); + const sessions = await authProvider.getSessions(scopes); /* Wrap the session object received from the plugin to prevent serialization mismatches e.g. if the plugin object is constructed with the help of getters they won't be serialized: @@ -158,12 +163,12 @@ export class AuthenticationExtImpl implements AuthenticationExt { throw new Error(`Unable to find authentication provider with handle: ${providerId}`); } - $onDidChangeAuthenticationSessions(id: string, label: string, event: AuthenticationSessionsChangeEvent): Promise { - this.onDidChangeSessionsEmitter.fire({ provider: { id, label }, ...event }); + $onDidChangeAuthenticationSessions(id: string, label: string): Promise { + this.onDidChangeSessionsEmitter.fire({ provider: { id, label } }); return Promise.resolve(); } - async $onDidChangeAuthenticationProviders(added: theia.AuthenticationProviderInformation[], removed: theia.AuthenticationProviderInformation[]): Promise { + async $onDidChangeAuthenticationProviders(added: theia.AuthenticationProviderInformation[], removed: theia.AuthenticationProviderInformation[]): Promise { added.forEach(id => { if (this._providers.indexOf(id) === -1) { this._providers.push(id); @@ -179,4 +184,9 @@ export class AuthenticationExtImpl implements AuthenticationExt { this.onDidChangeAuthenticationProvidersEmitter.fire({ added, removed }); } + + $setProviders(providers: theia.AuthenticationProviderInformation[]): Promise { + this._providers.push(...providers); + return Promise.resolve(undefined); + } } diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index ec8b6a03d19e1..3f90bb062504a 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -217,14 +217,31 @@ export function createAPIFactory( return function (plugin: InternalPlugin): typeof theia { const authentication: typeof theia.authentication = { - registerAuthenticationProvider(provider: theia.AuthenticationProvider): theia.Disposable { - return authenticationExt.registerAuthenticationProvider(provider); + // support older (< 1.53.0) and newer version of authentication provider registration + registerAuthenticationProvider( + id: string | theia.AuthenticationProvider, label?: string, provider?: theia.AuthenticationProvider, options?: theia.AuthenticationProviderOptions): + theia.Disposable { + // collect registration data based on registration type: new (all parameters given) vs old (data stored in provider) + const isNewRegistration = typeof id === 'string'; + const regProvider = isNewRegistration ? provider! : id; + const regId = isNewRegistration ? id : regProvider.id; + const regLabel = isNewRegistration ? label! : regProvider.label; + const regOptions = isNewRegistration ? options : { supportsMultipleAccounts: regProvider.supportsMultipleAccounts }; + + // ensure that all methods of the new AuthenticationProvider are available or delegate otherwise + if (!regProvider['createSession']) { + regProvider['createSession'] = (scopes: string[]) => regProvider.login(scopes); + } + if (!regProvider['removeSession']) { + regProvider['removeSession'] = (sessionId: string) => regProvider.logout(sessionId); + } + return authenticationExt.registerAuthenticationProvider(regId, regLabel, regProvider, regOptions); }, get onDidChangeAuthenticationProviders(): theia.Event { return authenticationExt.onDidChangeAuthenticationProviders; }, getProviderIds(): Thenable> { - return authenticationExt.getProviderIds(); + return Promise.resolve(authenticationExt.providerIds); }, get providerIds(): string[] { return authenticationExt.providerIds; @@ -240,6 +257,10 @@ export function createAPIFactory( }, get onDidChangeSessions(): theia.Event { return authenticationExt.onDidChangeSessions; + }, + async hasSession(providerId: string, scopes: readonly string[]): Promise { + const session = await authenticationExt.getSession(plugin, providerId, scopes, { silent: true }); + return !!session; } }; const commands: typeof theia.commands = { diff --git a/packages/plugin-ext/src/plugin/plugin-manager.ts b/packages/plugin-ext/src/plugin/plugin-manager.ts index 9220652313252..cdd306aecc508 100644 --- a/packages/plugin-ext/src/plugin/plugin-manager.ts +++ b/packages/plugin-ext/src/plugin/plugin-manager.ts @@ -91,7 +91,8 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { 'onWebviewPanel', 'onFileSystem', 'onCustomEditor', - 'onStartupFinished' + 'onStartupFinished', + 'onAuthenticationRequest' ]); private configStorage: ConfigStorage | undefined; diff --git a/packages/plugin/src/theia-proposed.d.ts b/packages/plugin/src/theia-proposed.d.ts index f7e46f007c2ec..cd50366aa9817 100644 --- a/packages/plugin/src/theia-proposed.d.ts +++ b/packages/plugin/src/theia-proposed.d.ts @@ -81,12 +81,6 @@ export module '@theia/plugin' { */ readonly supportsMultipleAccounts: boolean; - /** - * An [event](#Event) which fires when the array of sessions has changed, or data - * within a session has changed. - */ - readonly onDidChangeSessions: Event; - /** * Returns an array of current sessions. */ diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 06a0819b5bee3..5b668ca83757a 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -11184,6 +11184,28 @@ export module '@theia/plugin' { * Defaults to false. */ clearSessionPreference?: boolean; + + /** + * Whether we should attempt to reauthenticate even if there is already a session available. + * + * If true, a modal dialog will be shown asking the user to sign in again. This is mostly used for scenarios + * where the token needs to be re minted because it has lost some authorization. + * + * Defaults to false. + */ + forceNewSession?: boolean | { detail: string }; + + /** + * Whether we should show the indication to sign in in the Accounts menu. + * + * If false, the user will be shown a badge on the Accounts menu with an option to sign in for the extension. + * If true, no indication will be shown. + * + * Defaults to false. + * + * Note: you cannot use this option with any other options that prompt the user like {@link createIfNone}. + */ + silent?: boolean; } /** @@ -11211,6 +11233,83 @@ export module '@theia/plugin' { readonly provider: AuthenticationProviderInformation; } + /** + * Options for creating an {@link AuthenticationProvider}. + */ + export interface AuthenticationProviderOptions { + /** + * Whether it is possible to be signed into multiple accounts at once with this provider. + * If not specified, will default to false. + */ + readonly supportsMultipleAccounts?: boolean; + } + + /** + * An {@link Event} which fires when an {@link AuthenticationSession} is added, removed, or changed. + */ + export interface AuthenticationProviderAuthenticationSessionsChangeEvent { + /** + * The {@link AuthenticationSession AuthenticationSessions} of the {@link AuthenticationProvider} that have been added. + */ + readonly added: readonly AuthenticationSession[] | undefined; + + /** + * The {@link AuthenticationSession AuthenticationSessions} of the {@link AuthenticationProvider} that have been removed. + */ + readonly removed: readonly AuthenticationSession[] | undefined; + + /** + * The {@link AuthenticationSession AuthenticationSessions} of the {@link AuthenticationProvider} that have been changed. + * A session changes when its data excluding the id are updated. An example of this is a session refresh that results in a new + * access token being set for the session. + */ + readonly changed: readonly AuthenticationSession[] | undefined; + } + + /** + * A provider for performing authentication to a service. + */ + export interface AuthenticationProvider { + /** + * An {@link Event} which fires when the array of sessions has changed, or data + * within a session has changed. + */ + readonly onDidChangeSessions: Event; + + /** + * Get a list of sessions. + * @param scopes An optional list of scopes. If provided, the sessions returned should match + * these permissions, otherwise all sessions should be returned. + * @returns A promise that resolves to an array of authentication sessions. + */ + getSessions(scopes?: readonly string[]): Thenable; + + /** + * Prompts a user to login. + * + * If login is successful, the onDidChangeSessions event should be fired. + * + * If login fails, a rejected promise should be returned. + * + * If the provider has specified that it does not support multiple accounts, + * then this should never be called if there is already an existing session matching these + * scopes. + * @param scopes A list of scopes, permissions, that the new session should be created with. + * @returns A promise that resolves to an authentication session. + */ + createSession(scopes: readonly string[]): Thenable; + + /** + * Removes the session corresponding to session id. + * + * If the removal is successful, the onDidChangeSessions event should be fired. + * + * If a session cannot be removed, the provider should reject with an error message. + * @param sessionId The id of the session to remove. + */ + removeSession(sessionId: string): Thenable; + } + /** * Namespace for authentication. */ @@ -11230,6 +11329,21 @@ export module '@theia/plugin' { */ export function getSession(providerId: string, scopes: string[], options: AuthenticationGetSessionOptions & { createIfNone: true }): Thenable; + /** + * Get an authentication session matching the desired scopes. Rejects if a provider with providerId is not + * registered, or if the user does not consent to sharing authentication information with + * the extension. If there are multiple sessions with the same scopes, the user will be shown a + * quickpick to select which account they would like to use. + * + * Currently, there are only two authentication providers that are contributed from built in extensions + * to the editor that implement GitHub and Microsoft authentication: their providerId's are 'github' and 'microsoft'. + * @param providerId The id of the provider to use + * @param scopes A list of scopes representing the permissions requested. These are dependent on the authentication provider + * @param options The {@link AuthenticationGetSessionOptions} to use + * @returns A thenable that resolves to an authentication session + */ + export function getSession(providerId: string, scopes: readonly string[], options: AuthenticationGetSessionOptions & { forceNewSession: true | { detail: string } }): Thenable; + /** * Get an authentication session matching the desired scopes. Rejects if a provider with providerId is not * registered, or if the user does not consent to sharing authentication information with @@ -11248,5 +11362,25 @@ export module '@theia/plugin' { * been added, removed, or changed. */ export const onDidChangeSessions: Event; + + /** + * Register an authentication provider. + * + * There can only be one provider per id and an error is being thrown when an id + * has already been used by another provider. Ids are case-sensitive. + * + * @param id The unique identifier of the provider. + * @param label The human-readable name of the provider. + * @param provider The authentication provider provider. + * @params options Additional options for the provider. + * @return A {@link Disposable} that unregisters this provider when being disposed. + */ + export function registerAuthenticationProvider(id: string, label: string, provider: AuthenticationProvider, options?: AuthenticationProviderOptions): Disposable; + + /** + * @deprecated Use {@link getSession()} {@link AuthenticationGetSessionOptions.silent} instead. + */ + export function hasSession(providerId: string, scopes: readonly string[]): Thenable; + } }