Skip to content

Commit ac7922d

Browse files
authored
fix: auth credential storage and transient error handling (#81)
* fix: make credential and config stores keychain-primary with file fallback Both stores now follow the same pattern: write to system keychain first, only write to file when keychain is unavailable, never proactively delete existing file backups. config-store: removed deleteFile() calls from saveConfig() and getConfig() migration path that caused environment configs to vanish after addon rebuilds or keychain access failures. credential-store: stopped always writing to file alongside keychain. File is now only written as fallback when keychain write fails. * feat: add cross-process file locking for token refresh Prevents race condition where two concurrent CLI processes both attempt to refresh the same token, causing one to get invalid_grant when the server rotates the refresh token. New file-lock utility uses atomic file creation (O_EXCL) with stale lock detection (30s timeout). New refreshAccessTokenSafe() wraps the existing refresh with lock-acquire-then-recheck pattern: after acquiring the lock, re-reads credentials in case another process already refreshed. Falls back to unlocked refresh if lock acquisition fails (degraded but functional). All callers (ensure-auth, background-refresh, credential-proxy) now use the safe variant. * Revert "feat: add cross-process file locking for token refresh" This reverts commit 98095ab. * fix: don't clear credentials on transient refresh errors Only clear credentials on invalid_grant (refresh token permanently dead). Network and server errors are transient — preserve credentials so the user can retry without being forced to re-login. Previously, any refresh failure wiped the keychain, destroying a potentially valid refresh token over a temporary server hiccup. * fix: eliminate redundant keyring reads and migration retries - ensure-auth: collapse hasCredentials() + getCredentials() two-step into single getCredentials() call, eliminating double keyring read and fixing semantic mismatch (hasCredentials returns true for corrupt files, getCredentials returns null) - credential-store/config-store: add migrationAttempted flag to prevent repeated failed keyring writes on every read when keyring is unavailable (e.g., headless Linux with no keyring daemon) * fix: move session refresh message to debug log The "(Session refreshed)" message is implementation detail noise for users. Moved to debug log, keeping only "Already logged in as ..." in user-facing output.
1 parent 7b857f9 commit ac7922d

7 files changed

Lines changed: 192 additions & 41 deletions

File tree

src/commands/login.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import clack from '../utils/clack.js';
33
import { saveCredentials, getCredentials, getAccessToken, isTokenExpired, updateTokens } from '../lib/credentials.js';
44
import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js';
55
import { refreshAccessToken } from '../lib/token-refresh-client.js';
6+
import { logInfo } from '../utils/debug.js';
67

78
/**
89
* Parse JWT payload
@@ -88,8 +89,8 @@ export async function runLogin(): Promise<void> {
8889
const result = await refreshAccessToken(authkitDomain, clientId);
8990
if (result.accessToken && result.expiresAt) {
9091
updateTokens(result.accessToken, result.expiresAt, result.refreshToken);
92+
logInfo('[login] Session refreshed via refresh token');
9193
clack.log.info(`Already logged in as ${existingCreds.email ?? 'unknown'}`);
92-
clack.log.info('(Session refreshed)');
9394
clack.log.info('Run `workos logout` to log out');
9495
return;
9596
}

src/lib/config-store.spec.ts

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2-
import { existsSync, readFileSync, unlinkSync, mkdtempSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
2+
import {
3+
existsSync,
4+
readFileSync,
5+
unlinkSync,
6+
mkdtempSync,
7+
rmdirSync,
8+
statSync,
9+
writeFileSync,
10+
mkdirSync,
11+
} from 'node:fs';
312
import { join } from 'node:path';
413
import { tmpdir } from 'node:os';
514

@@ -13,6 +22,44 @@ let testDir: string;
1322
let workosDir: string;
1423
let configFile: string;
1524

25+
// Mock keyring storage
26+
const mockKeyring = new Map<string, string>();
27+
let keyringAvailable = true;
28+
29+
vi.mock('@napi-rs/keyring', () => ({
30+
Entry: class MockEntry {
31+
private key: string;
32+
33+
constructor(
34+
service: string,
35+
private account: string,
36+
) {
37+
this.key = `${service}:${account}`;
38+
}
39+
40+
getPassword(): string | null {
41+
if (!keyringAvailable) {
42+
throw new Error('Keyring not available');
43+
}
44+
return mockKeyring.get(this.key) ?? null;
45+
}
46+
47+
setPassword(password: string): void {
48+
if (!keyringAvailable) {
49+
throw new Error('Keyring not available');
50+
}
51+
mockKeyring.set(this.key, password);
52+
}
53+
54+
deletePassword(): void {
55+
if (!keyringAvailable && mockKeyring.has(this.key)) {
56+
throw new Error('Keyring not available');
57+
}
58+
mockKeyring.delete(this.key);
59+
}
60+
},
61+
}));
62+
1663
// Mock os.homedir BEFORE importing config-store module
1764
vi.mock('node:os', async (importOriginal) => {
1865
const original = await importOriginal<typeof import('node:os')>();
@@ -36,7 +83,11 @@ describe('config-store', () => {
3683
testDir = mkdtempSync(join(tmpdir(), 'config-store-test-'));
3784
workosDir = join(testDir, '.workos');
3885
configFile = join(workosDir, 'config.json');
39-
// Force file-based storage for tests
86+
87+
// Reset state
88+
mockKeyring.clear();
89+
keyringAvailable = true;
90+
// Force file-based storage for most tests
4091
setInsecureConfigStorage(true);
4192
});
4293

@@ -199,4 +250,97 @@ describe('config-store', () => {
199250
expect(env?.endpoint).toBe('http://localhost:8001');
200251
});
201252
});
253+
254+
describe('keyring storage (default)', () => {
255+
beforeEach(() => {
256+
setInsecureConfigStorage(false);
257+
});
258+
259+
it('saves config to keyring', () => {
260+
saveConfig(sampleConfig);
261+
expect(mockKeyring.has('workos-cli:config')).toBe(true);
262+
});
263+
264+
it('retrieves config from keyring', () => {
265+
saveConfig(sampleConfig);
266+
const config = getConfig();
267+
expect(config?.activeEnvironment).toBe('production');
268+
expect(config?.environments.production.apiKey).toBe('sk_test_abc123');
269+
});
270+
271+
it('does not write file when keyring succeeds', () => {
272+
saveConfig(sampleConfig);
273+
expect(mockKeyring.has('workos-cli:config')).toBe(true);
274+
expect(existsSync(configFile)).toBe(false);
275+
});
276+
277+
it('does not delete existing file on keyring success', () => {
278+
// Create a pre-existing file (from a prior fallback write)
279+
mkdirSync(workosDir, { recursive: true });
280+
writeFileSync(configFile, JSON.stringify(sampleConfig));
281+
282+
saveConfig({ ...sampleConfig, activeEnvironment: 'staging' });
283+
284+
expect(mockKeyring.has('workos-cli:config')).toBe(true);
285+
expect(existsSync(configFile)).toBe(true);
286+
});
287+
288+
it('clears from both keyring and file', () => {
289+
saveConfig(sampleConfig);
290+
mkdirSync(workosDir, { recursive: true });
291+
writeFileSync(configFile, JSON.stringify(sampleConfig));
292+
293+
clearConfig();
294+
295+
expect(mockKeyring.has('workos-cli:config')).toBe(false);
296+
expect(existsSync(configFile)).toBe(false);
297+
});
298+
});
299+
300+
describe('file fallback (keyring unavailable)', () => {
301+
beforeEach(() => {
302+
setInsecureConfigStorage(false);
303+
keyringAvailable = false;
304+
});
305+
306+
it('falls back to file when keyring unavailable', () => {
307+
saveConfig(sampleConfig);
308+
expect(existsSync(configFile)).toBe(true);
309+
expect(mockKeyring.has('workos-cli:config')).toBe(false);
310+
});
311+
312+
it('reads from file when keyring unavailable', () => {
313+
saveConfig(sampleConfig);
314+
const config = getConfig();
315+
expect(config?.activeEnvironment).toBe('production');
316+
});
317+
});
318+
319+
describe('migration (file to keyring)', () => {
320+
beforeEach(() => {
321+
setInsecureConfigStorage(false);
322+
});
323+
324+
it('migrates file config to keyring on read but keeps file', () => {
325+
mkdirSync(workosDir, { recursive: true });
326+
writeFileSync(configFile, JSON.stringify(sampleConfig));
327+
328+
const config = getConfig();
329+
330+
expect(config?.activeEnvironment).toBe('production');
331+
expect(mockKeyring.has('workos-cli:config')).toBe(true);
332+
expect(existsSync(configFile)).toBe(true);
333+
});
334+
335+
it('keeps file if keyring unavailable during migration', () => {
336+
mkdirSync(workosDir, { recursive: true });
337+
writeFileSync(configFile, JSON.stringify(sampleConfig));
338+
339+
keyringAvailable = false;
340+
const config = getConfig();
341+
342+
expect(config?.activeEnvironment).toBe('production');
343+
expect(existsSync(configFile)).toBe(true);
344+
});
345+
});
202346
});

src/lib/config-store.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ const ACCOUNT_NAME = 'config';
3333

3434
let fallbackWarningShown = false;
3535
let forceInsecureStorage = false;
36+
let migrationAttempted = false;
3637

3738
export function setInsecureConfigStorage(value: boolean): void {
3839
forceInsecureStorage = value;
40+
migrationAttempted = false;
3941
}
4042

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

135137
const fileConfig = readFromFile();
136138
if (fileConfig) {
137-
// Migrate file config to keyring if possible
138-
if (writeToKeyring(fileConfig)) deleteFile();
139+
if (!migrationAttempted) {
140+
migrationAttempted = true;
141+
writeToKeyring(fileConfig);
142+
}
139143
return fileConfig;
140144
}
141145

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

148-
if (writeToKeyring(config)) {
149-
deleteFile();
150-
} else {
152+
if (!writeToKeyring(config)) {
151153
showFallbackWarning();
152154
writeToFile(config);
153155
}
@@ -156,6 +158,7 @@ export function saveConfig(config: CliConfig): void {
156158
export function clearConfig(): void {
157159
deleteFromKeyring();
158160
deleteFile();
161+
migrationAttempted = false;
159162
}
160163

161164
export function getActiveEnvironment(): EnvironmentConfig | null {

src/lib/credential-store.spec.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,22 @@ describe('credential-store', () => {
124124
expect(creds?.userId).toBe(validCreds.userId);
125125
});
126126

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

130-
// Both keyring and file should have credentials
130+
// Keyring has credentials, file should NOT be written
131+
expect(mockKeyring.has('workos-cli:credentials')).toBe(true);
132+
expect(existsSync(credentialsFile)).toBe(false);
133+
});
134+
135+
it('does not delete existing file on keyring success', () => {
136+
// Create a pre-existing file (from a prior fallback write)
137+
mkdirSync(installerDir, { recursive: true });
138+
writeFileSync(credentialsFile, JSON.stringify(validCreds));
139+
140+
// Save with keyring available — file should remain untouched
141+
saveCredentials({ ...validCreds, accessToken: 'new_token' });
142+
131143
expect(mockKeyring.has('workos-cli:credentials')).toBe(true);
132144
expect(existsSync(credentialsFile)).toBe(true);
133145
});
@@ -236,11 +248,10 @@ describe('credential-store', () => {
236248
});
237249

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

242-
// Enable insecure storage — should still find the file
243-
setInsecureStorage(true);
244255
expect(hasCredentials()).toBe(true);
245256
});
246257
});

src/lib/credential-store.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ const ACCOUNT_NAME = 'credentials';
3232

3333
let fallbackWarningShown = false;
3434
let forceInsecureStorage = false;
35+
let migrationAttempted = false;
3536

3637
export function setInsecureStorage(value: boolean): void {
3738
forceInsecureStorage = value;
39+
migrationAttempted = false;
3840
}
3941

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

146148
const fileCreds = readFromFile();
147149
if (fileCreds) {
148-
writeToKeyring(fileCreds);
150+
if (!migrationAttempted) {
151+
migrationAttempted = true;
152+
writeToKeyring(fileCreds);
153+
}
149154
return fileCreds;
150155
}
151156

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

158-
writeToFile(creds);
159163
if (!writeToKeyring(creds)) {
160164
showFallbackWarning();
165+
writeToFile(creds);
161166
}
162167
}
163168

164169
export function clearCredentials(): void {
165170
deleteFromKeyring();
166171
deleteFile();
172+
migrationAttempted = false;
167173
}
168174

169175
export function updateTokens(accessToken: string, expiresAt: number, refreshToken?: string): void {

src/lib/ensure-auth.spec.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ describe('ensure-auth', () => {
304304
expect(mockRunLogin).toHaveBeenCalledOnce();
305305
});
306306

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

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

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

319318
await ensureAuthenticated();
320319

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

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

0 commit comments

Comments
 (0)