diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 85939892..389274d4 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -142,25 +142,42 @@ permissions based on how much power you want to give to the key owner. #### Security Considerations -| ⚠️ **IMPORTANT SECURITY WARNING** | -|-----------------------------------| -| Profile credentials (API tokens, OAuth client secrets, and access tokens) are **stored in plaintext** on your local filesystem. **No encryption is applied** to these credentials. | +| ⚠️ **IMPORTANT SECURITY WARNING** | +|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| The CLI attempts to store profile credentials (API tokens, OAuth client secrets, and access tokens) securely using your system's native keychain/credential store. However, if keychain storage fails, credentials **will fall back to plaintext storage** on your local filesystem. | + +**Secure Storage (Preferred):** +- When creating profiles, the CLI automatically attempts to store secrets in your system's secure credential store: + - **macOS**: Keychain Access + - **Windows**: Windows Credential Manager + - **Linux**: libsecret (requires a secret service like GNOME Keyring or KWallet) +- If successful, secrets are **removed from the profile file** and stored securely in the system keychain + +**Fallback to Plaintext Storage:** +- If keychain storage fails (e.g., keychain unavailable, permission denied, or unsupported system), secrets **will be stored in plaintext** in the profile file +- A warning message will be displayed: `⚠️ Failed to store secrets securely. They will be stored in plain text file.` +- For profiles with plaintext secrets, you may see a warning when accessing them: `⚠️ Profile secrets are stored as plain-text insecurely. Consider re-creating the profile to save the secrets securely.` **Storage Location:** -- **Linux/macOS: - - ** `~/.celonis-content-cli-profiles` - - ** `~/.celonis-content-cli-git-profiles` -- **Windows: - - ** `%USERPROFILE%\.celonis-content-cli-profiles` - - ** `%USERPROFILE%\.celonis-content-cli-git-profiles` +- **Profile files** (may contain non-sensitive data if secrets are stored securely): + - **Linux/macOS**: `~/.celonis-content-cli-profiles` + - **Windows**: `%USERPROFILE%\.celonis-content-cli-profiles` +- **Secure secrets** (when successfully stored): + - Stored in your system's native credential manager/keychain + - Service name: `celonis-content-cli:` **Protection Mechanisms:** -The security of your credentials relies **entirely on native operating system filesystem permissions**. The CLI does not provide additional encryption. - -Ensure that: -- Your user account and filesystem are properly secured -- File permissions restrict access to your user account only -- You use appropriate security measures on shared or multi-user systems +- **For securely stored profiles**: Secrets are protected by your system's keychain security (typically requires user authentication or system-level access) +- **For plaintext profiles**: Security relies **entirely on native operating system filesystem permissions** + +**Best Practices:** +- Ensure your system keychain is properly configured and accessible +- If you see warnings about plaintext storage, consider re-creating the profile to enable secure storage +- Ensure that: + - Your user account and filesystem are properly secured + - File permissions restrict access to your user account only + - You use appropriate security measures on shared or multi-user systems + - Your system keychain is locked when not in use #### When to create profiles diff --git a/package.json b/package.json index 0d55d9ac..92690a5b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "form-data": "4.0.4", "openid-client": "5.6.1", "hpagent": "1.2.0", + "keytar": "7.9.0", "semver": "7.6.3", "simple-git": "3.28.0", "valid-url": "1.0.9", diff --git a/src/commands/profile/profile-command.service.ts b/src/commands/profile/profile-command.service.ts index 253bb70a..121a436f 100644 --- a/src/commands/profile/profile-command.service.ts +++ b/src/commands/profile/profile-command.service.ts @@ -34,7 +34,7 @@ export class ProfileCommandService { profile.authenticationType = await ProfileValidator.validateProfile(profile); await this.profileService.authorizeProfile(profile); - this.profileService.storeProfile(profile); + await this.profileService.storeProfile(profile); if (setAsDefault) { await this.makeDefaultProfile(profile.name); } diff --git a/src/core/profile/profile.interface.ts b/src/core/profile/profile.interface.ts index ae21f547..53db04f6 100644 --- a/src/core/profile/profile.interface.ts +++ b/src/core/profile/profile.interface.ts @@ -1,14 +1,18 @@ -export interface Profile { +export interface ProfileSecrets { + apiToken: string; + clientSecret?: string; + refreshToken?: string; + secretsStoredSecurely?: boolean; +} + +export interface Profile extends ProfileSecrets { name: string; team: string; type: ProfileType; - apiToken: string; authenticationType: AuthenticationType; clientId?: string; - clientSecret?: string; scopes?: string[]; clientAuthenticationMethod?: ClientAuthenticationMethod; - refreshToken?: string; expiresAt?: number; } diff --git a/src/core/profile/profile.service.ts b/src/core/profile/profile.service.ts index dbd3ebc3..299e7a52 100644 --- a/src/core/profile/profile.service.ts +++ b/src/core/profile/profile.service.ts @@ -9,6 +9,7 @@ import { FatalError, logger } from "../utils/logger"; import { Issuer } from "openid-client"; import axios from "axios"; import os = require("os"); +import { SecureSecretStorageService } from "./secret-storage.service"; const homedir = os.homedir(); // use 5 seconds buffer to avoid rare cases when accessToken is just about to expire before the command is sent @@ -24,6 +25,8 @@ export class ProfileService { private profileContainerPath = path.resolve(homedir, ".celonis-content-cli-profiles"); private configContainer = path.resolve(this.profileContainerPath, "config.json"); + private secureSecretStorageService = new SecureSecretStorageService(); + public async findProfile(profileName: string): Promise { return new Promise((resolve, reject) => { try { @@ -33,8 +36,27 @@ export class ProfileService { { encoding: "utf-8" } ); const profile : Profile = JSON.parse(file); - this.refreshProfile(profile) - .then(() => resolve(profile)); + + if (profile.secretsStoredSecurely) { + this.secureSecretStorageService.getSecrets(profileName) + .then(profileSecureSecrets => { + if (profileSecureSecrets) { + profile.apiToken = profileSecureSecrets.apiToken; + profile.refreshToken = profileSecureSecrets.refreshToken; + profile.clientSecret = profileSecureSecrets.clientSecret; + this.refreshProfile(profile) + .then(() => resolve(profile)) + .catch(() => reject(`The profile ${profileName} couldn't be resolved.`)); + } else { + reject("Failed to read secrets from system keychain."); + } + }) + .catch(() => reject(`The profile ${profileName} couldn't be resolved.`)); + } else { + this.refreshProfile(profile) + .then(() => resolve(profile)) + .catch(() => reject(`The profile ${profileName} couldn't be resolved.`)); + } } else if (process.env.TEAM_URL && process.env.API_TOKEN) { resolve(this.buildProfileFromEnvVariables()); } else if (process.env.CELONIS_URL && process.env.CELONIS_API_TOKEN) { @@ -73,11 +95,27 @@ export class ProfileService { } } - public storeProfile(profile: Profile): void { + public async storeProfile(profile: Profile): Promise { this.createProfileContainerIfNotExists(); const newProfileFileName = this.constructProfileFileName(profile.name); - profile.team = this.getBaseTeamUrl(profile.team); - fs.writeFileSync(path.resolve(this.profileContainerPath, newProfileFileName), JSON.stringify(profile), { + + // Create a copy of the profile to avoid mutating the input + const profileToStore: Profile = { ...profile }; + profileToStore.team = this.getBaseTeamUrl(profile.team); + + const secretsStoredInKeychain = await this.secureSecretStorageService.storeSecrets(profileToStore); + + // Remove secrets from plain text storage if they were successfully stored in system keychain + if (secretsStoredInKeychain) { + profileToStore.secretsStoredSecurely = true; + delete profileToStore.apiToken; + delete profileToStore.refreshToken; + delete profileToStore.clientSecret; + } else { + profileToStore.secretsStoredSecurely = false; + } + + fs.writeFileSync(path.resolve(this.profileContainerPath, newProfileFileName), JSON.stringify(profileToStore), { encoding: "utf-8", }); } @@ -254,7 +292,7 @@ export class ProfileService { } } - this.storeProfile(profile); + await this.storeProfile(profile); } private getProfileEnvVariables(): any { @@ -322,4 +360,3 @@ export class ProfileService { } } -export const profileService = new ProfileService(); diff --git a/src/core/profile/secret-storage.service.ts b/src/core/profile/secret-storage.service.ts new file mode 100644 index 00000000..cb68588a --- /dev/null +++ b/src/core/profile/secret-storage.service.ts @@ -0,0 +1,101 @@ +import { Profile, ProfileSecrets } from "./profile.interface"; +import { logger } from "../utils/logger"; + +enum PROFILE_SECRET_TYPE { + API_TOKEN = "apiToken", + CLIENT_SECRET = "clientSecret", + REFRESH_TOKEN = "refreshToken", +} + +const CONTENT_CLI_SERVICE_NAME = "celonis-content-cli"; + +// Lazy load keytar to handle cases where native dependencies are not available +let keytar: any = null; +let keytarLoadError: Error | null = null; + +function getKeytar(): any { + if (keytar !== null) { + return keytar; + } + if (keytarLoadError !== null) { + return null; + } + try { + keytar = require("keytar"); + return keytar; + } catch (error) { + keytarLoadError = error as Error; + return null; + } +} + +export class SecureSecretStorageService { + + public async storeSecrets(profile: Profile): Promise { + let secretsStoredInKeychain = true; + const secretEntries = [ + { type: PROFILE_SECRET_TYPE.API_TOKEN, value: profile.apiToken }, + { type: PROFILE_SECRET_TYPE.CLIENT_SECRET, value: profile.clientSecret }, + { type: PROFILE_SECRET_TYPE.REFRESH_TOKEN, value: profile.refreshToken }, + ]; + + for (const secretEntry of secretEntries) { + if (secretEntry.value) { + const stored = await this.storeSecret( + this.getSecretServiceName(profile.name), + secretEntry.type, + secretEntry.value + ); + if (!stored) { + secretsStoredInKeychain = false; + } + } + } + + if (!secretsStoredInKeychain) { + logger.warn("⚠️ Failed to store secrets securely. They will be stored in plain text file."); + return false; + } + + return true; + } + + public async getSecrets(profileName: string): Promise { + const keytarModule = getKeytar(); + if (!keytarModule) { + return undefined; + } + + const secrets = await keytarModule.findCredentials(this.getSecretServiceName(profileName)); + + if (!secrets.length) { + return undefined; + } + + const profileSecrets = {}; + + for (const secret of secrets) { + profileSecrets[secret.account] = secret.password + } + + return profileSecrets as ProfileSecrets; + } + + private getSecretServiceName(profileName: string): string { + return `${CONTENT_CLI_SERVICE_NAME}:${profileName}`; + } + + private async storeSecret(service: string, account: string, secret: string): Promise { + const keytarModule = getKeytar(); + if (!keytarModule) { + return false; + } + + try { + await keytarModule.setPassword(service, account, secret); + return true; + } catch (err) { + return false; + } + } +} diff --git a/tests/core/profile/profile.service.spec.ts b/tests/core/profile/profile.service.spec.ts index e4b24488..d28617a3 100644 --- a/tests/core/profile/profile.service.spec.ts +++ b/tests/core/profile/profile.service.spec.ts @@ -4,8 +4,20 @@ import * as os from "os"; import { ProfileValidator } from "../../../src/core/profile/profile.validator"; import { Profile, ProfileType, AuthenticationType } from "../../../src/core/profile/profile.interface"; +const mockHomedir = "/mock/home"; + jest.mock("os", () => ({ - homedir: jest.fn(() => "/mock/home") + homedir: jest.fn(() => mockHomedir) +})); + +const mockStoreSecrets = jest.fn(); +const mockGetSecrets = jest.fn(); + +jest.mock("../../../src/core/profile/secret-storage.service", () => ({ + SecureSecretStorageService: jest.fn().mockImplementation(() => ({ + storeSecrets: mockStoreSecrets, + getSecrets: mockGetSecrets + })) })); import { ProfileService } from "../../../src/core/profile/profile.service"; @@ -19,6 +31,10 @@ describe("ProfileService - mapCelonisEnvProfile", () => { beforeEach(() => { profileService = new ProfileService(); + + mockStoreSecrets.mockClear(); + mockGetSecrets.mockClear(); + originalCelonisUrl = process.env.CELONIS_URL; originalCelonisApiToken = process.env.CELONIS_API_TOKEN; originalTeamUrl = process.env.TEAM_URL; @@ -212,12 +228,15 @@ describe("ProfileService - findProfile", () => { let originalCelonisApiToken: string | undefined; let originalTeamUrl: string | undefined; let originalApiToken: string | undefined; - const mockHomedir = "/mock/home"; const mockProfilePath = path.resolve(mockHomedir, ".celonis-content-cli-profiles"); beforeEach(() => { (os.homedir as jest.Mock).mockReturnValue(mockHomedir); profileService = new ProfileService(); + + mockStoreSecrets.mockClear(); + mockGetSecrets.mockClear(); + originalCelonisUrl = process.env.CELONIS_URL; originalCelonisApiToken = process.env.CELONIS_API_TOKEN; originalTeamUrl = process.env.TEAM_URL; @@ -464,3 +483,182 @@ describe("ProfileService - findProfile", () => { }); }); +describe("Profile Service - Store Profile", () => { + let profileService: ProfileService; + const mockProfilePath = path.resolve(mockHomedir, ".celonis-content-cli-profiles"); + + beforeEach(() => { + profileService = new ProfileService(); + + mockStoreSecrets.mockClear(); + mockGetSecrets.mockClear(); + + (fs.existsSync as jest.Mock).mockReturnValue(false); + (fs.mkdirSync as jest.Mock).mockImplementation(() => void 0); + (fs.writeFileSync as jest.Mock).mockImplementation(() => void 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("Should store secrets in plain text if keychain storage fails", async () => { + const profile: Profile = { + name: "plain-text-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-token", + clientSecret: "client-secret", + refreshToken: "refresh-token", + authenticationType: AuthenticationType.BEARER, + secretsStoredSecurely: false + }; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + mockStoreSecrets.mockResolvedValue(false); + + await profileService.storeProfile(profile); + + const expectedProfile = { + ...profile, + team: "https://test-team.celonis.cloud" + }; + + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.resolve(mockProfilePath, "plain-text-profile.json"), + JSON.stringify(expectedProfile), + { encoding: "utf-8" } + ); + }); + + it("Should remove all secrets from profile when stored securely", async () => { + const profile: Profile = { + name: "secure-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.CLIENT_CREDENTIALS, + clientId: "test-client-id", + clientSecret: "test-client-secret", + apiToken: "test-token", + refreshToken: "test-refresh-token", + authenticationType: AuthenticationType.BEARER + }; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + mockStoreSecrets.mockResolvedValue(true); + + await profileService.storeProfile(profile); + + const writeCall = (fs.writeFileSync as jest.Mock).mock.calls[0]; + const storedProfile = JSON.parse(writeCall[1]); + + expect(storedProfile.apiToken).toBeUndefined(); + expect(storedProfile.clientSecret).toBeUndefined(); + expect(storedProfile.refreshToken).toBeUndefined(); + expect(storedProfile.secretsStoredSecurely).toBe(true); + }); +}); + +describe("Profile Service - Find Profile", () => { + let profileService: ProfileService; + const mockProfilePath = path.resolve(mockHomedir, ".celonis-content-cli-profiles"); + + beforeEach(() => { + profileService = new ProfileService(); + + mockStoreSecrets.mockClear(); + mockGetSecrets.mockClear(); + + // Clear environment variables to avoid env-based profile resolution + delete process.env.TEAM_URL; + delete process.env.API_TOKEN; + delete process.env.CELONIS_URL; + delete process.env.CELONIS_API_TOKEN; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockImplementation(() => "{}"); + + // Mock refreshProfile to avoid OAuth calls + jest.spyOn(ProfileService.prototype, "refreshProfile").mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + delete process.env.TEAM_URL; + delete process.env.API_TOKEN; + delete process.env.CELONIS_URL; + delete process.env.CELONIS_API_TOKEN; + }); + + it("Should find profile with secrets stored securely", async () => { + const profileName = "secure-profile"; + const storedProfile = { + name: profileName, + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + authenticationType: AuthenticationType.BEARER, + secretsStoredSecurely: true + }; + + const secureSecrets = { + apiToken: "secure-api-token", + refreshToken: "secure-refresh-token", + clientSecret: "secure-client-secret" + }; + + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(storedProfile)); + mockGetSecrets.mockResolvedValue(secureSecrets); + + const profile = await profileService.findProfile(profileName); + + expect(mockGetSecrets).toHaveBeenCalledWith(profileName); + expect(profile.name).toBe(profileName); + expect(profile.team).toBe("https://test-team.celonis.cloud"); + expect(profile.apiToken).toBe(secureSecrets.apiToken); + expect(profile.refreshToken).toBe(secureSecrets.refreshToken); + expect(profile.clientSecret).toBe(secureSecrets.clientSecret); + expect(profile.secretsStoredSecurely).toBe(true); + }); + + it("Should find profile with secrets not stored securely", async () => { + const profileName = "plain-text-profile"; + const storedProfile = { + name: profileName, + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + authenticationType: AuthenticationType.BEARER, + apiToken: "plain-api-token", + refreshToken: "plain-refresh-token", + clientSecret: "plain-client-secret" + }; + + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(storedProfile)); + + const profile = await profileService.findProfile(profileName); + + expect(mockGetSecrets).not.toHaveBeenCalled(); + expect(profile.name).toBe(profileName); + expect(profile.team).toBe("https://test-team.celonis.cloud"); + expect(profile.apiToken).toBe(storedProfile.apiToken); + expect(profile.refreshToken).toBe(storedProfile.refreshToken); + expect(profile.clientSecret).toBe(storedProfile.clientSecret); + expect(profile.secretsStoredSecurely).toBeUndefined(); + }); + + it("Should throw error when secrets are stored securely but could not be retrieved", async () => { + const profileName = "secure-profile-fail"; + const storedProfile = { + name: profileName, + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + authenticationType: AuthenticationType.BEARER, + secretsStoredSecurely: true + }; + + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(storedProfile)); + mockGetSecrets.mockResolvedValue(undefined); + + await expect(profileService.findProfile(profileName)).rejects.toBe("Failed to read secrets from system keychain."); + expect(mockGetSecrets).toHaveBeenCalledWith(profileName); + }); +}); + diff --git a/tests/core/profile/secret-storage.service.keytar-unavailable.spec.ts b/tests/core/profile/secret-storage.service.keytar-unavailable.spec.ts new file mode 100644 index 00000000..1e2b0b32 --- /dev/null +++ b/tests/core/profile/secret-storage.service.keytar-unavailable.spec.ts @@ -0,0 +1,47 @@ +import { Profile, ProfileType, AuthenticationType } from "../../../src/core/profile/profile.interface"; + +jest.doMock("keytar", () => { + throw new Error("Mock failure in loading lib"); +}, { virtual: true }); + +import { SecureSecretStorageService } from "../../../src/core/profile/secret-storage.service"; + +describe("SecureSecretStorageService - Keytar Unavailable", () => { + let service: SecureSecretStorageService; + + beforeEach(() => { + service = new SecureSecretStorageService(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("storeSecrets", () => { + it("Should return false when keytar module cannot be loaded", async () => { + const profile: Profile = { + name: "no-keytar-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-token", + clientSecret: "test-client-secret", + refreshToken: "test-refresh-token", + authenticationType: AuthenticationType.BEARER + }; + + const result = await service.storeSecrets(profile); + + expect(result).toBe(false); + }); + }); + + describe("getSecrets", () => { + it("Should return undefined when keytar module cannot be loaded", async () => { + const profileName = "no-keytar-profile"; + const result = await service.getSecrets(profileName); + + expect(result).toBeUndefined(); + }); + }); +}); + diff --git a/tests/core/profile/secret-storage.service.spec.ts b/tests/core/profile/secret-storage.service.spec.ts new file mode 100644 index 00000000..2d57d47f --- /dev/null +++ b/tests/core/profile/secret-storage.service.spec.ts @@ -0,0 +1,190 @@ +import { Profile, ProfileType, AuthenticationType } from "../../../src/core/profile/profile.interface"; +import { + SecureSecretStorageService, +} from "../../../src/core/profile/secret-storage.service"; + +const mockKeytar = { + setPassword: jest.fn(), + findCredentials: jest.fn() +}; + +jest.mock("keytar", () => { + return mockKeytar; +}, { virtual: true }); + +describe("SecureSecretStorageService", () => { + let service: any; + + beforeEach(() => { + jest.resetModules(); + + jest.clearAllMocks(); + + mockKeytar.setPassword.mockClear(); + mockKeytar.findCredentials.mockClear(); + + service = new SecureSecretStorageService(); + + const keytarModule = require("keytar"); + expect(keytarModule).toBe(mockKeytar); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("storeSecrets", () => { + it("Should store all secrets successfully when keytar is available", async () => { + const profile: Profile = { + name: "test-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-api-token", + clientSecret: "test-client-secret", + refreshToken: "test-refresh-token", + authenticationType: AuthenticationType.BEARER + }; + + // keytar.setPassword returns Promise (resolves to undefined on success) + mockKeytar.setPassword.mockResolvedValue(undefined); + + const result = await service.storeSecrets(profile); + + expect(result).toBe(true); + expect(mockKeytar.setPassword).toHaveBeenCalledTimes(3); + expect(mockKeytar.setPassword).toHaveBeenCalledWith( + "celonis-content-cli:test-profile", + "apiToken", + "test-api-token" + ); + expect(mockKeytar.setPassword).toHaveBeenCalledWith( + "celonis-content-cli:test-profile", + "clientSecret", + "test-client-secret" + ); + expect(mockKeytar.setPassword).toHaveBeenCalledWith( + "celonis-content-cli:test-profile", + "refreshToken", + "test-refresh-token" + ); + }); + + it("Should return false when keytar setPassword throws for any secret", async () => { + const profile: Profile = { + name: "throwing-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-api-token", + authenticationType: AuthenticationType.BEARER + }; + + mockKeytar.setPassword.mockRejectedValue(new Error("Keychain unavailable")); + + const result = await service.storeSecrets(profile); + + expect(result).toBe(false); + }); + + it("Should skip undefined or null secret values", async () => { + const profile: Profile = { + name: "partial-secrets-profile", + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-api-token", + clientSecret: undefined, + refreshToken: null as any, + authenticationType: AuthenticationType.BEARER + }; + + mockKeytar.setPassword.mockResolvedValue(undefined); + + const result = await service.storeSecrets(profile); + + expect(result).toBe(true); + expect(mockKeytar.setPassword).toHaveBeenCalledTimes(1); + expect(mockKeytar.setPassword).toHaveBeenCalledWith( + "celonis-content-cli:partial-secrets-profile", + "apiToken", + "test-api-token" + ); + }); + }); + + describe("getSecrets", () => { + it("Should retrieve secrets successfully when keytar is available", async () => { + const profileName = "test-profile"; + const mockSecrets = [ + { account: "apiToken", password: "retrieved-api-token" }, + { account: "clientSecret", password: "retrieved-client-secret" }, + { account: "refreshToken", password: "retrieved-refresh-token" } + ]; + + mockKeytar.findCredentials.mockResolvedValue(mockSecrets); + + const result = await service.getSecrets(profileName); + + expect(result).toEqual({ + apiToken: "retrieved-api-token", + clientSecret: "retrieved-client-secret", + refreshToken: "retrieved-refresh-token" + }); + expect(mockKeytar.findCredentials).toHaveBeenCalledWith("celonis-content-cli:test-profile"); + }); + + it("Should return undefined when no secrets are found", async () => { + const profileName = "non-existent-profile"; + + mockKeytar.findCredentials.mockResolvedValue([]); + + const result = await service.getSecrets(profileName); + + expect(result).toBeUndefined(); + expect(mockKeytar.findCredentials).toHaveBeenCalledWith("celonis-content-cli:non-existent-profile"); + }); + + it("Should map account names to profile secrets correctly", async () => { + const profileName = "mapping-test-profile"; + const mockSecrets = [ + { account: "apiToken", password: "token-123" }, + { account: "clientSecret", password: "secret-456" }, + { account: "refreshToken", password: "refresh-789" } + ]; + + mockKeytar.findCredentials.mockResolvedValue(mockSecrets); + + const result = await service.getSecrets(profileName); + + expect(result).toEqual({ + apiToken: "token-123", + clientSecret: "secret-456", + refreshToken: "refresh-789" + }); + }); + }); + + describe("service name construction", () => { + it("Should construct correct service name", async () => { + const expectedService = { profileName: "profile1", serviceName: "celonis-content-cli:profile1" }; + + const profile: Profile = { + name: expectedService.profileName, + team: "https://test-team.celonis.cloud", + type: ProfileType.KEY, + apiToken: "test-token", + authenticationType: AuthenticationType.BEARER + }; + + mockKeytar.setPassword.mockResolvedValue(undefined); + jest.clearAllMocks(); + + await service.storeSecrets(profile); + + expect(mockKeytar.setPassword).toHaveBeenCalledWith( + expectedService.serviceName, + "apiToken", + profile.apiToken + ); + }); + }); +}); + diff --git a/yarn.lock b/yarn.lock index 812a440d..a0910d9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2382,7 +2382,7 @@ bignumber.js@^9.0.0: resolved "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz" integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== -bl@^4.1.0: +bl@^4.0.3, bl@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz" integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== @@ -2538,6 +2538,11 @@ chardet@^0.7.0: resolved "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + ci-info@^3.2.0: version "3.9.0" resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz" @@ -2770,6 +2775,13 @@ debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.4, debug@^4.4.0, d dependencies: ms "^2.1.3" +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + dedent@^1.0.0: version "1.5.3" resolved "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz" @@ -2811,6 +2823,11 @@ delayed-stream@~1.0.0: resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +detect-libc@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz" @@ -3054,6 +3071,11 @@ exit@^0.1.2: resolved "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz" integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + expect@^29.0.0, expect@^29.7.0: version "29.7.0" resolved "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz" @@ -3201,6 +3223,11 @@ form-data@^2.5.0: mime-types "^2.1.12" safe-buffer "^5.2.1" +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" @@ -3310,6 +3337,11 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== + glob@10.0.0, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: version "10.0.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.0.0.tgz#034ab2e93644ba702e769c3e0558143d3fbd1612" @@ -4135,6 +4167,14 @@ jws@^4.0.0: jwa "^2.0.0" safe-buffer "^5.0.1" +keytar@7.9.0: + version "7.9.0" + resolved "https://registry.yarnpkg.com/keytar/-/keytar-7.9.0.tgz#4c6225708f51b50cbf77c5aae81721964c2918cb" + integrity sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ== + dependencies: + node-addon-api "^4.3.0" + prebuild-install "^7.0.1" + kleur@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz" @@ -4341,6 +4381,11 @@ mimic-function@^5.0.0: resolved "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz" integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + minimatch@^3.0.4, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" @@ -4362,7 +4407,7 @@ minimatch@^9.0.0: dependencies: brace-expansion "^2.0.1" -minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: +minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6: version "1.2.8" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -4377,6 +4422,11 @@ minipass@^5.0.0: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + mkdirp@^0.5.1: version "0.5.6" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz" @@ -4404,6 +4454,11 @@ nan@^2.19.0, nan@^2.20.0: resolved "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz" integrity sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw== +napi-build-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e" + integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" @@ -4414,6 +4469,18 @@ netmask@^2.0.2: resolved "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz" integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== +node-abi@^3.3.0: + version "3.85.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.85.0.tgz#b115d575e52b2495ef08372b058e13d202875a7d" + integrity sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg== + dependencies: + semver "^7.3.5" + +node-addon-api@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" + integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== + node-fetch@^2.6.9, node-fetch@^2.7.0: version "2.7.0" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" @@ -4660,6 +4727,24 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +prebuild-install@^7.0.1: + version "7.1.3" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.3.tgz#d630abad2b147443f20a212917beae68b8092eec" + integrity sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^2.0.0" + node-abi "^3.3.0" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prettier@3.4.2: version "3.4.2" resolved "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz" @@ -4753,7 +4838,7 @@ pure-rand@^6.0.0: resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz" integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== -rc@^1.2.8: +rc@^1.2.7, rc@^1.2.8: version "1.2.8" resolved "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -4932,6 +5017,11 @@ semver@^6.3.0, semver@^6.3.1: resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +semver@^7.3.5: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz" @@ -4959,6 +5049,20 @@ signal-exit@^4.1.0: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-git@3.16.0: version "3.16.0" resolved "https://registry.npmjs.org/simple-git/-/simple-git-3.16.0.tgz" @@ -5247,6 +5351,27 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +tar-fs@^2.0.0: + version "2.1.4" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" + integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + teeny-request@^9.0.0: version "9.0.0" resolved "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz" @@ -5410,6 +5535,13 @@ tsutils@^3.5.0: dependencies: tslib "^1.8.1" +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== + dependencies: + safe-buffer "^5.0.1" + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz"