Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 32 additions & 15 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<profile-name>`

**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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you mentioned a migration solution being intentionally skipped in the PR description, but I think it would be very straightforward to enable users on it without re-create. Something like content-cli profile secure <profile> and re-use logic the already implemented logic from this PR.

- 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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/commands/profile/profile-command.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
12 changes: 8 additions & 4 deletions src/core/profile/profile.interface.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down
51 changes: 44 additions & 7 deletions src/core/profile/profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Profile> {
return new Promise<Profile>((resolve, reject) => {
try {
Expand All @@ -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) {
Expand Down Expand Up @@ -73,11 +95,27 @@ export class ProfileService {
}
}

public storeProfile(profile: Profile): void {
public async storeProfile(profile: Profile): Promise<void> {
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",
});
}
Expand Down Expand Up @@ -254,7 +292,7 @@ export class ProfileService {
}
}

this.storeProfile(profile);
await this.storeProfile(profile);
}

private getProfileEnvVariables(): any {
Expand Down Expand Up @@ -322,4 +360,3 @@ export class ProfileService {
}
}

export const profileService = new ProfileService();
101 changes: 101 additions & 0 deletions src/core/profile/secret-storage.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is the best approach. Maybe we should just give a message that storing failed, and then propose to the non-recommended storage, which can be an option in the command. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be a breaking change and can affect automated scripts that use content-cli. The implemented approach graciously fails and falls back to the insecure secret storage, and is a pattern that is commonly used by cli tools - especially those using keytar.

}

return true;
}

public async getSecrets(profileName: string): Promise<ProfileSecrets | undefined> {
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<boolean> {
const keytarModule = getKeytar();
if (!keytarModule) {
return false;
}

try {
await keytarModule.setPassword(service, account, secret);
return true;
} catch (err) {
return false;
}
}
}
Loading
Loading