@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
457 lines (389 loc) • 20 kB
text/typescript
// *****************************************************************************
// 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/services/authentication/browser/authenticationService.ts
import { injectable, inject, postConstruct } from 'inversify';
import { Emitter, Event } from '../common/event';
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 AuthenticationSessionAccountInformation {
readonly id: string;
readonly label: string;
}
export interface AuthenticationSession {
id: string;
accessToken: string;
account: AuthenticationSessionAccountInformation;
scopes: ReadonlyArray<string>;
}
export interface AuthenticationProviderInformation {
id: string;
label: string;
}
/** Should match the definition from the theia/vscode types */
export interface AuthenticationProviderAuthenticationSessionsChangeEvent {
readonly added: readonly AuthenticationSession[] | undefined;
readonly removed: readonly AuthenticationSession[] | undefined;
readonly changed: readonly AuthenticationSession[] | undefined;
}
export interface SessionRequest {
disposables: Disposable[];
requestingExtensionIds: string[];
}
export interface SessionRequestInfo {
[scopes: string]: SessionRequest;
}
/**
* Our authentication provider should at least contain the following information:
* - The signature of authentication providers from vscode
* - Registration information about the provider (id, label)
* - Provider options (supportsMultipleAccounts)
*
* Additionally, we provide the possibility to sign out of a specific account name.
*/
export interface AuthenticationProvider {
id: string;
label: string;
supportsMultipleAccounts: boolean;
hasSessions(): boolean;
signOut(accountName: string): Promise<void>;
updateSessionItems(event: AuthenticationProviderAuthenticationSessionsChangeEvent): Promise<void>;
/**
* @deprecated use `createSession` instead.
*/
login(scopes: string[]): Promise<AuthenticationSession>;
/**
* @deprecated use `removeSession` instead.
*/
logout(sessionId: string): Promise<void>;
/**
* An [event](#Event) which fires when the array of sessions has changed, or data
* within a session has changed.
*/
readonly onDidChangeSessions: Event<AuthenticationProviderAuthenticationSessionsChangeEvent>;
/**
* 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<ReadonlyArray<AuthenticationSession>>;
/**
* 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<AuthenticationSession>;
/**
* Removes the session corresponding to session id.
* @param sessionId The id of the session to remove.
*/
removeSession(sessionId: string): Thenable<void>;
}
export const AuthenticationService = Symbol('AuthenticationService');
export interface AuthenticationService {
isAuthenticationProviderRegistered(id: string): boolean;
getProviderIds(): string[];
registerAuthenticationProvider(id: string, provider: AuthenticationProvider): void;
unregisterAuthenticationProvider(id: string): void;
requestNewSession(id: string, scopes: string[], extensionId: string, extensionName: string): void;
updateSessions(providerId: string, event: AuthenticationProviderAuthenticationSessionsChangeEvent): void;
readonly onDidRegisterAuthenticationProvider: Event<AuthenticationProviderInformation>;
readonly onDidUnregisterAuthenticationProvider: Event<AuthenticationProviderInformation>;
readonly onDidChangeSessions: Event<{ providerId: string, label: string, event: AuthenticationProviderAuthenticationSessionsChangeEvent }>;
getSessions(providerId: string, scopes?: string[]): Promise<ReadonlyArray<AuthenticationSession>>;
getLabel(providerId: string): string;
supportsMultipleAccounts(providerId: string): boolean;
login(providerId: string, scopes: string[]): Promise<AuthenticationSession>;
logout(providerId: string, sessionId: string): Promise<void>;
signOutOfAccount(providerId: string, accountName: string): Promise<void>;
}
export interface SessionChangeEvent {
providerId: string,
label: string,
event: AuthenticationProviderAuthenticationSessionsChangeEvent
}
export class AuthenticationServiceImpl implements AuthenticationService {
private noAccountsMenuItem: Disposable | undefined;
private noAccountsCommand: Command = { id: 'noAccounts' };
private signInRequestItems = new Map<string, SessionRequestInfo>();
private sessionMap = new Map<string, DisposableCollection>();
protected authenticationProviders: Map<string, AuthenticationProvider> = new Map<string, AuthenticationProvider>();
private onDidRegisterAuthenticationProviderEmitter: Emitter<AuthenticationProviderInformation> = new Emitter<AuthenticationProviderInformation>();
readonly onDidRegisterAuthenticationProvider: Event<AuthenticationProviderInformation> = this.onDidRegisterAuthenticationProviderEmitter.event;
private onDidUnregisterAuthenticationProviderEmitter: Emitter<AuthenticationProviderInformation> = new Emitter<AuthenticationProviderInformation>();
readonly onDidUnregisterAuthenticationProvider: Event<AuthenticationProviderInformation> = this.onDidUnregisterAuthenticationProviderEmitter.event;
private onDidChangeSessionsEmitter: Emitter<SessionChangeEvent> = new Emitter<SessionChangeEvent>();
readonly onDidChangeSessions: Event<SessionChangeEvent> = this.onDidChangeSessionsEmitter.event;
protected readonly menus: MenuModelRegistry;
protected readonly commands: CommandRegistry;
protected readonly storageService: StorageService;
init(): void {
this.onDidChangeSessions(event => this.handleSessionChange(event));
this.commands.registerCommand(this.noAccountsCommand, {
execute: () => { },
isEnabled: () => false
});
}
protected async handleSessionChange(changeEvent: SessionChangeEvent): Promise<void> {
if (changeEvent.event.added && 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 => {
providerIds.push(provider.id);
});
return providerIds;
}
isAuthenticationProviderRegistered(id: string): boolean {
return this.authenticationProviders.has(id);
}
private updateAccountsMenuItem(): void {
let hasSession = false;
this.authenticationProviders.forEach(async provider => {
hasSession = hasSession || provider.hasSessions();
});
if (hasSession && this.noAccountsMenuItem) {
this.noAccountsMenuItem.dispose();
this.noAccountsMenuItem = undefined;
}
if (!hasSession && !this.noAccountsMenuItem) {
this.noAccountsMenuItem = this.menus.registerMenuAction(ACCOUNTS_MENU, {
label: 'You are not signed in to any accounts',
order: '0',
commandId: this.noAccountsCommand.id
});
}
}
registerAuthenticationProvider(id: string, authenticationProvider: AuthenticationProvider): void {
if (this.authenticationProviders.get(id)) {
throw new Error(`An authentication provider with id '${id}' is already registered.`);
}
this.authenticationProviders.set(id, authenticationProvider);
this.onDidRegisterAuthenticationProviderEmitter.fire({ id, label: authenticationProvider.label });
this.updateAccountsMenuItem();
console.log(`An authentication provider with id '${id}' was registered.`);
}
unregisterAuthenticationProvider(id: string): void {
const provider = this.authenticationProviders.get(id);
if (provider) {
this.authenticationProviders.delete(id);
this.onDidUnregisterAuthenticationProviderEmitter.fire({ id, label: provider.label });
this.updateAccountsMenuItem();
} else {
console.error(`Failed to unregister an authentication provider. A provider with id '${id}' was not found.`);
}
}
async updateSessions(id: string, event: AuthenticationProviderAuthenticationSessionsChangeEvent): Promise<void> {
const provider = this.authenticationProviders.get(id);
if (provider) {
await provider.updateSessionItems(event);
this.onDidChangeSessionsEmitter.fire({ providerId: id, label: provider.label, event: event });
this.updateAccountsMenuItem();
if (event.added) {
await this.updateNewSessionRequests(provider);
}
} else {
console.error(`Failed to update an authentication session. An authentication provider with id '${id}' was not found.`);
}
}
private async updateNewSessionRequests(provider: AuthenticationProvider): Promise<void> {
const existingRequestsForProvider = this.signInRequestItems.get(provider.id);
if (!existingRequestsForProvider) {
return;
}
const sessions = await provider.getSessions();
Object.keys(existingRequestsForProvider).forEach(requestedScopes => {
if (sessions.some(session => session.scopes.slice().sort().join('') === requestedScopes)) {
const sessionRequest = existingRequestsForProvider[requestedScopes];
if (sessionRequest) {
sessionRequest.disposables.forEach(item => item.dispose());
}
delete existingRequestsForProvider[requestedScopes];
if (Object.keys(existingRequestsForProvider).length === 0) {
this.signInRequestItems.delete(provider.id);
} else {
this.signInRequestItems.set(provider.id, existingRequestsForProvider);
}
}
});
}
async requestNewSession(providerId: string, scopes: string[], extensionId: string, extensionName: string): Promise<void> {
let provider = this.authenticationProviders.get(providerId);
if (!provider) {
// Activate has already been called for the authentication provider, but it cannot block on registering itself
// since this is sync and returns a disposable. So, wait for registration event to fire that indicates the
// provider is now in the map.
await new Promise<void>((resolve, _) => {
this.onDidRegisterAuthenticationProvider(e => {
if (e.id === providerId) {
provider = this.authenticationProviders.get(providerId);
resolve(undefined);
}
});
});
}
if (provider) {
const providerRequests = this.signInRequestItems.get(providerId);
const scopesList = scopes.sort().join('');
const extensionHasExistingRequest = providerRequests
&& providerRequests[scopesList]
&& providerRequests[scopesList].requestingExtensionIds.indexOf(extensionId) > -1;
if (extensionHasExistingRequest) {
return;
}
const menuItem = this.menus.registerMenuAction(ACCOUNTS_SUBMENU, {
label: `Sign in to use ${extensionName} (1)`,
order: '1',
commandId: `${extensionId}signIn`,
});
const signInCommand = this.commands.registerCommand({ id: `${extensionId}signIn` }, {
execute: async () => {
const session = await this.login(providerId, scopes);
// Add extension to allow list since user explicitly signed in on behalf of it
const allowList = await readAllowedExtensions(this.storageService, providerId, session.account.label);
if (!allowList.find(allowed => allowed.id === extensionId)) {
allowList.push({ id: extensionId, name: extensionName });
this.storageService.setData(`authentication-trusted-extensions-${providerId}-${session.account.label}`, JSON.stringify(allowList));
}
// And also set it as the preferred account for the extension
this.storageService.setData(`authentication-session-${extensionName}-${providerId}`, session.id);
}
});
if (providerRequests) {
const existingRequest = providerRequests[scopesList] || { disposables: [], requestingExtensionIds: [] };
providerRequests[scopesList] = {
disposables: [...existingRequest.disposables, menuItem, signInCommand],
requestingExtensionIds: [...existingRequest.requestingExtensionIds, extensionId]
};
this.signInRequestItems.set(providerId, providerRequests);
} else {
this.signInRequestItems.set(providerId, {
[scopesList]: {
disposables: [menuItem, signInCommand],
requestingExtensionIds: [extensionId]
}
});
}
}
}
getLabel(id: string): string {
const authProvider = this.authenticationProviders.get(id);
if (authProvider) {
return authProvider.label;
} else {
throw new Error(`No authentication provider '${id}' is currently registered.`);
}
}
supportsMultipleAccounts(id: string): boolean {
const authProvider = this.authenticationProviders.get(id);
if (authProvider) {
return authProvider.supportsMultipleAccounts;
} else {
throw new Error(`No authentication provider '${id}' is currently registered.`);
}
}
async getSessions(id: string, scopes?: string[]): Promise<ReadonlyArray<AuthenticationSession>> {
const authProvider = this.authenticationProviders.get(id);
if (authProvider) {
return authProvider.getSessions(scopes);
} else {
throw new Error(`No authentication provider '${id}' is currently registered.`);
}
}
async login(id: string, scopes: string[]): Promise<AuthenticationSession> {
const authProvider = this.authenticationProviders.get(id);
if (authProvider) {
return authProvider.createSession(scopes);
} else {
throw new Error(`No authentication provider '${id}' is currently registered.`);
}
}
async logout(id: string, sessionId: string): Promise<void> {
const authProvider = this.authenticationProviders.get(id);
if (authProvider) {
return authProvider.removeSession(sessionId);
} else {
throw new Error(`No authentication provider '${id}' is currently registered.`);
}
}
async signOutOfAccount(id: string, accountName: string): Promise<void> {
const authProvider = this.authenticationProviders.get(id);
if (authProvider) {
return authProvider.signOut(accountName);
} else {
throw new Error(`No authentication provider '${id}' is currently registered.`);
}
}
}
export interface AllowedExtension {
id: string;
name: string;
}
export async function readAllowedExtensions(storageService: StorageService, providerId: string, accountName: string): Promise<AllowedExtension[]> {
let trustedExtensions: AllowedExtension[] = [];
try {
const trustedExtensionSrc: string | undefined = await storageService.getData(`authentication-trusted-extensions-${providerId}-${accountName}`);
if (trustedExtensionSrc) {
trustedExtensions = JSON.parse(trustedExtensionSrc);
}
} catch (err) {
console.error(err);
}
return trustedExtensions;
}