From c2a8e203b307f3e509ff213e2c238b291c5d5450 Mon Sep 17 00:00:00 2001 From: Florent Benoit Date: Wed, 9 Apr 2025 21:39:33 +0200 Subject: [PATCH] feat: allow to select account --- src/api/cloud-account.ts | 47 +++++++ src/helper/account-select-helper.spec.ts | 117 +++++++++++++++++ src/helper/account-select-helper.ts | 50 ++++++++ src/helper/helper-module.ts | 2 + .../iam-session-refresh-token-helper.spec.ts | 50 +++++++- .../iam-session-refresh-token-helper.ts | 11 +- .../authentication-provider-manager.spec.ts | 120 +++++++++++++++--- .../authentication-provider-manager.ts | 32 ++++- 8 files changed, 403 insertions(+), 26 deletions(-) create mode 100644 src/api/cloud-account.ts create mode 100644 src/helper/account-select-helper.spec.ts create mode 100644 src/helper/account-select-helper.ts diff --git a/src/api/cloud-account.ts b/src/api/cloud-account.ts new file mode 100644 index 0000000..526b50e --- /dev/null +++ b/src/api/cloud-account.ts @@ -0,0 +1,47 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { z } from 'zod'; + +const CloudAccountResourceSchema = z.object({ + metadata: z.object({ + guid: z.string(), + }), + entity: z.object({ + name: z.string(), + primary_owner: z.object({ + ibmid: z.string(), + }), + }), +}); + +/** + * Schema for validating account data. + */ +export const CloudAccountSchema = z.object({ + next_url: z.string().nullable(), + resources: z.array(CloudAccountResourceSchema), +}); + +export type CloudAccountType = z.infer; + +export interface CloudAccount { + guid: string; + name: string; + ibmid: string; +} diff --git a/src/helper/account-select-helper.spec.ts b/src/helper/account-select-helper.spec.ts new file mode 100644 index 0000000..152b4f8 --- /dev/null +++ b/src/helper/account-select-helper.spec.ts @@ -0,0 +1,117 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { beforeAll, beforeEach, expect, test, vi } from 'vitest'; +import { Container } from 'inversify'; + +import { AccountSelectHelper } from './account-select-helper'; +import type { IAMSession } from '../api/iam-session'; +import type { CloudAccountType } from '../api/cloud-account'; + +let accountSelectHelper: AccountSelectHelper; + +beforeAll(() => { + // Mock the fetch function globally + Object.defineProperty(globalThis, 'fetch', { + writable: true, + value: vi.fn<(input: string | URL | globalThis.Request, init?: RequestInit) => Promise>(), + }); +}); + +beforeEach(async () => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + + // Create fresh instance each time + const container = new Container(); + container.bind(AccountSelectHelper).toSelf().inSingletonScope(); + + accountSelectHelper = await container.getAsync(AccountSelectHelper); +}); + +test('should fetch and return valid CloudAccount list', async () => { + expect.assertions(2); + + const session: IAMSession = { + access_token: 'mock-access-token', + } as unknown as IAMSession; + + const validAccountResponse: CloudAccountType = { + next_url: '', + resources: [ + { + metadata: { guid: 'account-1' }, + entity: { + name: 'Account One', + primary_owner: { ibmid: 'user1@ibm.com' }, + }, + }, + { + metadata: { guid: 'account-2' }, + entity: { + name: 'Account Two', + primary_owner: { ibmid: 'user2@ibm.com' }, + }, + }, + ], + }; + + vi.mocked(fetch).mockResolvedValue({ + json: async () => validAccountResponse, + } as Response); + + const result = await accountSelectHelper.getAccounts(session); + + expect(fetch).toHaveBeenCalledWith(new URL(AccountSelectHelper.ACCOUNT_URL), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${session.access_token}`, + }, + }); + + expect(result).toStrictEqual([ + { guid: 'account-1', name: 'Account One', ibmid: 'user1@ibm.com' }, + { guid: 'account-2', name: 'Account Two', ibmid: 'user2@ibm.com' }, + ]); +}); + +test('should throw error on invalid account response', async () => { + expect.assertions(2); + + const session: IAMSession = { + access_token: 'mock-access-token', + } as unknown as IAMSession; + + const invalidResponse = { foo: 'bar' }; + + vi.mocked(fetch).mockResolvedValue({ + json: async () => invalidResponse, + } as Response); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(accountSelectHelper.getAccounts(session)).rejects.toThrow('Invalid account'); + + expect(errorSpy).toHaveBeenCalledWith( + 'Invalid account', + expect.anything(), // Zod error details + ); + + errorSpy.mockRestore(); +}); diff --git a/src/helper/account-select-helper.ts b/src/helper/account-select-helper.ts new file mode 100644 index 0000000..0a5bf0b --- /dev/null +++ b/src/helper/account-select-helper.ts @@ -0,0 +1,50 @@ +import { injectable } from 'inversify'; +import { type CloudAccount, CloudAccountSchema, type CloudAccountType } from '../api/cloud-account'; +import type { IAMSession } from '../api/iam-session'; + +/** + * Helper class to list all accounts + */ +@injectable() +export class AccountSelectHelper { + static readonly ACCOUNT_URL = 'https://accounts.cloud.ibm.com/v1/accounts'; + + async getAccounts(session: IAMSession): Promise { + // Get the accounts from the given session + + const accountInfoUrl = new URL(AccountSelectHelper.ACCOUNT_URL); + + // Use the fetch API to get the accounts + const response = await fetch(accountInfoUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${session.access_token}`, + }, + }); + + const jsonBody = await response.json(); + console.log('AccountSelectHelper json is', JSON.stringify(jsonBody)); + const cloudData = this.validateJson(jsonBody); + + // Map the accounts to the CloudAccount type + const accounts: CloudAccount[] = cloudData.resources.map(account => { + return { + guid: account.metadata.guid, + name: account.entity.name, + ibmid: account.entity.primary_owner.ibmid, + }; + }); + + return accounts; + } + + protected validateJson(json: unknown): CloudAccountType { + const result = CloudAccountSchema.safeParse(json); + if (!result.success) { + console.error('Invalid account', result.error); + throw new Error('Invalid account'); + } + return result.data; + } +} diff --git a/src/helper/helper-module.ts b/src/helper/helper-module.ts index 9c33a91..646a083 100644 --- a/src/helper/helper-module.ts +++ b/src/helper/helper-module.ts @@ -24,8 +24,10 @@ import { IamSessionRefreshTokenHelper } from './iam-session-refresh-token-helper import { PasscodeEndpointHelper } from './passcode-endpoint-helper'; import { PersistentSessionHelper } from './persistent-session-helper'; import { TokenEndpointHelper } from './token-endpoint-helper'; +import { AccountSelectHelper } from './account-select-helper'; const helpersModule = new ContainerModule(options => { + options.bind(AccountSelectHelper).toSelf().inSingletonScope(); options.bind(IamSessionAccessTokenHelper).toSelf().inSingletonScope(); options.bind(IamSessionConverterHelper).toSelf().inSingletonScope(); options.bind(IamSessionRefreshTokenHelper).toSelf().inSingletonScope(); diff --git a/src/helper/iam-session-refresh-token-helper.spec.ts b/src/helper/iam-session-refresh-token-helper.spec.ts index c6d5b52..3040aad 100644 --- a/src/helper/iam-session-refresh-token-helper.spec.ts +++ b/src/helper/iam-session-refresh-token-helper.spec.ts @@ -21,14 +21,15 @@ import type { IAMSession } from '../api/iam-session'; import { Container, injectFromBase, injectable } from 'inversify'; import { IamSessionRefreshTokenHelper } from './iam-session-refresh-token-helper'; import { TokenEndpointHelper } from './token-endpoint-helper'; +import type { CloudAccount } from '../api/cloud-account'; vi.mock(import('./token-endpoint-helper')); @injectable() @injectFromBase() class TestIamSessionRefreshTokenHelper extends IamSessionRefreshTokenHelper { - createBody(refreshToken: string): string { - return super.createBody(refreshToken); + createBody(refreshToken: string, cloudAccount?: CloudAccount): string { + return super.createBody(refreshToken, cloudAccount); } } @@ -67,6 +68,37 @@ test('should refresh token successfully using the refresh token', async () => { expect(result).toStrictEqual(newSession); }); +test('should refresh token successfully with a custom account', async () => { + expect.assertions(3); + + const mockSession: IAMSession = { + refresh_token: 'refresh-abc', + } as IAMSession; + + const newSession: IAMSession = { + access_token: 'new-token', + refresh_token: 'new-refresh-token', + } as IAMSession; + + const account: CloudAccount = { + guid: 'account-guid', + name: 'account-name', + ibmid: 'account-ibmid', + }; + + const expectedBody = 'grant_type=refresh_token&refresh_token=refresh-abc&account=account-guid'; + + vi.mocked(TokenEndpointHelper.prototype.getToken).mockResolvedValue(newSession); + + const spyCreateBody = vi.spyOn(iamSessionRefreshTokenHelper, 'createBody'); + + const result = await iamSessionRefreshTokenHelper.refreshToken(mockSession, account); + + expect(TokenEndpointHelper.prototype.getToken).toHaveBeenCalledWith(expectedBody); + expect(result).toStrictEqual(newSession); + expect(spyCreateBody).toHaveBeenCalledWith('refresh-abc', account); +}); + test('should throw error if no refresh token is present', async () => { expect.assertions(1); @@ -82,3 +114,17 @@ test('should build correct request body from refresh token', () => { expect(body).toBe('grant_type=refresh_token&refresh_token=refresh-xyz'); }); + +test('should build correct request body from refresh token and cloudAccount', () => { + expect.assertions(1); + + const cloudAccount: CloudAccount = { + guid: 'account-guid', + name: 'account-name', + ibmid: 'account-ibmid', + }; + + const body = iamSessionRefreshTokenHelper.createBody('refresh-xyz', cloudAccount); + + expect(body).toBe('grant_type=refresh_token&refresh_token=refresh-xyz&account=account-guid'); +}); diff --git a/src/helper/iam-session-refresh-token-helper.ts b/src/helper/iam-session-refresh-token-helper.ts index 6804c53..5aa24df 100644 --- a/src/helper/iam-session-refresh-token-helper.ts +++ b/src/helper/iam-session-refresh-token-helper.ts @@ -19,6 +19,7 @@ import { inject } from 'inversify'; import type { IAMSession } from '../api/iam-session'; import { TokenEndpointHelper } from './token-endpoint-helper'; +import type { CloudAccount } from '../api/cloud-account'; /** * Helper class for getting a new IAM sessions using the refresh token. @@ -27,21 +28,25 @@ export class IamSessionRefreshTokenHelper { @inject(TokenEndpointHelper) private readonly tokenEndpointHelper: TokenEndpointHelper; - async refreshToken(session: IAMSession): Promise { + async refreshToken(session: IAMSession, cloudAccount?: CloudAccount): Promise { const refreshToken = session.refresh_token; if (!refreshToken) { throw new Error('No refresh token found'); } - const body = this.createBody(refreshToken); + const body = this.createBody(refreshToken, cloudAccount); // Call the token endpoint with the refresh token return this.tokenEndpointHelper.getToken(body); } - protected createBody(refreshToken: string): string { + protected createBody(refreshToken: string, cloudAccount?: CloudAccount): string { const body = new URLSearchParams(); body.append('grant_type', 'refresh_token'); body.append('refresh_token', refreshToken); + // Add the account guid if any + if (cloudAccount) { + body.append('account', cloudAccount.guid); + } return body.toString(); } } diff --git a/src/manager/authentication-provider-manager.spec.ts b/src/manager/authentication-provider-manager.spec.ts index 4ab91a0..48d3861 100644 --- a/src/manager/authentication-provider-manager.spec.ts +++ b/src/manager/authentication-provider-manager.spec.ts @@ -27,18 +27,22 @@ import { type TelemetryLogger, type TelemetryTrustedValue, authentication, + window, } from '@podman-desktop/api'; import { PasscodeEndpointHelper } from '../helper/passcode-endpoint-helper'; import { IamSessionConverterHelper } from '../helper/iam-session-converter-helper'; import { PersistentSessionHelper } from '../helper/persistent-session-helper'; import { IamSessionRefreshTokenHelper } from '../helper/iam-session-refresh-token-helper'; import type { IAMSession } from '../api/iam-session'; +import { AccountSelectHelper } from '/@/helper/account-select-helper'; +import type { CloudAccount } from '../api/cloud-account'; vi.mock(import('node:path')); -vi.mock(import('../helper/passcode-endpoint-helper')); -vi.mock(import('../helper/iam-session-converter-helper')); -vi.mock(import('../helper/persistent-session-helper')); -vi.mock(import('../helper/iam-session-refresh-token-helper')); +vi.mock(import('/@/helper/account-select-helper')); +vi.mock(import('/@/helper/passcode-endpoint-helper')); +vi.mock(import('/@/helper/iam-session-converter-helper')); +vi.mock(import('/@/helper/persistent-session-helper')); +vi.mock(import('/@/helper/iam-session-refresh-token-helper')); vi.useFakeTimers(); let authenticationProviderManager: TestAuthenticationProviderManager; @@ -98,6 +102,7 @@ beforeEach(async () => { container.bind(TelemetryLoggerSymbol).toConstantValue(telemetryLoggerMock); container.bind(ExtensionContextSymbol).toConstantValue(extensionContextMock); + container.bind(AccountSelectHelper).toSelf().inSingletonScope(); container.bind(PasscodeEndpointHelper).toSelf().inSingletonScope(); container.bind(IamSessionConverterHelper).toSelf().inSingletonScope(); container.bind(PersistentSessionHelper).toSelf().inSingletonScope(); @@ -194,29 +199,104 @@ test('destroy', async () => { expect(PersistentSessionHelper.prototype.save).toHaveBeenCalledWith(expect.any(Array)); }); -test('createSession', async () => { - expect.assertions(5); +describe('createSession', async () => { + it('create session without accounts', async () => { + expect.assertions(5); - const dummyIamSession: IAMSession = { - session_id: '123id', - } as unknown as IAMSession; + const dummyIamSession: IAMSession = { + session_id: '123id', + } as unknown as IAMSession; + + const podmanDesktopSession: AuthenticationSession = {} as unknown as AuthenticationSession; + + const onDidChangeSessions = authenticationProviderManager.getOnDidChangeSessions(); + vi.mocked(PasscodeEndpointHelper.prototype.authenticate).mockResolvedValue(dummyIamSession); + vi.mocked(IamSessionConverterHelper.prototype.convertToAuthenticationSession).mockReturnValue(podmanDesktopSession); + vi.mocked(AccountSelectHelper.prototype.getAccounts).mockResolvedValue([]); + + const result = await authenticationProviderManager.createSession(['test']); + + expect(PasscodeEndpointHelper.prototype.authenticate).toHaveBeenCalledWith(); + expect(IamSessionConverterHelper.prototype.convertToAuthenticationSession).toHaveBeenCalledWith(dummyIamSession); + expect(result).toStrictEqual(podmanDesktopSession); + + expect(authenticationProviderManager.getIamSessions()).toStrictEqual([dummyIamSession]); - const podmanDesktopSession: AuthenticationSession = {} as unknown as AuthenticationSession; + expect(onDidChangeSessions.fire).toHaveBeenCalledWith({ + added: [podmanDesktopSession], + }); + }); - const onDidChangeSessions = authenticationProviderManager.getOnDidChangeSessions(); - vi.mocked(PasscodeEndpointHelper.prototype.authenticate).mockResolvedValue(dummyIamSession); - vi.mocked(IamSessionConverterHelper.prototype.convertToAuthenticationSession).mockReturnValue(podmanDesktopSession); + it('create session with multiple accounts', async () => { + expect.assertions(1); - const result = await authenticationProviderManager.createSession(['test']); + const dummyIamSession: IAMSession = { + session_id: '123id', + } as unknown as IAMSession; - expect(PasscodeEndpointHelper.prototype.authenticate).toHaveBeenCalledWith(); - expect(IamSessionConverterHelper.prototype.convertToAuthenticationSession).toHaveBeenCalledWith(dummyIamSession); - expect(result).toStrictEqual(podmanDesktopSession); + const podmanDesktopSession: AuthenticationSession = {} as unknown as AuthenticationSession; - expect(authenticationProviderManager.getIamSessions()).toStrictEqual([dummyIamSession]); + vi.mocked(PasscodeEndpointHelper.prototype.authenticate).mockResolvedValue(dummyIamSession); + vi.mocked(IamSessionConverterHelper.prototype.convertToAuthenticationSession).mockReturnValue(podmanDesktopSession); + const account1 = { + guid: 'test-guid', + name: 'test-name', + ibmid: 'test-ibmid', + }; + vi.mocked(AccountSelectHelper.prototype.getAccounts).mockResolvedValue([ + account1, + { + guid: 'test-guid-2', + name: 'test-name-2', + ibmid: 'test-ibmid-2', + }, + ]); - expect(onDidChangeSessions.fire).toHaveBeenCalledWith({ - added: [podmanDesktopSession], + // Mock showQuickPick to return the first account + vi.mocked(window.showQuickPick<{ label: string; description: string; data: CloudAccount }>).mockResolvedValue({ + label: 'test-name', + description: 'test-guid', + data: account1, + }); + + await authenticationProviderManager.createSession(['test']); + + expect(IamSessionRefreshTokenHelper.prototype.refreshToken).toHaveBeenCalledWith(dummyIamSession, account1); + }); + + it('create session with multiple accounts and no selecting account', async () => { + expect.assertions(2); + + const dummyIamSession: IAMSession = { + session_id: '123id', + } as unknown as IAMSession; + + const podmanDesktopSession: AuthenticationSession = {} as unknown as AuthenticationSession; + + vi.mocked(PasscodeEndpointHelper.prototype.authenticate).mockResolvedValue(dummyIamSession); + vi.mocked(IamSessionConverterHelper.prototype.convertToAuthenticationSession).mockReturnValue(podmanDesktopSession); + const account1 = { + guid: 'test-guid', + name: 'test-name', + ibmid: 'test-ibmid', + }; + vi.mocked(AccountSelectHelper.prototype.getAccounts).mockResolvedValue([ + account1, + { + guid: 'test-guid-2', + name: 'test-name-2', + ibmid: 'test-ibmid-2', + }, + ]); + + // Mock showQuickPick to return the first account + vi.mocked(window.showQuickPick<{ label: string; description: string; data: CloudAccount }>).mockResolvedValue( + undefined, + ); + + await expect(() => authenticationProviderManager.createSession(['test'])).rejects.toThrow('No account selected'); + + expect(IamSessionRefreshTokenHelper.prototype.refreshToken).not.toHaveBeenCalled(); }); }); diff --git a/src/manager/authentication-provider-manager.ts b/src/manager/authentication-provider-manager.ts index 69ef9a7..6c2523a 100644 --- a/src/manager/authentication-provider-manager.ts +++ b/src/manager/authentication-provider-manager.ts @@ -25,12 +25,14 @@ import { EventEmitter, type ExtensionContext, type AuthenticationProvider, + window, } from '@podman-desktop/api'; import { PasscodeEndpointHelper } from '/@/helper/passcode-endpoint-helper'; import { IamSessionConverterHelper } from '/@/helper/iam-session-converter-helper'; import type { IAMSession } from '/@/api/iam-session'; import { PersistentSessionHelper } from '/@/helper/persistent-session-helper'; import { IamSessionRefreshTokenHelper } from '/@/helper/iam-session-refresh-token-helper'; +import { AccountSelectHelper } from '../helper/account-select-helper'; /** * Manager for the authentication provider. @@ -57,6 +59,9 @@ export class AuthenticationProviderManager { @inject(IamSessionRefreshTokenHelper) private readonly iamSessionRefreshTokenHelper: IamSessionRefreshTokenHelper; + @inject(AccountSelectHelper) + private readonly accountSelectHelper: AccountSelectHelper; + protected _onDidChangeSessions = new EventEmitter(); protected iamSessions: IAMSession[] = []; @@ -133,7 +138,32 @@ export class AuthenticationProviderManager { // Do not use provided scopes for creating a session protected async createSession(_scopes: string[]): Promise { // Open the browser with the passcode URL - const iamSession = await this.passcodeEndpointHelper.authenticate(); + let iamSession = await this.passcodeEndpointHelper.authenticate(); + + // List accounts from this session + const accounts = await this.accountSelectHelper.getAccounts(iamSession); + // Check if there are more than one account + if (accounts.length > 1) { + // Show a list of accounts to the user + const responseAccount = await window.showQuickPick( + accounts.map(account => ({ + label: account.name, + description: account.guid, + data: account, + })), + { + placeHolder: 'Select an account', + canPickMany: false, + }, + ); + if (!responseAccount) { + throw new Error('No account selected'); + } + const selectedAccount = responseAccount.data; + + // Get token for this account + iamSession = await this.iamSessionRefreshTokenHelper.refreshToken(iamSession, selectedAccount); + } const authenticationSession = this.iamSessionConverterHelper.convertToAuthenticationSession(iamSession);