Allow Core to contribute auth providers (#201741)

* Allow Core to contribute auth providers

This props us up to allow embedders to contribute their own auth providers that override. This is moving in the direction to resolve this one: https://github.com/microsoft/vscode/issues/197389

* handle the change to AuthenticationSessionsChangeEvent to align with vscode API
This commit is contained in:
Tyler James Leonhardt 2024-01-03 13:57:54 -08:00 committed by GitHub
parent 25974b8afc
commit eb55c74a90
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 177 additions and 141 deletions

View file

@ -3,116 +3,36 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from 'vs/base/common/lifecycle';
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 { AllowedExtension, readAllowedExtensions, getAuthenticationProviderActivationEvent, addAccountUsage, readAccountUsages, removeAccountUsage } from 'vs/workbench/services/authentication/browser/authenticationService';
import { getAuthenticationProviderActivationEvent, addAccountUsage } from 'vs/workbench/services/authentication/browser/authenticationService';
import { IAuthenticationCreateSessionOptions, AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication';
import { ExtHostAuthenticationShape, ExtHostContext, MainContext, MainThreadAuthenticationShape } from '../common/extHost.protocol';
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
import Severity from 'vs/base/common/severity';
import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput';
import { INotificationService } from 'vs/platform/notification/common/notification';
import { fromNow } from 'vs/base/common/date';
import { ActivationKind, IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import type { AuthenticationGetSessionOptions } from 'vscode';
import { Emitter, Event } from 'vs/base/common/event';
interface TrustedExtensionsQuickPickItem {
label: string;
description: string;
extension: AllowedExtension;
}
export class MainThreadAuthenticationProvider extends Disposable implements IAuthenticationProvider {
readonly onDidChangeSessions: Event<AuthenticationSessionsChangeEvent>;
constructor(
private readonly _proxy: ExtHostAuthenticationShape,
public readonly id: string,
public readonly label: string,
public readonly supportsMultipleAccounts: boolean,
private readonly notificationService: INotificationService,
private readonly storageService: IStorageService,
private readonly quickInputService: IQuickInputService,
private readonly dialogService: IDialogService
onDidChangeSessionsEmitter: Emitter<AuthenticationSessionsChangeEvent>,
) {
super();
}
public manageTrustedExtensions(accountName: string) {
const allowedExtensions = readAllowedExtensions(this.storageService, this.id, accountName);
if (!allowedExtensions.length) {
this.dialogService.info(nls.localize('noTrustedExtensions', "This account has not been used by any extensions."));
return;
}
const quickPick = this.quickInputService.createQuickPick<TrustedExtensionsQuickPickItem>();
quickPick.canSelectMany = true;
quickPick.customButton = true;
quickPick.customLabel = nls.localize('manageTrustedExtensions.cancel', 'Cancel');
const usages = readAccountUsages(this.storageService, this.id, accountName);
const items = allowedExtensions.map(extension => {
const usage = usages.find(usage => extension.id === usage.extensionId);
return {
label: extension.name,
description: usage
? nls.localize({ key: 'accountLastUsedDate', comment: ['The placeholder {0} is a string with time information, such as "3 days ago"'] }, "Last used this account {0}", fromNow(usage.lastUsed, true))
: nls.localize('notUsed', "Has not used this account"),
extension
};
});
quickPick.items = items;
quickPick.selectedItems = items.filter(item => item.extension.allowed === undefined || item.extension.allowed);
quickPick.title = nls.localize('manageTrustedExtensions', "Manage Trusted Extensions");
quickPick.placeholder = nls.localize('manageExtensions', "Choose which extensions can access this account");
quickPick.onDidAccept(() => {
const updatedAllowedList = quickPick.items
.map(i => (i as TrustedExtensionsQuickPickItem).extension);
this.storageService.store(`${this.id}-${accountName}`, JSON.stringify(updatedAllowedList), StorageScope.APPLICATION, StorageTarget.USER);
quickPick.dispose();
});
quickPick.onDidChangeSelection((changed) => {
quickPick.items.forEach(item => {
if ((item as TrustedExtensionsQuickPickItem).extension) {
(item as TrustedExtensionsQuickPickItem).extension.allowed = false;
}
});
changed.forEach((item) => item.extension.allowed = true);
});
quickPick.onDidHide(() => {
quickPick.dispose();
});
quickPick.onDidCustom(() => {
quickPick.hide();
});
quickPick.show();
}
async removeAccountSessions(accountName: string, sessions: AuthenticationSession[]): Promise<void> {
const accountUsages = readAccountUsages(this.storageService, this.id, accountName);
const { confirmed } = await this.dialogService.confirm({
type: Severity.Info,
message: accountUsages.length
? nls.localize('signOutMessage', "The account '{0}' has been used by: \n\n{1}\n\n Sign out from these extensions?", accountName, accountUsages.map(usage => usage.extensionName).join('\n'))
: nls.localize('signOutMessageSimple', "Sign out of '{0}'?", accountName),
primaryButton: nls.localize({ key: 'signOut', comment: ['&& denotes a mnemonic'] }, "&&Sign Out")
});
if (confirmed) {
const removeSessionPromises = sessions.map(session => this.removeSession(session.id));
await Promise.all(removeSessionPromises);
removeAccountUsage(this.storageService, this.id, accountName);
this.storageService.remove(`${this.id}-${accountName}`, StorageScope.APPLICATION);
}
this.onDidChangeSessions = onDidChangeSessionsEmitter.event;
}
async getSessions(scopes?: string[]) {
@ -133,13 +53,14 @@ export class MainThreadAuthenticationProvider extends Disposable implements IAut
export class MainThreadAuthentication extends Disposable implements MainThreadAuthenticationShape {
private readonly _proxy: ExtHostAuthenticationShape;
private readonly _registrations = this._register(new DisposableMap<string>());
constructor(
extHostContext: IExtHostContext,
@IAuthenticationService private readonly authenticationService: IAuthenticationService,
@IDialogService private readonly dialogService: IDialogService,
@IStorageService private readonly storageService: IStorageService,
@INotificationService private readonly notificationService: INotificationService,
@IQuickInputService private readonly quickInputService: IQuickInputService,
@IExtensionService private readonly extensionService: IExtensionService,
@ITelemetryService private readonly telemetryService: ITelemetryService
) {
@ -158,11 +79,14 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
}
async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): Promise<void> {
const provider = new MainThreadAuthenticationProvider(this._proxy, id, label, supportsMultipleAccounts, this.notificationService, this.storageService, this.quickInputService, this.dialogService);
const emitter = new Emitter<AuthenticationSessionsChangeEvent>();
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);
}
@ -170,8 +94,11 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
return this.extensionService.activateByEvent(getAuthenticationProviderActivationEvent(id), ActivationKind.Immediate);
}
$sendDidChangeSessions(id: string, event: AuthenticationSessionsChangeEvent): void {
this.authenticationService.sessionsUpdate(id, event);
$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<void> {

View file

@ -83,13 +83,7 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape {
});
}
const listener = provider.onDidChangeSessions(e => {
this._proxy.$sendDidChangeSessions(id, {
added: e.added ?? [],
changed: e.changed ?? [],
removed: e.removed ?? []
});
});
const listener = provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(id, e));
this._proxy.$registerAuthenticationProvider(id, label, options?.supportsMultipleAccounts ?? false);

View file

@ -331,15 +331,17 @@ export class AccountsActivityActionViewItem extends AbstractGlobalActivityAction
}));
this._register(this.authenticationService.onDidChangeSessions(async e => {
for (const changed of [...e.event.changed, ...e.event.added]) {
for (const changed of [...(e.event.changed ?? []), ...(e.event.added ?? [])]) {
try {
await this.addOrUpdateAccount(e.providerId, changed.account);
} catch (e) {
this.logService.error(e);
}
}
for (const removed of e.event.removed) {
this.removeAccount(e.providerId, removed.account);
if (e.event.removed) {
for (const removed of e.event.removed) {
this.removeAccount(e.providerId, removed.account);
}
}
}));
}

View file

@ -100,9 +100,9 @@ class AccountsEntitlement extends Disposable implements IWorkbenchContribution {
}));
this._register(this.authenticationService.onDidChangeSessions(async (e) => {
if (e.providerId === this.productService.gitHubEntitlement!.providerId && e.event.added.length > 0 && !this.isInitialized) {
if (e.providerId === this.productService.gitHubEntitlement!.providerId && e.event.added?.length && !this.isInitialized) {
this.onSessionChange(e.event.added[0]);
} else if (e.providerId === this.productService.gitHubEntitlement!.providerId && e.event.removed.length > 0) {
} else if (e.providerId === this.productService.gitHubEntitlement!.providerId && e.event.removed?.length) {
this.contextKey.set(false);
}
}));

View file

@ -448,7 +448,7 @@ export class EditSessionsWorkbenchService extends Disposable implements IEditSes
}
private onDidChangeSessions(e: AuthenticationSessionsChangeEvent): void {
if (this.authenticationInfo?.sessionId && e.removed.find(session => session.id === this.authenticationInfo?.sessionId)) {
if (this.authenticationInfo?.sessionId && e.removed?.find(session => session.id === this.authenticationInfo?.sessionId)) {
this.clearAuthenticationPreference();
}
}

View file

@ -3,10 +3,10 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { flatten } from 'vs/base/common/arrays';
import { fromNow } from 'vs/base/common/date';
import { Emitter, Event } from 'vs/base/common/event';
import { IJSONSchema } from 'vs/base/common/jsonSchema';
import { Disposable, dispose, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle';
import { Disposable, DisposableMap, DisposableStore, dispose, IDisposable, isDisposable, MutableDisposable } from 'vs/base/common/lifecycle';
import { isFalsyOrWhitespace } from 'vs/base/common/strings';
import { isString } from 'vs/base/common/types';
import * as nls from 'vs/nls';
@ -34,7 +34,9 @@ interface IAccountUsage {
lastUsed: number;
}
export function readAccountUsages(storageService: IStorageService, providerId: string, accountName: string,): IAccountUsage[] {
// TODO: make this account usage stuff a service
function readAccountUsages(storageService: IStorageService, providerId: string, accountName: string,): IAccountUsage[] {
const accountKey = `${providerId}-${accountName}-usages`;
const storedUsages = storageService.get(accountKey, StorageScope.APPLICATION);
let usages: IAccountUsage[] = [];
@ -49,7 +51,7 @@ export function readAccountUsages(storageService: IStorageService, providerId: s
return usages;
}
export function removeAccountUsage(storageService: IStorageService, providerId: string, accountName: string): void {
function removeAccountUsage(storageService: IStorageService, providerId: string, accountName: string): void {
const accountKey = `${providerId}-${accountName}-usages`;
storageService.remove(accountKey, StorageScope.APPLICATION);
}
@ -107,7 +109,7 @@ export interface AllowedExtension {
allowed?: boolean;
}
export function readAllowedExtensions(storageService: IStorageService, providerId: string, accountName: string): AllowedExtension[] {
function readAllowedExtensions(storageService: IStorageService, providerId: string, accountName: string): AllowedExtension[] {
let trustedExtensions: AllowedExtension[] = [];
try {
const trustedExtensionSrc = storageService.get(`${providerId}-${accountName}`, StorageScope.APPLICATION);
@ -182,6 +184,7 @@ export class AuthenticationService extends Disposable implements IAuthentication
private _accountBadgeDisposable = this._register(new MutableDisposable());
private _authenticationProviders: Map<string, IAuthenticationProvider> = new Map<string, IAuthenticationProvider>();
private _authenticationProviderDisposables: DisposableMap<string, IDisposable> = this._register(new DisposableMap<string, IDisposable>());
/**
* All providers that have been statically declared by extensions. These may not be registered.
@ -231,7 +234,7 @@ export class AuthenticationService extends Disposable implements IAuthentication
}
});
const removedExtPoints = flatten(removed.map(r => r.value));
const removedExtPoints = removed.flatMap(r => r.value);
removedExtPoints.forEach(point => {
const index = this.declaredProviders.findIndex(provider => provider.id === point.id);
if (index > -1) {
@ -257,6 +260,12 @@ export class AuthenticationService extends Disposable implements IAuthentication
registerAuthenticationProvider(id: string, authenticationProvider: IAuthenticationProvider): void {
this._authenticationProviders.set(id, authenticationProvider);
const disposableStore = new DisposableStore();
disposableStore.add(authenticationProvider.onDidChangeSessions(e => this.sessionsUpdate(authenticationProvider, e)));
if (isDisposable(authenticationProvider)) {
disposableStore.add(authenticationProvider);
}
this._authenticationProviderDisposables.set(id, disposableStore);
this._onDidRegisterAuthenticationProvider.fire({ id, label: authenticationProvider.label });
if (placeholderMenuItem) {
@ -268,7 +277,6 @@ export class AuthenticationService extends Disposable implements IAuthentication
unregisterAuthenticationProvider(id: string): void {
const provider = this._authenticationProviders.get(id);
if (provider) {
provider.dispose();
this._authenticationProviders.delete(id);
this._onDidUnregisterAuthenticationProvider.fire({ id, label: provider.label });
@ -277,6 +285,7 @@ export class AuthenticationService extends Disposable implements IAuthentication
this.removeAccessRequest(id, extensionId);
});
}
this._authenticationProviderDisposables.deleteAndDispose(id);
if (!this._authenticationProviders.size) {
placeholderMenuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, {
@ -289,21 +298,15 @@ export class AuthenticationService extends Disposable implements IAuthentication
}
}
async sessionsUpdate(id: string, event: AuthenticationSessionsChangeEvent): Promise<void> {
const provider = this._authenticationProviders.get(id);
if (provider) {
this._onDidChangeSessions.fire({ providerId: id, label: provider.label, event: event });
if (event.added) {
await this.updateNewSessionRequests(provider, event.added);
}
if (event.removed) {
await this.updateAccessRequests(id, event.removed);
}
this.updateBadgeCount();
private async sessionsUpdate(provider: IAuthenticationProvider, event: AuthenticationSessionsChangeEvent): Promise<void> {
this._onDidChangeSessions.fire({ providerId: provider.id, label: provider.label, event });
if (event.added?.length) {
await this.updateNewSessionRequests(provider, event.added);
}
if (event.removed?.length) {
await this.updateAccessRequests(provider.id, event.removed);
}
this.updateBadgeCount();
}
private async updateNewSessionRequests(provider: IAuthenticationProvider, addedSessions: readonly AuthenticationSession[]): Promise<void> {
@ -769,20 +772,91 @@ export class AuthenticationService extends Disposable implements IAuthentication
async manageTrustedExtensionsForAccount(id: string, accountName: string): Promise<void> {
const authProvider = this._authenticationProviders.get(id);
if (authProvider) {
return authProvider.manageTrustedExtensions(accountName);
} else {
if (!authProvider) {
throw new Error(`No authentication provider '${id}' is currently registered.`);
}
const allowedExtensions = readAllowedExtensions(this.storageService, authProvider.id, accountName);
if (!allowedExtensions.length) {
this.dialogService.info(nls.localize('noTrustedExtensions', "This account has not been used by any extensions."));
return;
}
type TrustedExtensionsQuickPickItem = {
label: string;
description: string;
extension: AllowedExtension;
};
const quickPick = this.quickInputService.createQuickPick<TrustedExtensionsQuickPickItem>();
quickPick.canSelectMany = true;
quickPick.customButton = true;
quickPick.customLabel = nls.localize('manageTrustedExtensions.cancel', 'Cancel');
const usages = readAccountUsages(this.storageService, authProvider.id, accountName);
const items = allowedExtensions.map(extension => {
const usage = usages.find(usage => extension.id === usage.extensionId);
return {
label: extension.name,
description: usage
? nls.localize({ key: 'accountLastUsedDate', comment: ['The placeholder {0} is a string with time information, such as "3 days ago"'] }, "Last used this account {0}", fromNow(usage.lastUsed, true))
: nls.localize('notUsed', "Has not used this account"),
extension
};
});
quickPick.items = items;
quickPick.selectedItems = items.filter(item => item.extension.allowed === undefined || item.extension.allowed);
quickPick.title = nls.localize('manageTrustedExtensions', "Manage Trusted Extensions");
quickPick.placeholder = nls.localize('manageExtensions', "Choose which extensions can access this account");
quickPick.onDidAccept(() => {
const updatedAllowedList = quickPick.items.map(i => (i as TrustedExtensionsQuickPickItem).extension);
this.storageService.store(`${authProvider.id}-${accountName}`, JSON.stringify(updatedAllowedList), StorageScope.APPLICATION, StorageTarget.USER);
quickPick.dispose();
});
quickPick.onDidChangeSelection((changed) => {
quickPick.items.forEach(item => {
if ((item as TrustedExtensionsQuickPickItem).extension) {
(item as TrustedExtensionsQuickPickItem).extension.allowed = false;
}
});
changed.forEach((item) => item.extension.allowed = true);
});
quickPick.onDidHide(() => {
quickPick.dispose();
});
quickPick.onDidCustom(() => {
quickPick.hide();
});
quickPick.show();
}
async removeAccountSessions(id: string, accountName: string, sessions: AuthenticationSession[]): Promise<void> {
const authProvider = this._authenticationProviders.get(id);
if (authProvider) {
return authProvider.removeAccountSessions(accountName, sessions);
} else {
if (!authProvider) {
throw new Error(`No authentication provider '${id}' is currently registered.`);
}
const accountUsages = readAccountUsages(this.storageService, authProvider.id, accountName);
const { confirmed } = await this.dialogService.confirm({
type: Severity.Info,
message: accountUsages.length
? nls.localize('signOutMessage', "The account '{0}' has been used by: \n\n{1}\n\n Sign out from these extensions?", accountName, accountUsages.map(usage => usage.extensionName).join('\n'))
: nls.localize('signOutMessageSimple', "Sign out of '{0}'?", accountName),
primaryButton: nls.localize({ key: 'signOut', comment: ['&& denotes a mnemonic'] }, "&&Sign Out")
});
if (confirmed) {
const removeSessionPromises = sessions.map(session => authProvider.removeSession(session.id));
await Promise.all(removeSessionPromises);
removeAccountUsage(this.storageService, authProvider.id, accountName);
this.storageService.remove(`${authProvider.id}-${accountName}`, StorageScope.APPLICATION);
}
}
}

View file

@ -19,9 +19,9 @@ export interface AuthenticationSession {
}
export interface AuthenticationSessionsChangeEvent {
added: ReadonlyArray<AuthenticationSession>;
removed: ReadonlyArray<AuthenticationSession>;
changed: ReadonlyArray<AuthenticationSession>;
added: ReadonlyArray<AuthenticationSession> | undefined;
removed: ReadonlyArray<AuthenticationSession> | undefined;
changed: ReadonlyArray<AuthenticationSession> | undefined;
}
export interface AuthenticationProviderInformation {
@ -53,7 +53,6 @@ export interface IAuthenticationService {
requestSessionAccess(providerId: string, extensionId: string, extensionName: string, scopes: string[], possibleSessions: readonly AuthenticationSession[]): void;
completeSessionAccessRequest(providerId: string, extensionId: string, extensionName: string, scopes: string[]): Promise<void>;
requestNewSession(providerId: string, scopes: string[], extensionId: string, extensionName: string): Promise<void>;
sessionsUpdate(providerId: string, event: AuthenticationSessionsChangeEvent): void;
readonly onDidRegisterAuthenticationProvider: Event<AuthenticationProviderInformation>;
readonly onDidUnregisterAuthenticationProvider: Event<AuthenticationProviderInformation>;
@ -78,14 +77,54 @@ export interface IAuthenticationProviderCreateSessionOptions {
sessionToRecreate?: AuthenticationSession;
}
/**
* Represents an authentication provider.
*/
export interface IAuthenticationProvider {
/**
* The unique identifier of the authentication provider.
*/
readonly id: string;
/**
* The display label of the authentication provider.
*/
readonly label: string;
/**
* Indicates whether the authentication provider supports multiple accounts.
*/
readonly supportsMultipleAccounts: boolean;
dispose(): void;
manageTrustedExtensions(accountName: string): void;
removeAccountSessions(accountName: string, sessions: AuthenticationSession[]): Promise<void>;
/**
* An {@link Event} which fires when the array of sessions has changed, or data
* within a session has changed.
*/
readonly onDidChangeSessions: Event<AuthenticationSessionsChangeEvent>;
/**
* Retrieves a list of authentication 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[]): Promise<readonly AuthenticationSession[]>;
/**
* Prompts the user to log in.
* If login is successful, the `onDidChangeSessions` event should be fired.
* If login fails, a rejected promise should be returned.
* If the provider does not support multiple accounts, this method should not be called if there is already an existing session matching the provided scopes.
* @param scopes - A list of scopes that the new session should be created with.
* @param options - Additional options for creating the session.
* @returns A promise that resolves to an authentication session.
*/
createSession(scopes: string[], options: IAuthenticationProviderCreateSessionOptions): Promise<AuthenticationSession>;
/**
* Removes the session corresponding to the specified 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): Promise<void>;
}

View file

@ -692,7 +692,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat
}
private onDidChangeSessions(e: AuthenticationSessionsChangeEvent): void {
if (this.currentSessionId && e.removed.find(session => session.id === this.currentSessionId)) {
if (this.currentSessionId && e.removed?.find(session => session.id === this.currentSessionId)) {
this.currentSessionId = undefined;
}
this.update('change in sessions');