Skip to content

Commit

Permalink
Add handling of multiple accounts to authentication api (#14149)
Browse files Browse the repository at this point in the history
* Adds handling of multiple accounts to authentication api

Fixes #14110

Contributed on behalf of STMicroelectronics

Signed-off-by: Thomas Mäder <t.s.maeder@gmail.com>
  • Loading branch information
tsmaeder authored Sep 12, 2024
1 parent 8ed5001 commit e7e1963
Show file tree
Hide file tree
Showing 7 changed files with 93 additions and 39 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<!-- ## Unreleased
<a name="breaking_changes_1.54.0">[Breaking Changes:](#breaking_changes_1.54.0)</a> -->
- [core] Updated AuthenticationService to handle multiple accounts per provider [#14149](https://github.com/eclipse-theia/theia/pull/14149) - Contributed on behalf of STMicroelectronics

## 1.53.0 - 08/29/2024

Expand Down
37 changes: 18 additions & 19 deletions packages/core/src/browser/authentication-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ export interface AuthenticationSessionAccountInformation {
readonly id: string;
readonly label: string;
}
export interface AuthenticationProviderSessionOptions {
/**
* The account that is being asked about. If this is passed in, the provider should
* attempt to return the sessions that are only related to this account.
*/
account?: AuthenticationSessionAccountInformation;
}

export interface AuthenticationSession {
id: string;
Expand Down Expand Up @@ -82,16 +89,6 @@ export interface AuthenticationProvider {

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.
Expand All @@ -102,16 +99,18 @@ export interface AuthenticationProvider {
* 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.
* @param account The optional account that you would like to get the session for
* @returns A promise that resolves to an array of authentication sessions.
*/
getSessions(scopes?: string[]): Thenable<ReadonlyArray<AuthenticationSession>>;
getSessions(scopes: string[] | undefined, account?: AuthenticationSessionAccountInformation): Thenable<ReadonlyArray<AuthenticationSession>>;

/**
* Prompts a user to login.
* @param scopes A list of scopes, permissions, that the new session should be created with.
* @param options The options for createing the session
* @returns A promise that resolves to an authentication session.
*/
createSession(scopes: string[]): Thenable<AuthenticationSession>;
createSession(scopes: string[], options: AuthenticationProviderSessionOptions): Thenable<AuthenticationSession>;

/**
* Removes the session corresponding to session id.
Expand All @@ -134,10 +133,10 @@ export interface AuthenticationService {

readonly onDidChangeSessions: Event<{ providerId: string, label: string, event: AuthenticationProviderAuthenticationSessionsChangeEvent }>;
readonly onDidUpdateSignInCount: Event<number>;
getSessions(providerId: string, scopes?: string[]): Promise<ReadonlyArray<AuthenticationSession>>;
getSessions(providerId: string, scopes?: string[], user?: AuthenticationSessionAccountInformation): Promise<ReadonlyArray<AuthenticationSession>>;
getLabel(providerId: string): string;
supportsMultipleAccounts(providerId: string): boolean;
login(providerId: string, scopes: string[]): Promise<AuthenticationSession>;
login(providerId: string, scopes: string[], options?: AuthenticationProviderSessionOptions): Promise<AuthenticationSession>;
logout(providerId: string, sessionId: string): Promise<void>;

signOutOfAccount(providerId: string, accountName: string): Promise<void>;
Expand Down Expand Up @@ -300,7 +299,7 @@ export class AuthenticationServiceImpl implements AuthenticationService {
}

const previousSize = this.signInRequestItems.size;
const sessions = await provider.getSessions();
const sessions = await provider.getSessions(undefined);
Object.keys(existingRequestsForProvider).forEach(requestedScopes => {
if (sessions.some(session => session.scopes.slice().sort().join('') === requestedScopes)) {
const sessionRequest = existingRequestsForProvider[requestedScopes];
Expand Down Expand Up @@ -411,19 +410,19 @@ export class AuthenticationServiceImpl implements AuthenticationService {
}
}

async getSessions(id: string, scopes?: string[]): Promise<ReadonlyArray<AuthenticationSession>> {
async getSessions(id: string, scopes?: string[], user?: AuthenticationSessionAccountInformation): Promise<ReadonlyArray<AuthenticationSession>> {
const authProvider = this.authenticationProviders.get(id);
if (authProvider) {
return authProvider.getSessions(scopes);
return authProvider.getSessions(scopes, user);
} else {
throw new Error(`No authentication provider '${id}' is currently registered.`);
}
}

async login(id: string, scopes: string[]): Promise<AuthenticationSession> {
async login(id: string, scopes: string[], options?: AuthenticationProviderSessionOptions): Promise<AuthenticationSession> {
const authProvider = this.authenticationProviders.get(id);
if (authProvider) {
return authProvider.createSession(scopes);
return authProvider.createSession(scopes, options || {});
} else {
throw new Error(`No authentication provider '${id}' is currently registered.`);
}
Expand Down
5 changes: 3 additions & 2 deletions packages/plugin-ext/src/common/plugin-api-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2398,13 +2398,14 @@ export interface TasksMain {
}

export interface AuthenticationExt {
$getSessions(id: string, scopes?: string[]): Promise<ReadonlyArray<theia.AuthenticationSession>>;
$createSession(id: string, scopes: string[]): Promise<theia.AuthenticationSession>;
$getSessions(providerId: string, scopes: string[] | undefined, options: theia.AuthenticationProviderSessionOptions): Promise<ReadonlyArray<theia.AuthenticationSession>>;
$createSession(id: string, scopes: string[], options: theia.AuthenticationProviderSessionOptions): Promise<theia.AuthenticationSession>;
$removeSession(id: string, sessionId: string): Promise<void>;
$onDidChangeAuthenticationSessions(provider: theia.AuthenticationProviderInformation): Promise<void>;
}

export interface AuthenticationMain {
$getAccounts(providerId: string): Thenable<readonly theia.AuthenticationSessionAccountInformation[]>;
$registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): void;
$unregisterAuthenticationProvider(id: string): void;
$onDidChangeSessions(providerId: string, event: AuthenticationProviderAuthenticationSessionsChangeEvent): void;
Expand Down
35 changes: 24 additions & 11 deletions packages/plugin-ext/src/main/browser/authentication-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ import { MessageService } from '@theia/core/lib/common/message-service';
import { ConfirmDialog, Dialog, StorageService } from '@theia/core/lib/browser';
import {
AuthenticationProvider,
AuthenticationProviderSessionOptions,
AuthenticationService,
AuthenticationSession,
AuthenticationSessionAccountInformation,
readAllowedExtensions
} from '@theia/core/lib/browser/authentication-service';
import { QuickPickService } from '@theia/core/lib/common/quick-pick-service';
Expand Down Expand Up @@ -77,9 +80,13 @@ export class AuthenticationMainImpl implements AuthenticationMain {
return this.authenticationService.requestNewSession(providerId, scopes, extensionId, extensionName);
}

$getAccounts(providerId: string): Thenable<readonly theia.AuthenticationSessionAccountInformation[]> {
return this.authenticationService.getSessions(providerId).then(sessions => sessions.map(session => session.account));
}

async $getSession(providerId: string, scopes: string[], extensionId: string, extensionName: string,
options: theia.AuthenticationGetSessionOptions): Promise<theia.AuthenticationSession | undefined> {
const sessions = await this.authenticationService.getSessions(providerId, scopes);
const sessions = await this.authenticationService.getSessions(providerId, scopes, options?.account);

// Error cases
if (options.forceNewSession && !sessions.length) {
Expand Down Expand Up @@ -140,26 +147,32 @@ export class AuthenticationMainImpl implements AuthenticationMain {
}

protected async selectSession(providerId: string, providerName: string, extensionId: string, extensionName: string,
potentialSessions: Readonly<theia.AuthenticationSession[]>, scopes: string[], clearSessionPreference: boolean): Promise<theia.AuthenticationSession> {
potentialSessions: Readonly<AuthenticationSession[]>, scopes: string[], clearSessionPreference: boolean): Promise<theia.AuthenticationSession> {

if (!potentialSessions.length) {
throw new Error('No potential sessions found');
}

return new Promise(async (resolve, reject) => {
const items: QuickPickValue<{ session?: theia.AuthenticationSession }>[] = potentialSessions.map(session => ({
const items: QuickPickValue<{ session?: AuthenticationSession, account?: AuthenticationSessionAccountInformation }>[] = potentialSessions.map(session => ({
label: session.account.label,
value: { session }
}));
items.push({
label: nls.localizeByDefault('Sign in to another account'),
value: { session: undefined }
value: {}
});

// VS Code has code here that pushes accounts that have no active sessions. However, since we do not store
// any accounts that don't have sessions, we dont' do this.
const selected = await this.quickPickService.show(items,
{
title: nls.localizeByDefault("The extension '{0}' wants to access a {1} account", extensionName, providerName),
ignoreFocusOut: true
});
if (selected) {

// if we ever have accounts without sessions, pass the account to the login call
const session = selected.value?.session ?? await this.authenticationService.login(providerId, scopes);
const accountName = session.account.label;

Expand Down Expand Up @@ -318,13 +331,13 @@ export class AuthenticationProviderImpl implements AuthenticationProvider {
}
}

async getSessions(scopes?: string[]): Promise<ReadonlyArray<theia.AuthenticationSession>> {
return this.proxy.$getSessions(this.id, scopes);
async getSessions(scopes?: string[], account?: AuthenticationSessionAccountInformation): Promise<ReadonlyArray<theia.AuthenticationSession>> {
return this.proxy.$getSessions(this.id, scopes, { account: account });
}

async updateSessionItems(event: theia.AuthenticationProviderAuthenticationSessionsChangeEvent): Promise<void> {
const { added, removed } = event;
const session = await this.proxy.$getSessions(this.id);
const session = await this.proxy.$getSessions(this.id, undefined, {});
const addedSessions = added ? session.filter(s => added.some(addedSession => addedSession.id === s.id)) : [];

removed?.forEach(removedSession => {
Expand All @@ -347,16 +360,16 @@ export class AuthenticationProviderImpl implements AuthenticationProvider {
addedSessions.forEach(s => this.registerSession(s));
}

async login(scopes: string[]): Promise<theia.AuthenticationSession> {
return this.createSession(scopes);
async login(scopes: string[], options: AuthenticationProviderSessionOptions): Promise<theia.AuthenticationSession> {
return this.createSession(scopes, options);
}

async logout(sessionId: string): Promise<void> {
return this.removeSession(sessionId);
}

createSession(scopes: string[]): Thenable<theia.AuthenticationSession> {
return this.proxy.$createSession(this.id, scopes);
createSession(scopes: string[], options: AuthenticationProviderSessionOptions): Thenable<theia.AuthenticationSession> {
return this.proxy.$createSession(this.id, scopes, options);
}

removeSession(sessionId: string): Thenable<void> {
Expand Down
14 changes: 9 additions & 5 deletions packages/plugin-ext/src/plugin/authentication-ext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,18 @@ export class AuthenticationExtImpl implements AuthenticationExt {
return this.proxy.$getSession(providerId, scopes, extensionId, extensionName, options);
}

getAccounts(providerId: string): Thenable<readonly theia.AuthenticationSessionAccountInformation[]> {
return this.proxy.$getAccounts(providerId);
}

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(id, provider);

provider.getSessions().then(sessions => { // sessions might have been restored from secret storage
provider.getSessions(undefined, {}).then(sessions => { // sessions might have been restored from secret storage
if (sessions.length > 0) {
this.proxy.$onDidChangeSessions(id, {
added: sessions,
Expand All @@ -87,10 +91,10 @@ export class AuthenticationExtImpl implements AuthenticationExt {
});
}

$createSession(providerId: string, scopes: string[]): Promise<theia.AuthenticationSession> {
$createSession(providerId: string, scopes: string[], options: theia.AuthenticationProviderSessionOptions): Promise<theia.AuthenticationSession> {
const authProvider = this.authenticationProviders.get(providerId);
if (authProvider) {
return Promise.resolve(authProvider.createSession(scopes));
return Promise.resolve(authProvider.createSession(scopes, options));
}

throw new Error(`Unable to find authentication provider with handle: ${providerId}`);
Expand All @@ -105,10 +109,10 @@ export class AuthenticationExtImpl implements AuthenticationExt {
throw new Error(`Unable to find authentication provider with handle: ${providerId}`);
}

async $getSessions(providerId: string, scopes?: string[]): Promise<ReadonlyArray<theia.AuthenticationSession>> {
async $getSessions(providerId: string, scopes: string[] | undefined, options: theia.AuthenticationProviderSessionOptions): Promise<ReadonlyArray<theia.AuthenticationSession>> {
const authProvider = this.authenticationProviders.get(providerId);
if (authProvider) {
const sessions = await authProvider.getSessions(scopes);
const sessions = await authProvider.getSessions(scopes, options);

/* 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:
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin-ext/src/plugin/plugin-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,9 @@ export function createAPIFactory(
},
get onDidChangeSessions(): theia.Event<theia.AuthenticationSessionsChangeEvent> {
return authenticationExt.onDidChangeSessions;
},
getAccounts(providerId: string): Thenable<readonly theia.AuthenticationSessionAccountInformation[]> {
return authenticationExt.getAccounts(providerId);
}
};
function commandIsDeclaredInPackage(id: string, model: PluginPackage): boolean {
Expand Down
37 changes: 35 additions & 2 deletions packages/plugin/src/theia.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14084,6 +14084,11 @@ export module '@theia/plugin' {
* Note: you cannot use this option with any other options that prompt the user like {@link createIfNone}.
*/
silent?: boolean;

/**
* The account that you would like to get a session for. This is passed down to the Authentication Provider to be used for creating the correct session.
*/
account?: AuthenticationSessionAccountInformation;
}

/**
Expand Down Expand Up @@ -14144,6 +14149,18 @@ export module '@theia/plugin' {
readonly changed: readonly AuthenticationSession[] | undefined;
}

/**
* The options passed in to the {@link AuthenticationProvider.getSessions} and
* {@link AuthenticationProvider.createSession} call.
*/
export interface AuthenticationProviderSessionOptions {
/**
* The account that is being asked about. If this is passed in, the provider should
* attempt to return the sessions that are only related to this account.
*/
account?: AuthenticationSessionAccountInformation;
}

/**
* A provider for performing authentication to a service.
*/
Expand All @@ -14158,9 +14175,10 @@ export module '@theia/plugin' {
* 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.
* @param options Additional options for getting sessions.
* @returns A promise that resolves to an array of authentication sessions.
*/
getSessions(scopes?: readonly string[]): Thenable<readonly AuthenticationSession[]>;
getSessions(scopes: readonly string[] | undefined, options: AuthenticationProviderSessionOptions): Thenable<AuthenticationSession[]>;

/**
* Prompts a user to login.
Expand All @@ -14173,9 +14191,10 @@ export module '@theia/plugin' {
* 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.
* @param options Additional options for creating a session.
* @returns A promise that resolves to an authentication session.
*/
createSession(scopes: readonly string[]): Thenable<AuthenticationSession>;
createSession(scopes: readonly string[], options: AuthenticationProviderSessionOptions): Thenable<AuthenticationSession>;

/**
* Removes the session corresponding to session id.
Expand Down Expand Up @@ -14235,6 +14254,20 @@ export module '@theia/plugin' {
*/
export function getSession(providerId: string, scopes: readonly string[], options?: AuthenticationGetSessionOptions): Thenable<AuthenticationSession | undefined>;

/**
* Get all accounts that the user is logged in to for the specified provider.
* Use this paired with {@link getSession} in order to get an authentication session for a specific account.
*
* 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'.
*
* Note: Getting accounts does not imply that your extension has access to that account or its authentication sessions. You can verify access to the account by calling {@link getSession}.
*
* @param providerId The id of the provider to use
* @returns A thenable that resolves to a readonly array of authentication accounts.
*/
export function getAccounts(providerId: string): Thenable<readonly AuthenticationSessionAccountInformation[]>;

/**
* An {@link Event event} which fires when the authentication sessions of an authentication provider have
* been added, removed, or changed.
Expand Down

0 comments on commit e7e1963

Please sign in to comment.