Skip to content
Merged
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
3 changes: 2 additions & 1 deletion src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import clack from '../utils/clack.js';
import { saveCredentials, getCredentials, getAccessToken, isTokenExpired, updateTokens } from '../lib/credentials.js';
import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js';
import { refreshAccessToken } from '../lib/token-refresh-client.js';
import { logInfo } from '../utils/debug.js';

/**
* Parse JWT payload
Expand Down Expand Up @@ -88,8 +89,8 @@ export async function runLogin(): Promise<void> {
const result = await refreshAccessToken(authkitDomain, clientId);
if (result.accessToken && result.expiresAt) {
updateTokens(result.accessToken, result.expiresAt, result.refreshToken);
logInfo('[login] Session refreshed via refresh token');
clack.log.info(`Already logged in as ${existingCreds.email ?? 'unknown'}`);
clack.log.info('(Session refreshed)');
clack.log.info('Run `workos logout` to log out');
return;
}
Expand Down
148 changes: 146 additions & 2 deletions src/lib/config-store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, readFileSync, unlinkSync, mkdtempSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
import {
existsSync,
readFileSync,
unlinkSync,
mkdtempSync,
rmdirSync,
statSync,
writeFileSync,
mkdirSync,
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

Expand All @@ -13,6 +22,44 @@ let testDir: string;
let workosDir: string;
let configFile: string;

// Mock keyring storage
const mockKeyring = new Map<string, string>();
let keyringAvailable = true;

vi.mock('@napi-rs/keyring', () => ({
Entry: class MockEntry {
private key: string;

constructor(
service: string,
private account: string,
) {
this.key = `${service}:${account}`;
}

getPassword(): string | null {
if (!keyringAvailable) {
throw new Error('Keyring not available');
}
return mockKeyring.get(this.key) ?? null;
}

setPassword(password: string): void {
if (!keyringAvailable) {
throw new Error('Keyring not available');
}
mockKeyring.set(this.key, password);
}

deletePassword(): void {
if (!keyringAvailable && mockKeyring.has(this.key)) {
throw new Error('Keyring not available');
}
mockKeyring.delete(this.key);
}
},
}));

// Mock os.homedir BEFORE importing config-store module
vi.mock('node:os', async (importOriginal) => {
const original = await importOriginal<typeof import('node:os')>();
Expand All @@ -36,7 +83,11 @@ describe('config-store', () => {
testDir = mkdtempSync(join(tmpdir(), 'config-store-test-'));
workosDir = join(testDir, '.workos');
configFile = join(workosDir, 'config.json');
// Force file-based storage for tests

// Reset state
mockKeyring.clear();
keyringAvailable = true;
// Force file-based storage for most tests
setInsecureConfigStorage(true);
});

Expand Down Expand Up @@ -199,4 +250,97 @@ describe('config-store', () => {
expect(env?.endpoint).toBe('http://localhost:8001');
});
});

describe('keyring storage (default)', () => {
beforeEach(() => {
setInsecureConfigStorage(false);
});

it('saves config to keyring', () => {
saveConfig(sampleConfig);
expect(mockKeyring.has('workos-cli:config')).toBe(true);
});

it('retrieves config from keyring', () => {
saveConfig(sampleConfig);
const config = getConfig();
expect(config?.activeEnvironment).toBe('production');
expect(config?.environments.production.apiKey).toBe('sk_test_abc123');
});

it('does not write file when keyring succeeds', () => {
saveConfig(sampleConfig);
expect(mockKeyring.has('workos-cli:config')).toBe(true);
expect(existsSync(configFile)).toBe(false);
});

it('does not delete existing file on keyring success', () => {
// Create a pre-existing file (from a prior fallback write)
mkdirSync(workosDir, { recursive: true });
writeFileSync(configFile, JSON.stringify(sampleConfig));

saveConfig({ ...sampleConfig, activeEnvironment: 'staging' });

expect(mockKeyring.has('workos-cli:config')).toBe(true);
expect(existsSync(configFile)).toBe(true);
});

it('clears from both keyring and file', () => {
saveConfig(sampleConfig);
mkdirSync(workosDir, { recursive: true });
writeFileSync(configFile, JSON.stringify(sampleConfig));

clearConfig();

expect(mockKeyring.has('workos-cli:config')).toBe(false);
expect(existsSync(configFile)).toBe(false);
});
});

describe('file fallback (keyring unavailable)', () => {
beforeEach(() => {
setInsecureConfigStorage(false);
keyringAvailable = false;
});

it('falls back to file when keyring unavailable', () => {
saveConfig(sampleConfig);
expect(existsSync(configFile)).toBe(true);
expect(mockKeyring.has('workos-cli:config')).toBe(false);
});

it('reads from file when keyring unavailable', () => {
saveConfig(sampleConfig);
const config = getConfig();
expect(config?.activeEnvironment).toBe('production');
});
});

describe('migration (file to keyring)', () => {
beforeEach(() => {
setInsecureConfigStorage(false);
});

it('migrates file config to keyring on read but keeps file', () => {
mkdirSync(workosDir, { recursive: true });
writeFileSync(configFile, JSON.stringify(sampleConfig));

const config = getConfig();

expect(config?.activeEnvironment).toBe('production');
expect(mockKeyring.has('workos-cli:config')).toBe(true);
expect(existsSync(configFile)).toBe(true);
});

it('keeps file if keyring unavailable during migration', () => {
mkdirSync(workosDir, { recursive: true });
writeFileSync(configFile, JSON.stringify(sampleConfig));

keyringAvailable = false;
const config = getConfig();

expect(config?.activeEnvironment).toBe('production');
expect(existsSync(configFile)).toBe(true);
});
});
});
13 changes: 8 additions & 5 deletions src/lib/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ const ACCOUNT_NAME = 'config';

let fallbackWarningShown = false;
let forceInsecureStorage = false;
let migrationAttempted = false;

export function setInsecureConfigStorage(value: boolean): void {
forceInsecureStorage = value;
migrationAttempted = false;
}

function getConfigDir(): string {
Expand Down Expand Up @@ -134,8 +136,10 @@ export function getConfig(): CliConfig | null {

const fileConfig = readFromFile();
if (fileConfig) {
// Migrate file config to keyring if possible
if (writeToKeyring(fileConfig)) deleteFile();
if (!migrationAttempted) {
migrationAttempted = true;
writeToKeyring(fileConfig);
}
return fileConfig;
}

Expand All @@ -145,9 +149,7 @@ export function getConfig(): CliConfig | null {
export function saveConfig(config: CliConfig): void {
if (forceInsecureStorage) return writeToFile(config);

if (writeToKeyring(config)) {
deleteFile();
} else {
if (!writeToKeyring(config)) {
showFallbackWarning();
writeToFile(config);
}
Expand All @@ -156,6 +158,7 @@ export function saveConfig(config: CliConfig): void {
export function clearConfig(): void {
deleteFromKeyring();
deleteFile();
migrationAttempted = false;
}

export function getActiveEnvironment(): EnvironmentConfig | null {
Expand Down
21 changes: 16 additions & 5 deletions src/lib/credential-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,22 @@ describe('credential-store', () => {
expect(creds?.userId).toBe(validCreds.userId);
});

it('keeps file as durable fallback alongside keyring', () => {
it('does not write file when keyring succeeds', () => {
saveCredentials(validCreds);

// Both keyring and file should have credentials
// Keyring has credentials, file should NOT be written
expect(mockKeyring.has('workos-cli:credentials')).toBe(true);
expect(existsSync(credentialsFile)).toBe(false);
});

it('does not delete existing file on keyring success', () => {
// Create a pre-existing file (from a prior fallback write)
mkdirSync(installerDir, { recursive: true });
writeFileSync(credentialsFile, JSON.stringify(validCreds));

// Save with keyring available — file should remain untouched
saveCredentials({ ...validCreds, accessToken: 'new_token' });

expect(mockKeyring.has('workos-cli:credentials')).toBe(true);
expect(existsSync(credentialsFile)).toBe(true);
});
Expand Down Expand Up @@ -236,11 +248,10 @@ describe('credential-store', () => {
});

it('hasCredentials only checks file when flag is set', () => {
// Save with default mode (writes to both keyring + file)
// Save with insecure mode (writes to file)
setInsecureStorage(true);
saveCredentials(validCreds);

// Enable insecure storage — should still find the file
setInsecureStorage(true);
expect(hasCredentials()).toBe(true);
});
});
Expand Down
10 changes: 8 additions & 2 deletions src/lib/credential-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ const ACCOUNT_NAME = 'credentials';

let fallbackWarningShown = false;
let forceInsecureStorage = false;
let migrationAttempted = false;

export function setInsecureStorage(value: boolean): void {
forceInsecureStorage = value;
migrationAttempted = false;
}

function getCredentialsDir(): string {
Expand Down Expand Up @@ -145,7 +147,10 @@ export function getCredentials(): Credentials | null {

const fileCreds = readFromFile();
if (fileCreds) {
writeToKeyring(fileCreds);
if (!migrationAttempted) {
migrationAttempted = true;
writeToKeyring(fileCreds);
}
return fileCreds;
}

Expand All @@ -155,15 +160,16 @@ export function getCredentials(): Credentials | null {
export function saveCredentials(creds: Credentials): void {
if (forceInsecureStorage) return writeToFile(creds);

writeToFile(creds);
if (!writeToKeyring(creds)) {
showFallbackWarning();
writeToFile(creds);
}
}

export function clearCredentials(): void {
deleteFromKeyring();
deleteFile();
migrationAttempted = false;
}

export function updateTokens(accessToken: string, expiresAt: number, refreshToken?: string): void {
Expand Down
7 changes: 3 additions & 4 deletions src/lib/ensure-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ describe('ensure-auth', () => {
expect(mockRunLogin).toHaveBeenCalledOnce();
});

it('clears credentials when refresh fails with network error', async () => {
it('preserves credentials when refresh fails with network error', async () => {
saveCredentials(expiredAccessCreds);

mockRefreshAccessToken.mockResolvedValue({
Expand All @@ -313,13 +313,12 @@ describe('ensure-auth', () => {
error: 'Network error',
});

// Don't save new creds in login — verify old ones were cleared
mockRunLogin.mockImplementation(() => {});

await ensureAuthenticated();

// Old stale credentials should be gone
expect(hasCredentials()).toBe(false);
// Credentials should be preserved — transient errors shouldn't nuke the session
expect(hasCredentials()).toBe(true);
});

it('clears credentials when no refresh token available', async () => {
Expand Down
Loading