From 9d61ffbada87abf9f66395bf5481128eed0c4b10 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 4 Feb 2026 10:25:47 -0500 Subject: [PATCH 1/6] feat: add keyring credential storage with --insecure-storage fallback - Add credential-store.ts with keyring support via @napi-rs/keyring - Refactor credentials.ts to delegate storage to credential-store - Add --insecure-storage flag to login, logout, install commands - Auto-migrate file credentials to keyring on first read - Fall back to file storage with warning when keyring unavailable - Add comprehensive tests for keyring and file storage paths --- package.json | 1 + pnpm-lock.yaml | 135 +++++++++++++++ src/bin.ts | 63 +++++-- src/lib/credential-store.spec.ts | 277 +++++++++++++++++++++++++++++++ src/lib/credential-store.ts | 242 +++++++++++++++++++++++++++ src/lib/credentials.spec.ts | 3 + src/lib/credentials.ts | 107 +++--------- src/lib/ensure-auth.spec.ts | 4 +- src/lib/token-refresh.spec.ts | 4 +- 9 files changed, 736 insertions(+), 100 deletions(-) create mode 100644 src/lib/credential-store.spec.ts create mode 100644 src/lib/credential-store.ts diff --git a/package.json b/package.json index 3f3716a3..02a6e422 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@anthropic-ai/sdk": "^0.71.2", "@clack/core": "^0.5.0", "@clack/prompts": "0.11.0", + "@napi-rs/keyring": "^1.2.0", "chalk": "^5.6.2", "diff": "^8.0.3", "fast-glob": "^3.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d4b02c7..a9e187b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@clack/prompts': specifier: 0.11.0 version: 0.11.0 + '@napi-rs/keyring': + specifier: ^1.2.0 + version: 1.2.0 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -610,6 +613,87 @@ packages: resolution: {integrity: sha512-RuzCup9Ct91Y7V79xwCb146RaBRHZ7NBbrIUySumd1rpKqHL5OonaqrGIbug5hNwP/fRyxFMA6ISgw4FTtYFYg==} engines: {node: '>=18'} + '@napi-rs/keyring-darwin-arm64@1.2.0': + resolution: {integrity: sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.2.0': + resolution: {integrity: sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.2.0': + resolution: {integrity: sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.2.0': + resolution: {integrity: sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.2.0': + resolution: {integrity: sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.2.0': + resolution: {integrity: sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': + resolution: {integrity: sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.2.0': + resolution: {integrity: sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.2.0': + resolution: {integrity: sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.2.0': + resolution: {integrity: sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.2.0': + resolution: {integrity: sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.2.0': + resolution: {integrity: sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.2.0': + resolution: {integrity: sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==} + engines: {node: '>= 10'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2376,6 +2460,57 @@ snapshots: strict-event-emitter: 0.5.1 optional: true + '@napi-rs/keyring-darwin-arm64@1.2.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.2.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.2.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.2.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.2.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.2.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.2.0': + optional: true + + '@napi-rs/keyring@1.2.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.2.0 + '@napi-rs/keyring-darwin-x64': 1.2.0 + '@napi-rs/keyring-freebsd-x64': 1.2.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.2.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.2.0 + '@napi-rs/keyring-linux-arm64-musl': 1.2.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.2.0 + '@napi-rs/keyring-linux-x64-gnu': 1.2.0 + '@napi-rs/keyring-linux-x64-musl': 1.2.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.2.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.2.0 + '@napi-rs/keyring-win32-x64-msvc': 1.2.0 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/src/bin.ts b/src/bin.ts index 573f2dfe..db64b057 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -39,7 +39,15 @@ import clack from './utils/clack.js'; */ function withAuth(handler: (argv: T) => Promise): (argv: T) => Promise { return async (argv: T) => { - if (!(argv as { skipAuth?: boolean }).skipAuth) { + const typedArgv = argv as { skipAuth?: boolean; insecureStorage?: boolean }; + + // Set storage mode before any auth operations + if (typedArgv.insecureStorage) { + const { setInsecureStorage } = await import('./lib/credentials.js'); + setInsecureStorage(true); + } + + if (!typedArgv.skipAuth) { await ensureAuthenticated(); } await handler(argv); @@ -58,6 +66,11 @@ const installerOptions = { describe: 'Enable verbose logging', type: 'boolean' as const, }, + 'insecure-storage': { + default: false, + describe: 'Store credentials in plaintext file instead of system keyring', + type: 'boolean' as const, + }, // Hidden dev/automation flags (use env vars) local: { default: false, @@ -128,15 +141,45 @@ await checkForUpdates(); yargs(hideBin(process.argv)) .env('WORKOS_INSTALLER') - .command('login', 'Authenticate with WorkOS', {}, async () => { - const { runLogin } = await import('./commands/login.js'); - await runLogin(); - process.exit(0); - }) - .command('logout', 'Remove stored credentials', {}, async () => { - const { runLogout } = await import('./commands/logout.js'); - await runLogout(); - }) + .command( + 'login', + 'Authenticate with WorkOS', + { + 'insecure-storage': { + default: false, + describe: 'Store credentials in plaintext file instead of system keyring', + type: 'boolean' as const, + }, + }, + async (argv) => { + if (argv.insecureStorage) { + const { setInsecureStorage } = await import('./lib/credentials.js'); + setInsecureStorage(true); + } + const { runLogin } = await import('./commands/login.js'); + await runLogin(); + process.exit(0); + }, + ) + .command( + 'logout', + 'Remove stored credentials', + { + 'insecure-storage': { + default: false, + describe: 'Store credentials in plaintext file instead of system keyring', + type: 'boolean' as const, + }, + }, + async (argv) => { + if (argv.insecureStorage) { + const { setInsecureStorage } = await import('./lib/credentials.js'); + setInsecureStorage(true); + } + const { runLogout } = await import('./commands/logout.js'); + await runLogout(); + }, + ) .command( 'install-skill', 'Install bundled AuthKit skills to coding agents', diff --git a/src/lib/credential-store.spec.ts b/src/lib/credential-store.spec.ts new file mode 100644 index 00000000..af09fb71 --- /dev/null +++ b/src/lib/credential-store.spec.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; +import { existsSync, readFileSync, unlinkSync, mkdtempSync, rmdirSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Create a mock home directory for all tests +let testDir: string; +let installerDir: string; +let credentialsFile: string; + +// Mock keyring storage +const mockKeyring = new Map(); + +// Track whether keyring is "available" for this test +let keyringAvailable = true; + +// Mock @napi-rs/keyring BEFORE importing credential-store +vi.mock('@napi-rs/keyring', () => ({ + Entry: class MockEntry { + private key: string; + + constructor(private service: string, private account: string) { + this.key = `${service}:${account}`; + } + + getPassword(): string | null { + if (!keyringAvailable && this.account !== '__probe__') { + 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 credential-store module +vi.mock('node:os', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + default: { + ...original, + homedir: () => testDir, + }, + homedir: () => testDir, + }; +}); + +// Now import credential-store module (after mocks are set up) +const { + hasCredentials, + getCredentials, + saveCredentials, + clearCredentials, + setInsecureStorage, + updateTokens, + getCredentialsPath, +} = await import('./credential-store.js'); +import type { Credentials } from './credential-store.js'; + +describe('credential-store', () => { + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'cred-store-test-')); + installerDir = join(testDir, '.workos'); + credentialsFile = join(installerDir, 'credentials.json'); + + // Reset state + mockKeyring.clear(); + keyringAvailable = true; + setInsecureStorage(false); + }); + + afterEach(() => { + // Clean up + if (existsSync(credentialsFile)) { + unlinkSync(credentialsFile); + } + if (existsSync(installerDir)) { + rmdirSync(installerDir); + } + if (existsSync(testDir)) { + rmdirSync(testDir); + } + }); + + const validCreds: Credentials = { + accessToken: 'token123', + expiresAt: Date.now() + 3600000, + userId: 'user_abc', + email: 'test@example.com', + }; + + describe('keyring storage (default)', () => { + it('saves credentials to keyring', () => { + saveCredentials(validCreds); + expect(mockKeyring.has('workos-cli:credentials')).toBe(true); + }); + + it('retrieves credentials from keyring', () => { + saveCredentials(validCreds); + const creds = getCredentials(); + expect(creds?.accessToken).toBe(validCreds.accessToken); + expect(creds?.userId).toBe(validCreds.userId); + }); + + it('deletes file after saving to keyring (migration cleanup)', () => { + // First create file storage manually + mkdirSync(installerDir, { recursive: true }); + writeFileSync(credentialsFile, JSON.stringify(validCreds)); + + // Now save to keyring (should delete the file) + saveCredentials(validCreds); + + expect(existsSync(credentialsFile)).toBe(false); + expect(mockKeyring.has('workos-cli:credentials')).toBe(true); + }); + + it('clears from both keyring and file', () => { + // Save to keyring + saveCredentials(validCreds); + + // Also create a file manually + mkdirSync(installerDir, { recursive: true }); + writeFileSync(credentialsFile, JSON.stringify(validCreds)); + + clearCredentials(); + + expect(mockKeyring.has('workos-cli:credentials')).toBe(false); + expect(existsSync(credentialsFile)).toBe(false); + }); + + it('hasCredentials returns true when in keyring', () => { + saveCredentials(validCreds); + expect(hasCredentials()).toBe(true); + }); + }); + + describe('file fallback (keyring unavailable)', () => { + beforeEach(() => { + keyringAvailable = false; + }); + + it('falls back to file storage when keyring unavailable', () => { + // Suppress console.warn for this test + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + saveCredentials(validCreds); + + expect(existsSync(credentialsFile)).toBe(true); + expect(mockKeyring.has('workos-cli:credentials')).toBe(false); + + warnSpy.mockRestore(); + }); + + it('reads from file when keyring unavailable', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + saveCredentials(validCreds); + const creds = getCredentials(); + + expect(creds?.accessToken).toBe(validCreds.accessToken); + + warnSpy.mockRestore(); + }); + }); + + describe('migration (file to keyring)', () => { + it('migrates file credentials to keyring on read', () => { + // Create file credentials directly + mkdirSync(installerDir, { recursive: true }); + writeFileSync(credentialsFile, JSON.stringify(validCreds)); + + // Read should migrate to keyring + const creds = getCredentials(); + + expect(creds?.accessToken).toBe(validCreds.accessToken); + expect(mockKeyring.has('workos-cli:credentials')).toBe(true); + expect(existsSync(credentialsFile)).toBe(false); + }); + + it('keeps file if keyring unavailable during migration', () => { + // Create file credentials + mkdirSync(installerDir, { recursive: true }); + writeFileSync(credentialsFile, JSON.stringify(validCreds)); + + // Make keyring unavailable + keyringAvailable = false; + + // Read should return file creds without migrating + const creds = getCredentials(); + + expect(creds?.accessToken).toBe(validCreds.accessToken); + expect(existsSync(credentialsFile)).toBe(true); + }); + }); + + describe('--insecure-storage flag', () => { + it('uses file storage when flag is set', () => { + setInsecureStorage(true); + saveCredentials(validCreds); + + expect(existsSync(credentialsFile)).toBe(true); + expect(mockKeyring.has('workos-cli:credentials')).toBe(false); + }); + + it('reads only from file when flag is set', () => { + // Save to keyring first (before flag) + saveCredentials(validCreds); + expect(mockKeyring.has('workos-cli:credentials')).toBe(true); + + // Now enable insecure storage and save different creds + setInsecureStorage(true); + const fileCreds: Credentials = { ...validCreds, userId: 'file_user' }; + saveCredentials(fileCreds); + + // Should read from file, not keyring + const creds = getCredentials(); + expect(creds?.userId).toBe('file_user'); + }); + + it('hasCredentials only checks file when flag is set', () => { + // Save to keyring + saveCredentials(validCreds); + + // Enable insecure storage + setInsecureStorage(true); + + // hasCredentials should return false (no file) + expect(hasCredentials()).toBe(false); + + // Now save to file + saveCredentials(validCreds); + expect(hasCredentials()).toBe(true); + }); + }); + + describe('updateTokens', () => { + it('updates tokens in keyring storage', () => { + saveCredentials(validCreds); + + updateTokens('new_access_token', Date.now() + 7200000, 'new_refresh'); + + const creds = getCredentials(); + expect(creds?.accessToken).toBe('new_access_token'); + expect(creds?.refreshToken).toBe('new_refresh'); + // Original fields preserved + expect(creds?.userId).toBe(validCreds.userId); + }); + + it('throws when no credentials exist', () => { + expect(() => updateTokens('token', Date.now())).toThrow( + 'No existing credentials to update', + ); + }); + }); + + describe('getCredentialsPath', () => { + it('returns path to credentials file', () => { + const path = getCredentialsPath(); + expect(path).toContain('.workos'); + expect(path).toContain('credentials.json'); + }); + }); +}); diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts new file mode 100644 index 00000000..d20d88c6 --- /dev/null +++ b/src/lib/credential-store.ts @@ -0,0 +1,242 @@ +/** + * Credential storage abstraction with keyring support and file fallback. + * + * Storage priority: + * 1. If --insecure-storage: use file only + * 2. Try keyring, fall back to file with warning if unavailable + */ + +import { Entry } from '@napi-rs/keyring'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +// Inline the Credentials type to avoid circular dependency with credentials.ts +// The canonical type definition lives in credentials.ts +export interface StagingCache { + clientId: string; + apiKey: string; + fetchedAt: number; +} + +export interface Credentials { + accessToken: string; + expiresAt: number; + userId: string; + email?: string; + staging?: StagingCache; + refreshToken?: string; +} + +const SERVICE_NAME = 'workos-cli'; +const ACCOUNT_NAME = 'credentials'; + +// Track if we've already shown the fallback warning this session +let fallbackWarningShown = false; + +// Global flag set by CLI argument parsing +let forceInsecureStorage = false; + +export function setInsecureStorage(value: boolean): void { + forceInsecureStorage = value; +} + +// ============================================================================ +// File Storage (fallback) +// ============================================================================ + +function getCredentialsDir(): string { + return path.join(os.homedir(), '.workos'); +} + +function getCredentialsPath(): string { + return path.join(getCredentialsDir(), 'credentials.json'); +} + +function fileExists(): boolean { + return fs.existsSync(getCredentialsPath()); +} + +function readFromFile(): Credentials | null { + if (!fileExists()) return null; + try { + const content = fs.readFileSync(getCredentialsPath(), 'utf-8'); + return JSON.parse(content); + } catch { + return null; + } +} + +function writeToFile(creds: Credentials): void { + const dir = getCredentialsDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(getCredentialsPath(), JSON.stringify(creds, null, 2), { + mode: 0o600, + }); +} + +function deleteFile(): void { + if (fileExists()) { + fs.unlinkSync(getCredentialsPath()); + } +} + +// ============================================================================ +// Keyring Storage +// ============================================================================ + +function getKeyringEntry(): Entry { + return new Entry(SERVICE_NAME, ACCOUNT_NAME); +} + +function readFromKeyring(): Credentials | null { + try { + const entry = getKeyringEntry(); + const data = entry.getPassword(); + if (!data) return null; + return JSON.parse(data); + } catch { + return null; + } +} + +function writeToKeyring(creds: Credentials): boolean { + try { + const entry = getKeyringEntry(); + entry.setPassword(JSON.stringify(creds)); + return true; + } catch { + return false; + } +} + +function deleteFromKeyring(): void { + try { + const entry = getKeyringEntry(); + entry.deletePassword(); + } catch { + // Ignore errors - might not exist + } +} + +function isKeyringAvailable(): boolean { + try { + const entry = new Entry(SERVICE_NAME, '__probe__'); + entry.setPassword('test'); + entry.deletePassword(); + return true; + } catch { + return false; + } +} + +// ============================================================================ +// Unified API +// ============================================================================ + +function showFallbackWarning(): void { + if (fallbackWarningShown || forceInsecureStorage) return; + fallbackWarningShown = true; + console.warn( + '\n⚠ Unable to store credentials in system keyring. Using file storage.\n' + + ' Credentials saved to ~/.workos/credentials.json\n' + + ' Use --insecure-storage to suppress this warning.\n' + ); +} + +/** + * Check if credentials exist (in keyring or file). + */ +export function hasCredentials(): boolean { + if (forceInsecureStorage) { + return fileExists(); + } + return readFromKeyring() !== null || fileExists(); +} + +/** + * Get credentials from keyring or file. + * Handles migration from file to keyring. + */ +export function getCredentials(): Credentials | null { + if (forceInsecureStorage) { + return readFromFile(); + } + + // Try keyring first + const keyringCreds = readFromKeyring(); + if (keyringCreds) { + return keyringCreds; + } + + // Check for file credentials (migration case) + const fileCreds = readFromFile(); + if (fileCreds) { + // Attempt to migrate to keyring + if (writeToKeyring(fileCreds)) { + deleteFile(); // Clean up after successful migration + return fileCreds; + } + // Keyring unavailable, keep using file + return fileCreds; + } + + return null; +} + +/** + * Save credentials to keyring (with file fallback). + */ +export function saveCredentials(creds: Credentials): void { + if (forceInsecureStorage) { + writeToFile(creds); + return; + } + + // Try keyring first + if (writeToKeyring(creds)) { + // Also delete any old file to avoid confusion + deleteFile(); + return; + } + + // Fallback to file + showFallbackWarning(); + writeToFile(creds); +} + +/** + * Clear credentials from both keyring and file. + */ +export function clearCredentials(): void { + deleteFromKeyring(); + deleteFile(); +} + +/** + * Update tokens atomically. + */ +export function updateTokens( + accessToken: string, + expiresAt: number, + refreshToken?: string +): void { + const creds = getCredentials(); + if (!creds) { + throw new Error('No existing credentials to update'); + } + + const updated: Credentials = { + ...creds, + accessToken, + expiresAt, + ...(refreshToken && { refreshToken }), + }; + + saveCredentials(updated); +} + +// Re-export utility functions that don't depend on storage backend +export { getCredentialsPath }; diff --git a/src/lib/credentials.spec.ts b/src/lib/credentials.spec.ts index fc698722..787dca19 100644 --- a/src/lib/credentials.spec.ts +++ b/src/lib/credentials.spec.ts @@ -32,6 +32,7 @@ const { getCredentialsPath, saveStagingCredentials, getStagingCredentials, + setInsecureStorage, } = await import('./credentials.js'); import type { Credentials } from './credentials.js'; @@ -40,6 +41,8 @@ describe('credentials', () => { testDir = mkdtempSync(join(tmpdir(), 'credentials-test-')); installerDir = join(testDir, '.workos'); credentialsFile = join(installerDir, 'credentials.json'); + // Force file-based storage for these tests (tests file storage behavior) + setInsecureStorage(true); }); afterEach(() => { diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index bbc93762..0c1f545b 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -1,59 +1,25 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; - -export interface StagingCache { - clientId: string; - apiKey: string; - fetchedAt: number; -} - -export interface Credentials { - accessToken: string; - expiresAt: number; - userId: string; - email?: string; - staging?: StagingCache; - refreshToken?: string; -} - -function getCredentialsDir(): string { - return path.join(os.homedir(), '.workos'); -} +/** + * Credential types and utilities. + * Storage implementation delegated to credential-store.ts + */ -export function getCredentialsPath(): string { - return path.join(getCredentialsDir(), 'credentials.json'); -} +// Re-export types from credential-store (canonical definitions) +export type { StagingCache, Credentials } from './credential-store.js'; -export function hasCredentials(): boolean { - return fs.existsSync(getCredentialsPath()); -} +// Re-export storage functions from credential-store +export { + hasCredentials, + getCredentials, + saveCredentials, + clearCredentials, + updateTokens, + getCredentialsPath, + setInsecureStorage, +} from './credential-store.js'; -export function getCredentials(): Credentials | null { - if (!hasCredentials()) return null; - try { - const content = fs.readFileSync(getCredentialsPath(), 'utf-8'); - return JSON.parse(content); - } catch { - return null; - } -} - -export function saveCredentials(creds: Credentials): void { - const dir = getCredentialsDir(); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync(getCredentialsPath(), JSON.stringify(creds, null, 2), { - mode: 0o600, - }); -} - -export function clearCredentials(): void { - if (hasCredentials()) { - fs.unlinkSync(getCredentialsPath()); - } -} +// Import for use in local functions +import type { Credentials } from './credential-store.js'; +import { getCredentials, saveCredentials } from './credential-store.js'; /** * Check if token is actually expired (hard expiry check). @@ -100,38 +66,3 @@ export function getStagingCredentials(): { clientId: string; apiKey: string } | if (isTokenExpired(creds)) return null; return { clientId: creds.staging.clientId, apiKey: creds.staging.apiKey }; } - -/** - * Atomically update tokens in credentials file. - * Uses write-to-temp + rename pattern for atomic updates. - */ -export function updateTokens(accessToken: string, expiresAt: number, refreshToken?: string): void { - const creds = getCredentials(); - if (!creds) { - throw new Error('No existing credentials to update'); - } - - const updated: Credentials = { - ...creds, - accessToken, - expiresAt, - ...(refreshToken && { refreshToken }), - }; - - // Atomic write: temp file + rename - const credPath = getCredentialsPath(); - const tempPath = `${credPath}.${crypto.randomUUID()}.tmp`; - - try { - fs.writeFileSync(tempPath, JSON.stringify(updated, null, 2), { mode: 0o600 }); - fs.renameSync(tempPath, credPath); - } catch (error) { - // Clean up temp file if rename failed - try { - fs.unlinkSync(tempPath); - } catch { - // Ignore cleanup errors - } - throw error; - } -} diff --git a/src/lib/ensure-auth.spec.ts b/src/lib/ensure-auth.spec.ts index dc296510..c195461d 100644 --- a/src/lib/ensure-auth.spec.ts +++ b/src/lib/ensure-auth.spec.ts @@ -47,7 +47,7 @@ vi.mock('./token-refresh-client.js', () => ({ })); // Import after mocks are set up -const { saveCredentials, clearCredentials, getCredentials } = await import('./credentials.js'); +const { saveCredentials, clearCredentials, getCredentials, setInsecureStorage } = await import('./credentials.js'); const { ensureAuthenticated } = await import('./ensure-auth.js'); describe('ensure-auth', () => { @@ -56,6 +56,8 @@ describe('ensure-auth', () => { installerDir = join(testDir, '.workos'); credentialsFile = join(installerDir, 'credentials.json'); vi.clearAllMocks(); + // Force file-based storage for these tests + setInsecureStorage(true); }); afterEach(() => { diff --git a/src/lib/token-refresh.spec.ts b/src/lib/token-refresh.spec.ts index 63c31b7c..c85f95dc 100644 --- a/src/lib/token-refresh.spec.ts +++ b/src/lib/token-refresh.spec.ts @@ -29,7 +29,7 @@ vi.mock('../utils/debug.js', () => ({ })); // Import after mocks are set up -const { saveCredentials, clearCredentials } = await import('./credentials.js'); +const { saveCredentials, clearCredentials, setInsecureStorage } = await import('./credentials.js'); const { ensureValidToken } = await import('./token-refresh.js'); describe('token-refresh', () => { @@ -38,6 +38,8 @@ describe('token-refresh', () => { installerDir = join(testDir, '.workos'); credentialsFile = join(installerDir, 'credentials.json'); vi.clearAllMocks(); + // Force file-based storage for these tests + setInsecureStorage(true); }); afterEach(() => { From 1512cb5224d4aa3afb4f8f3adb48089a56c1c4fe Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 4 Feb 2026 10:29:41 -0500 Subject: [PATCH 2/6] chore: format credential-store files --- src/lib/credential-store.spec.ts | 9 +++++---- src/lib/credential-store.ts | 8 ++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/lib/credential-store.spec.ts b/src/lib/credential-store.spec.ts index af09fb71..869e645f 100644 --- a/src/lib/credential-store.spec.ts +++ b/src/lib/credential-store.spec.ts @@ -19,7 +19,10 @@ vi.mock('@napi-rs/keyring', () => ({ Entry: class MockEntry { private key: string; - constructor(private service: string, private account: string) { + constructor( + private service: string, + private account: string, + ) { this.key = `${service}:${account}`; } @@ -261,9 +264,7 @@ describe('credential-store', () => { }); it('throws when no credentials exist', () => { - expect(() => updateTokens('token', Date.now())).toThrow( - 'No existing credentials to update', - ); + expect(() => updateTokens('token', Date.now())).toThrow('No existing credentials to update'); }); }); diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index d20d88c6..0fc1867e 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -142,7 +142,7 @@ function showFallbackWarning(): void { console.warn( '\n⚠ Unable to store credentials in system keyring. Using file storage.\n' + ' Credentials saved to ~/.workos/credentials.json\n' + - ' Use --insecure-storage to suppress this warning.\n' + ' Use --insecure-storage to suppress this warning.\n', ); } @@ -218,11 +218,7 @@ export function clearCredentials(): void { /** * Update tokens atomically. */ -export function updateTokens( - accessToken: string, - expiresAt: number, - refreshToken?: string -): void { +export function updateTokens(accessToken: string, expiresAt: number, refreshToken?: string): void { const creds = getCredentials(); if (!creds) { throw new Error('No existing credentials to update'); From 4c493284ec4a9ef712bb226a50b8f02a17d24ba4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 4 Feb 2026 10:33:53 -0500 Subject: [PATCH 3/6] chore: remove unnecessary comments and section dividers --- src/bin.ts | 1 - src/lib/credential-store.ts | 45 ++----------------------------------- src/lib/credentials.ts | 23 ------------------- 3 files changed, 2 insertions(+), 67 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index db64b057..06be565e 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -41,7 +41,6 @@ function withAuth(handler: (argv: T) => Promise): (argv: T) => Promise< return async (argv: T) => { const typedArgv = argv as { skipAuth?: boolean; insecureStorage?: boolean }; - // Set storage mode before any auth operations if (typedArgv.insecureStorage) { const { setInsecureStorage } = await import('./lib/credentials.js'); setInsecureStorage(true); diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index 0fc1867e..0e5b8be1 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -11,8 +11,6 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -// Inline the Credentials type to avoid circular dependency with credentials.ts -// The canonical type definition lives in credentials.ts export interface StagingCache { clientId: string; apiKey: string; @@ -31,20 +29,13 @@ export interface Credentials { const SERVICE_NAME = 'workos-cli'; const ACCOUNT_NAME = 'credentials'; -// Track if we've already shown the fallback warning this session let fallbackWarningShown = false; - -// Global flag set by CLI argument parsing let forceInsecureStorage = false; export function setInsecureStorage(value: boolean): void { forceInsecureStorage = value; } -// ============================================================================ -// File Storage (fallback) -// ============================================================================ - function getCredentialsDir(): string { return path.join(os.homedir(), '.workos'); } @@ -83,10 +74,6 @@ function deleteFile(): void { } } -// ============================================================================ -// Keyring Storage -// ============================================================================ - function getKeyringEntry(): Entry { return new Entry(SERVICE_NAME, ACCOUNT_NAME); } @@ -117,7 +104,7 @@ function deleteFromKeyring(): void { const entry = getKeyringEntry(); entry.deletePassword(); } catch { - // Ignore errors - might not exist + // Might not exist } } @@ -132,10 +119,6 @@ function isKeyringAvailable(): boolean { } } -// ============================================================================ -// Unified API -// ============================================================================ - function showFallbackWarning(): void { if (fallbackWarningShown || forceInsecureStorage) return; fallbackWarningShown = true; @@ -146,9 +129,6 @@ function showFallbackWarning(): void { ); } -/** - * Check if credentials exist (in keyring or file). - */ export function hasCredentials(): boolean { if (forceInsecureStorage) { return fileExists(); @@ -156,68 +136,48 @@ export function hasCredentials(): boolean { return readFromKeyring() !== null || fileExists(); } -/** - * Get credentials from keyring or file. - * Handles migration from file to keyring. - */ export function getCredentials(): Credentials | null { if (forceInsecureStorage) { return readFromFile(); } - // Try keyring first const keyringCreds = readFromKeyring(); if (keyringCreds) { return keyringCreds; } - // Check for file credentials (migration case) const fileCreds = readFromFile(); if (fileCreds) { - // Attempt to migrate to keyring if (writeToKeyring(fileCreds)) { - deleteFile(); // Clean up after successful migration + deleteFile(); return fileCreds; } - // Keyring unavailable, keep using file return fileCreds; } return null; } -/** - * Save credentials to keyring (with file fallback). - */ export function saveCredentials(creds: Credentials): void { if (forceInsecureStorage) { writeToFile(creds); return; } - // Try keyring first if (writeToKeyring(creds)) { - // Also delete any old file to avoid confusion deleteFile(); return; } - // Fallback to file showFallbackWarning(); writeToFile(creds); } -/** - * Clear credentials from both keyring and file. - */ export function clearCredentials(): void { deleteFromKeyring(); deleteFile(); } -/** - * Update tokens atomically. - */ export function updateTokens(accessToken: string, expiresAt: number, refreshToken?: string): void { const creds = getCredentials(); if (!creds) { @@ -234,5 +194,4 @@ export function updateTokens(accessToken: string, expiresAt: number, refreshToke saveCredentials(updated); } -// Re-export utility functions that don't depend on storage backend export { getCredentialsPath }; diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index 0c1f545b..358eecc8 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -1,12 +1,5 @@ -/** - * Credential types and utilities. - * Storage implementation delegated to credential-store.ts - */ - -// Re-export types from credential-store (canonical definitions) export type { StagingCache, Credentials } from './credential-store.js'; -// Re-export storage functions from credential-store export { hasCredentials, getCredentials, @@ -17,20 +10,13 @@ export { setInsecureStorage, } from './credential-store.js'; -// Import for use in local functions import type { Credentials } from './credential-store.js'; import { getCredentials, saveCredentials } from './credential-store.js'; -/** - * Check if token is actually expired (hard expiry check). - */ export function isTokenExpired(creds: Credentials): boolean { return Date.now() >= creds.expiresAt; } -/** - * Get access token if available and not expired. - */ export function getAccessToken(): string | null { const creds = getCredentials(); if (!creds) return null; @@ -38,10 +24,6 @@ export function getAccessToken(): string | null { return creds.accessToken; } -/** - * Save staging credentials to the credential cache. - * Staging credentials are tied to the access token lifecycle. - */ export function saveStagingCredentials(staging: { clientId: string; apiKey: string }): void { const creds = getCredentials(); if (!creds) return; @@ -55,14 +37,9 @@ export function saveStagingCredentials(staging: { clientId: string; apiKey: stri }); } -/** - * Get cached staging credentials if available and access token is still valid. - * Returns null if no cached credentials or if access token has expired. - */ export function getStagingCredentials(): { clientId: string; apiKey: string } | null { const creds = getCredentials(); if (!creds?.staging) return null; - // Invalidate staging credentials when access token expires if (isTokenExpired(creds)) return null; return { clientId: creds.staging.clientId, apiKey: creds.staging.apiKey }; } From b72d62c38504a08c62d20c7c3d55ec8f5b21b305 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 4 Feb 2026 10:45:03 -0500 Subject: [PATCH 4/6] refactor: extract shared insecure-storage helpers and remove dead code --- src/bin.ts | 85 ++++++++++++++----------------------- src/lib/credential-store.ts | 37 ++++------------ 2 files changed, 39 insertions(+), 83 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 06be565e..dcf9f7d8 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -31,7 +31,23 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) { import { isNonInteractiveEnvironment } from './utils/environment.js'; import clack from './utils/clack.js'; -// Shared options for wizard commands (default and dashboard) +/** Apply insecure storage flag if set */ +async function applyInsecureStorage(insecureStorage?: boolean): Promise { + if (insecureStorage) { + const { setInsecureStorage } = await import('./lib/credentials.js'); + setInsecureStorage(true); + } +} + +/** Shared insecure-storage option for commands that access credentials */ +const insecureStorageOption = { + 'insecure-storage': { + default: false, + describe: 'Store credentials in plaintext file instead of system keyring', + type: 'boolean' as const, + }, +} as const; + /** * Wrap a command handler with authentication check. * Ensures valid auth before executing the handler. @@ -40,15 +56,8 @@ import clack from './utils/clack.js'; function withAuth(handler: (argv: T) => Promise): (argv: T) => Promise { return async (argv: T) => { const typedArgv = argv as { skipAuth?: boolean; insecureStorage?: boolean }; - - if (typedArgv.insecureStorage) { - const { setInsecureStorage } = await import('./lib/credentials.js'); - setInsecureStorage(true); - } - - if (!typedArgv.skipAuth) { - await ensureAuthenticated(); - } + await applyInsecureStorage(typedArgv.insecureStorage); + if (!typedArgv.skipAuth) await ensureAuthenticated(); await handler(argv); }; } @@ -65,11 +74,7 @@ const installerOptions = { describe: 'Enable verbose logging', type: 'boolean' as const, }, - 'insecure-storage': { - default: false, - describe: 'Store credentials in plaintext file instead of system keyring', - type: 'boolean' as const, - }, + ...insecureStorageOption, // Hidden dev/automation flags (use env vars) local: { default: false, @@ -140,45 +145,17 @@ await checkForUpdates(); yargs(hideBin(process.argv)) .env('WORKOS_INSTALLER') - .command( - 'login', - 'Authenticate with WorkOS', - { - 'insecure-storage': { - default: false, - describe: 'Store credentials in plaintext file instead of system keyring', - type: 'boolean' as const, - }, - }, - async (argv) => { - if (argv.insecureStorage) { - const { setInsecureStorage } = await import('./lib/credentials.js'); - setInsecureStorage(true); - } - const { runLogin } = await import('./commands/login.js'); - await runLogin(); - process.exit(0); - }, - ) - .command( - 'logout', - 'Remove stored credentials', - { - 'insecure-storage': { - default: false, - describe: 'Store credentials in plaintext file instead of system keyring', - type: 'boolean' as const, - }, - }, - async (argv) => { - if (argv.insecureStorage) { - const { setInsecureStorage } = await import('./lib/credentials.js'); - setInsecureStorage(true); - } - const { runLogout } = await import('./commands/logout.js'); - await runLogout(); - }, - ) + .command('login', 'Authenticate with WorkOS', insecureStorageOption, async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runLogin } = await import('./commands/login.js'); + await runLogin(); + process.exit(0); + }) + .command('logout', 'Remove stored credentials', insecureStorageOption, async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runLogout } = await import('./commands/logout.js'); + await runLogout(); + }) .command( 'install-skill', 'Install bundled AuthKit skills to coding agents', diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index 0e5b8be1..ada4e5a1 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -108,17 +108,6 @@ function deleteFromKeyring(): void { } } -function isKeyringAvailable(): boolean { - try { - const entry = new Entry(SERVICE_NAME, '__probe__'); - entry.setPassword('test'); - entry.deletePassword(); - return true; - } catch { - return false; - } -} - function showFallbackWarning(): void { if (fallbackWarningShown || forceInsecureStorage) return; fallbackWarningShown = true; @@ -137,21 +126,15 @@ export function hasCredentials(): boolean { } export function getCredentials(): Credentials | null { - if (forceInsecureStorage) { - return readFromFile(); - } + if (forceInsecureStorage) return readFromFile(); const keyringCreds = readFromKeyring(); - if (keyringCreds) { - return keyringCreds; - } + if (keyringCreds) return keyringCreds; const fileCreds = readFromFile(); if (fileCreds) { - if (writeToKeyring(fileCreds)) { - deleteFile(); - return fileCreds; - } + // Migrate file creds to keyring if possible + if (writeToKeyring(fileCreds)) deleteFile(); return fileCreds; } @@ -159,18 +142,14 @@ export function getCredentials(): Credentials | null { } export function saveCredentials(creds: Credentials): void { - if (forceInsecureStorage) { - writeToFile(creds); - return; - } + if (forceInsecureStorage) return writeToFile(creds); if (writeToKeyring(creds)) { deleteFile(); - return; + } else { + showFallbackWarning(); + writeToFile(creds); } - - showFallbackWarning(); - writeToFile(creds); } export function clearCredentials(): void { From 25b3dee0937d8afbe5ba0b247421deca6924e857 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 4 Feb 2026 11:02:23 -0500 Subject: [PATCH 5/6] fix: add error logging to credential storage and fix default command - Add logWarn calls to catch blocks for debugging keyring/file errors - Add --insecure-storage support to default ($0) command - Use logWarn instead of console.warn for fallback warning - Update test mocks to include logWarn --- src/bin.ts | 5 +++-- src/lib/credential-store.spec.ts | 5 +++++ src/lib/credential-store.ts | 25 ++++++++++++++++--------- src/lib/credentials.spec.ts | 5 +++++ src/lib/ensure-auth.spec.ts | 1 + src/lib/token-refresh.spec.ts | 1 + 6 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index dcf9f7d8..4d9f3ef4 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -209,8 +209,8 @@ yargs(hideBin(process.argv)) .command( ['$0'], 'WorkOS AuthKit CLI', - (yargs) => yargs, - async () => { + (yargs) => yargs.options(insecureStorageOption), + async (argv) => { // Non-TTY: show help if (isNonInteractiveEnvironment()) { yargs(hideBin(process.argv)).showHelp(); @@ -227,6 +227,7 @@ yargs(hideBin(process.argv)) } // Auth check happens HERE, after user confirms + await applyInsecureStorage(argv.insecureStorage); await ensureAuthenticated(); const { handleInstall } = await import('./commands/install.js'); diff --git a/src/lib/credential-store.spec.ts b/src/lib/credential-store.spec.ts index 869e645f..dbb8499d 100644 --- a/src/lib/credential-store.spec.ts +++ b/src/lib/credential-store.spec.ts @@ -3,6 +3,11 @@ import { existsSync, readFileSync, unlinkSync, mkdtempSync, rmdirSync, writeFile import { join } from 'node:path'; import { tmpdir } from 'node:os'; +// Mock debug utilities BEFORE anything that imports credential-store +vi.mock('../utils/debug.js', () => ({ + logWarn: vi.fn(), +})); + // Create a mock home directory for all tests let testDir: string; let installerDir: string; diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index ada4e5a1..1748f36e 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -10,6 +10,7 @@ import { Entry } from '@napi-rs/keyring'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { logWarn } from '../utils/debug.js'; export interface StagingCache { clientId: string; @@ -53,7 +54,8 @@ function readFromFile(): Credentials | null { try { const content = fs.readFileSync(getCredentialsPath(), 'utf-8'); return JSON.parse(content); - } catch { + } catch (error) { + logWarn('Failed to read credentials file:', error); return null; } } @@ -84,7 +86,8 @@ function readFromKeyring(): Credentials | null { const data = entry.getPassword(); if (!data) return null; return JSON.parse(data); - } catch { + } catch (error) { + logWarn('Failed to read from keyring:', error); return null; } } @@ -94,7 +97,8 @@ function writeToKeyring(creds: Credentials): boolean { const entry = getKeyringEntry(); entry.setPassword(JSON.stringify(creds)); return true; - } catch { + } catch (error) { + logWarn('Failed to write to keyring:', error); return false; } } @@ -103,18 +107,21 @@ function deleteFromKeyring(): void { try { const entry = getKeyringEntry(); entry.deletePassword(); - } catch { - // Might not exist + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (!msg.includes('not found') && !msg.includes('No such')) { + logWarn('Failed to delete from keyring:', error); + } } } function showFallbackWarning(): void { if (fallbackWarningShown || forceInsecureStorage) return; fallbackWarningShown = true; - console.warn( - '\n⚠ Unable to store credentials in system keyring. Using file storage.\n' + - ' Credentials saved to ~/.workos/credentials.json\n' + - ' Use --insecure-storage to suppress this warning.\n', + logWarn( + 'Unable to store credentials in system keyring. Using file storage.', + 'Credentials saved to ~/.workos/credentials.json', + 'Use --insecure-storage to suppress this warning.', ); } diff --git a/src/lib/credentials.spec.ts b/src/lib/credentials.spec.ts index 787dca19..35d8ef86 100644 --- a/src/lib/credentials.spec.ts +++ b/src/lib/credentials.spec.ts @@ -3,6 +3,11 @@ import { existsSync, readFileSync, unlinkSync, mkdtempSync, rmdirSync, statSync, import { join } from 'node:path'; import { tmpdir } from 'node:os'; +// Mock debug utilities BEFORE anything that imports credential-store +vi.mock('../utils/debug.js', () => ({ + logWarn: vi.fn(), +})); + // Create a mock home directory for all tests let testDir: string; let installerDir: string; diff --git a/src/lib/ensure-auth.spec.ts b/src/lib/ensure-auth.spec.ts index c195461d..0872cc55 100644 --- a/src/lib/ensure-auth.spec.ts +++ b/src/lib/ensure-auth.spec.ts @@ -26,6 +26,7 @@ vi.mock('node:os', async (importOriginal) => { vi.mock('../utils/debug.js', () => ({ logInfo: vi.fn(), logError: vi.fn(), + logWarn: vi.fn(), })); // Mock settings diff --git a/src/lib/token-refresh.spec.ts b/src/lib/token-refresh.spec.ts index c85f95dc..ea4b1a8c 100644 --- a/src/lib/token-refresh.spec.ts +++ b/src/lib/token-refresh.spec.ts @@ -26,6 +26,7 @@ vi.mock('node:os', async (importOriginal) => { vi.mock('../utils/debug.js', () => ({ debug: vi.fn(), logInfo: vi.fn(), + logWarn: vi.fn(), })); // Import after mocks are set up From bf35b0004ea91c2dbf5114d12657aedbd4f23f97 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 4 Feb 2026 11:09:44 -0500 Subject: [PATCH 6/6] fix(login): auto-refresh expired token instead of forcing re-auth --- src/commands/login.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index fc424123..404852b1 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,7 +1,8 @@ import open from 'opn'; import clack from '../utils/clack.js'; -import { saveCredentials, getCredentials, getAccessToken } from '../lib/credentials.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'; /** * Parse JWT payload @@ -71,6 +72,7 @@ export async function runLogin(): Promise { process.exit(1); } + // Check if already logged in with valid token if (getAccessToken()) { const creds = getCredentials(); clack.log.info(`Already logged in as ${creds?.email ?? 'unknown'}`); @@ -78,6 +80,24 @@ export async function runLogin(): Promise { return; } + // Try to refresh if we have expired credentials with a refresh token + const existingCreds = getCredentials(); + if (existingCreds?.refreshToken && isTokenExpired(existingCreds)) { + try { + const authkitDomain = getAuthkitDomain(); + const result = await refreshAccessToken(authkitDomain, clientId); + if (result.accessToken && result.expiresAt) { + updateTokens(result.accessToken, result.expiresAt, result.refreshToken); + 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; + } + } catch { + // Refresh failed, proceed with fresh login + } + } + clack.log.step('Starting authentication...'); const endpoints = getConnectEndpoints();