diff --git a/CHANGELOG.md b/CHANGELOG.md index 49cd5cf7bab25..320f08d4d921f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ +- [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 diff --git a/packages/core/src/browser/authentication-service.ts b/packages/core/src/browser/authentication-service.ts index a1f1c7120ff01..fa06485e39600 100644 --- a/packages/core/src/browser/authentication-service.ts +++ b/packages/core/src/browser/authentication-service.ts @@ -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; @@ -82,16 +89,6 @@ export interface AuthenticationProvider { updateSessionItems(event: AuthenticationProviderAuthenticationSessionsChangeEvent): Promise; - /** - * @deprecated use `createSession` instead. - */ - login(scopes: string[]): Promise; - - /** - * @deprecated use `removeSession` instead. - */ - logout(sessionId: string): Promise; - /** * An [event](#Event) which fires when the array of sessions has changed, or data * within a session has changed. @@ -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>; + getSessions(scopes: string[] | undefined, account?: AuthenticationSessionAccountInformation): Thenable>; /** * 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; + createSession(scopes: string[], options: AuthenticationProviderSessionOptions): Thenable; /** * Removes the session corresponding to session id. @@ -134,10 +133,10 @@ export interface AuthenticationService { readonly onDidChangeSessions: Event<{ providerId: string, label: string, event: AuthenticationProviderAuthenticationSessionsChangeEvent }>; readonly onDidUpdateSignInCount: Event; - getSessions(providerId: string, scopes?: string[]): Promise>; + getSessions(providerId: string, scopes?: string[], user?: AuthenticationSessionAccountInformation): Promise>; getLabel(providerId: string): string; supportsMultipleAccounts(providerId: string): boolean; - login(providerId: string, scopes: string[]): Promise; + login(providerId: string, scopes: string[], options?: AuthenticationProviderSessionOptions): Promise; logout(providerId: string, sessionId: string): Promise; signOutOfAccount(providerId: string, accountName: string): Promise; @@ -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]; @@ -411,19 +410,19 @@ export class AuthenticationServiceImpl implements AuthenticationService { } } - async getSessions(id: string, scopes?: string[]): Promise> { + async getSessions(id: string, scopes?: string[], user?: AuthenticationSessionAccountInformation): Promise> { 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 { + async login(id: string, scopes: string[], options?: AuthenticationProviderSessionOptions): Promise { 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.`); } diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index ed117bb1eac19..dab7e870d0251 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -2398,13 +2398,14 @@ export interface TasksMain { } export interface AuthenticationExt { - $getSessions(id: string, scopes?: string[]): Promise>; - $createSession(id: string, scopes: string[]): Promise; + $getSessions(providerId: string, scopes: string[] | undefined, options: theia.AuthenticationProviderSessionOptions): Promise>; + $createSession(id: string, scopes: string[], options: theia.AuthenticationProviderSessionOptions): Promise; $removeSession(id: string, sessionId: string): Promise; $onDidChangeAuthenticationSessions(provider: theia.AuthenticationProviderInformation): Promise; } export interface AuthenticationMain { + $getAccounts(providerId: string): Thenable; $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): void; $unregisterAuthenticationProvider(id: string): void; $onDidChangeSessions(providerId: string, event: AuthenticationProviderAuthenticationSessionsChangeEvent): void; diff --git a/packages/plugin-ext/src/main/browser/authentication-main.ts b/packages/plugin-ext/src/main/browser/authentication-main.ts index 65765e457ab98..58f3c4d8c935d 100644 --- a/packages/plugin-ext/src/main/browser/authentication-main.ts +++ b/packages/plugin-ext/src/main/browser/authentication-main.ts @@ -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'; @@ -77,9 +80,13 @@ export class AuthenticationMainImpl implements AuthenticationMain { return this.authenticationService.requestNewSession(providerId, scopes, extensionId, extensionName); } + $getAccounts(providerId: string): Thenable { + 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 { - 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) { @@ -140,26 +147,32 @@ export class AuthenticationMainImpl implements AuthenticationMain { } protected async selectSession(providerId: string, providerName: string, extensionId: string, extensionName: string, - potentialSessions: Readonly, scopes: string[], clearSessionPreference: boolean): Promise { + potentialSessions: Readonly, scopes: string[], clearSessionPreference: boolean): Promise { + 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; @@ -318,13 +331,13 @@ export class AuthenticationProviderImpl implements AuthenticationProvider { } } - async getSessions(scopes?: string[]): Promise> { - return this.proxy.$getSessions(this.id, scopes); + async getSessions(scopes?: string[], account?: AuthenticationSessionAccountInformation): Promise> { + return this.proxy.$getSessions(this.id, scopes, { account: account }); } async updateSessionItems(event: theia.AuthenticationProviderAuthenticationSessionsChangeEvent): Promise { const { added, removed } = event; - const session = await this.proxy.$getSessions(this.id); + 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 => { @@ -347,16 +360,16 @@ export class AuthenticationProviderImpl implements AuthenticationProvider { addedSessions.forEach(s => this.registerSession(s)); } - async login(scopes: string[]): Promise { - return this.createSession(scopes); + async login(scopes: string[], options: AuthenticationProviderSessionOptions): Promise { + return this.createSession(scopes, options); } async logout(sessionId: string): Promise { return this.removeSession(sessionId); } - createSession(scopes: string[]): Thenable { - return this.proxy.$createSession(this.id, scopes); + createSession(scopes: string[], options: AuthenticationProviderSessionOptions): Thenable { + return this.proxy.$createSession(this.id, scopes, options); } removeSession(sessionId: string): Thenable { diff --git a/packages/plugin-ext/src/plugin/authentication-ext.ts b/packages/plugin-ext/src/plugin/authentication-ext.ts index 7cf0e3e1c349a..8069406f13daa 100644 --- a/packages/plugin-ext/src/plugin/authentication-ext.ts +++ b/packages/plugin-ext/src/plugin/authentication-ext.ts @@ -57,6 +57,10 @@ export class AuthenticationExtImpl implements AuthenticationExt { return this.proxy.$getSession(providerId, scopes, extensionId, extensionName, options); } + getAccounts(providerId: string): Thenable { + 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.`); @@ -64,7 +68,7 @@ export class AuthenticationExtImpl implements AuthenticationExt { 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, @@ -87,10 +91,10 @@ export class AuthenticationExtImpl implements AuthenticationExt { }); } - $createSession(providerId: string, scopes: string[]): Promise { + $createSession(providerId: string, scopes: string[], options: theia.AuthenticationProviderSessionOptions): Promise { 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}`); @@ -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> { + async $getSessions(providerId: string, scopes: string[] | undefined, options: theia.AuthenticationProviderSessionOptions): Promise> { 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: diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 8a36492244edc..e4bfae6643065 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -338,6 +338,9 @@ export function createAPIFactory( }, get onDidChangeSessions(): theia.Event { return authenticationExt.onDidChangeSessions; + }, + getAccounts(providerId: string): Thenable { + return authenticationExt.getAccounts(providerId); } }; function commandIsDeclaredInPackage(id: string, model: PluginPackage): boolean { diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 4ce0f20f46c2a..14cf710ea9f12 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -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; } /** @@ -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. */ @@ -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; + getSessions(scopes: readonly string[] | undefined, options: AuthenticationProviderSessionOptions): Thenable; /** * Prompts a user to login. @@ -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; + createSession(scopes: readonly string[], options: AuthenticationProviderSessionOptions): Thenable; /** * Removes the session corresponding to session id. @@ -14235,6 +14254,20 @@ export module '@theia/plugin' { */ export function getSession(providerId: string, scopes: readonly string[], options?: AuthenticationGetSessionOptions): Thenable; + /** + * 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; + /** * An {@link Event event} which fires when the authentication sessions of an authentication provider have * been added, removed, or changed.