From 1e19971679febeff6aba8584bb04a776ab7e7e4e Mon Sep 17 00:00:00 2001 From: kaigler Date: Fri, 30 Jan 2026 04:39:21 -0600 Subject: [PATCH] fix(windows): complete Windows credential fixes with path normalization (#1585) * fix(windows): add Windows file-based credential fallback (PR #1561) This includes all changes from PR #1561: - Add shared file credential helpers (getCredentialsFromFile, getFullCredentialsFromFile) - Add Windows file-based credential storage functions: - getWindowsCredentialsPath() - path to .credentials.json - getCredentialsFromWindowsFile() - read basic credentials from file - getCredentialsFromWindows() - tries Credential Manager first, falls back to file - getFullCredentialsFromWindowsFile() - read full credentials from file - getFullCredentialsFromWindows() - tries Credential Manager first, falls back to file - updateWindowsFileCredentials() - write credentials to file - updateWindowsCredentials() - updates both Credential Manager and file - Fix PtrToStructure failure in Windows Credential Manager PowerShell script by defining proper CREDENTIAL struct with IntPtr fields - Update index.ts to check migrated profiles for valid credentials via file fallback before showing re-auth modal - Update claude-code-handlers.ts to check Windows .credentials.json file - Refactor Linux credential code to use shared helpers - Add/update tests for Windows file fallback scenarios Co-Authored-By: Claude Opus 4.5 * fix(windows): add path normalization and smart credential source comparison Additional fixes on top of PR #1561: 1. Path normalization in claude-profile-manager.ts: - Normalize forward slashes to backslashes on Windows in getActiveProfileEnv() and getProfileEnv() - This ensures CLAUDE_CONFIG_DIR hash matches what Claude CLI computes - Fixes credential lookup failures when paths have mixed separators 2. Smart credential source comparison in credential-utils.ts: - getCredentialsFromWindows() now checks both file and Credential Manager - When both have tokens, prefers file (Claude CLI primary storage) - getFullCredentialsFromWindows() compares expiry times when both have tokens - Returns the token with later expiry (more recently refreshed) - Fixes stale token issue when Credential Manager has old tokens 3. Updated tests: - Fixed test to properly mock file not existing when testing Credential Manager - Added test for preferring file when both sources have tokens Co-Authored-By: Claude Opus 4.5 * fix: address PR #1585 review findings - Extract normalizeWindowsPath() shared helper to eliminate duplicated path normalization logic across 3 locations (credential-utils.ts, claude-profile-manager.ts x2) - Use isWindows() from platform module instead of process.platform - Remove redundant new Date() wrapper on numeric expiresAt values - Update getCredentialsFromKeychain() JSDoc to reflect actual behavior (Linux tries Secret Service first; Windows checks both file and Credential Manager) Co-Authored-By: Claude Opus 4.5 * fix(windows): address PR review findings for credential handling 1. Fix dual-write order in updateWindowsCredentials() (NEW-002-final): - Write to file FIRST (primary storage), then Credential Manager - Prevents inconsistent state where CM has new tokens but file has stale - Claude CLI reads from file, so file must always have latest tokens 2. Add Windows file permission restrictions (NEW-006-v2): - Use icacls to restrict credentials file to current user only - Mimics Unix 0600 permissions (owner read/write only) - Best-effort: logs warning if icacls fails but doesn't block operation 3. Add Windows Credential Manager fallback to checkProfileAuthentication (NEW-005-v2-final): - Check Windows Credential Manager when file-based checks fail - Handles edge case where credentials stored only in Credential Manager Co-Authored-By: Claude Opus 4.5 * fix(windows): address follow-up PR review findings 1. Fix UNC path regex in normalizeWindowsPath (NEW-001): - Updated regex to handle UNC paths starting with forward slashes - Paths like //server/share now correctly get normalized to \\server\share 2. Add directory permission restrictions (NEW-004): - Call restrictWindowsFilePermissions on directory after mkdirSync - Defense-in-depth: both directory and file now have user-only access 3. Align credential selection logic (NEW-005): - Changed getFullCredentialsFromWindows to always prefer file credentials - Now consistent with getCredentialsFromWindows behavior - Ensures same token returned from both basic and full APIs 4. Add test coverage for Windows credential selection (NEW-006): - Added 4 new tests for getFullCredentialsFromKeychain on Windows - Tests cover: file-only, CM-only, both sources, and neither source - Verifies file preference when both sources have tokens Co-Authored-By: Claude Opus 4.5 * fix(windows): document struct differences and use atomic file write 1. Document CREDENTIAL struct differences (NEW-001-NEW): - Added comments explaining why CredRead uses IntPtr (blittable struct for receiving data from Windows) while CredWrite uses string types (auto- marshaled when calling Windows APIs) - This is intentional and correct, not a bug 2. Implement atomic file write for credentials (NEW-003-NEW): - Write to temp file first, apply restrictive permissions, then rename - Eliminates race condition where file briefly exists with default permissions - Clean up temp file on error Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com> Co-authored-by: AndyMik90 --- .../src/main/claude-profile-manager.ts | 19 +- .../claude-profile/credential-utils.test.ts | 173 +++- .../main/claude-profile/credential-utils.ts | 807 ++++++++++++++---- apps/frontend/src/main/index.ts | 20 +- .../main/ipc-handlers/claude-code-handlers.ts | 24 +- 5 files changed, 832 insertions(+), 211 deletions(-) diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index b2de28e8..151c7768 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -43,7 +43,7 @@ import { shouldProactivelySwitch as shouldProactivelySwitchImpl, getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl } from './claude-profile/profile-scorer'; -import { getCredentialsFromKeychain } from './claude-profile/credential-utils'; +import { getCredentialsFromKeychain, normalizeWindowsPath } from './claude-profile/credential-utils'; import { CLAUDE_PROFILES_DIR, generateProfileId as generateProfileIdImpl, @@ -498,9 +498,12 @@ export class ClaudeProfileManager { // This prevents interference with external Claude Code CLI usage if (profile?.configDir) { // Expand ~ to home directory for the environment variable - const expandedConfigDir = profile.configDir.startsWith('~') - ? profile.configDir.replace(/^~/, homedir()) - : profile.configDir; + const expandedConfigDir = normalizeWindowsPath( + profile.configDir.startsWith('~') + ? profile.configDir.replace(/^~/, homedir()) + : profile.configDir + ); + env.CLAUDE_CONFIG_DIR = expandedConfigDir; if (process.env.DEBUG === 'true') { console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir); @@ -719,9 +722,11 @@ export class ClaudeProfileManager { } // Expand ~ to home directory for the environment variable - const expandedConfigDir = profile.configDir.startsWith('~') - ? profile.configDir.replace(/^~/, require('os').homedir()) - : profile.configDir; + const expandedConfigDir = normalizeWindowsPath( + profile.configDir.startsWith('~') + ? profile.configDir.replace(/^~/, require('os').homedir()) + : profile.configDir + ); if (process.env.DEBUG === 'true') { console.warn('[ClaudeProfileManager] getProfileEnv:', { diff --git a/apps/frontend/src/main/claude-profile/credential-utils.test.ts b/apps/frontend/src/main/claude-profile/credential-utils.test.ts index bf0b6c7f..3089d244 100644 --- a/apps/frontend/src/main/claude-profile/credential-utils.test.ts +++ b/apps/frontend/src/main/claude-profile/credential-utils.test.ts @@ -34,6 +34,7 @@ import { getKeychainServiceName, getWindowsCredentialTarget, getCredentialsFromKeychain, + getFullCredentialsFromKeychain, getCredentials, clearKeychainCache, clearCredentialCache, @@ -358,19 +359,24 @@ describe('credential-utils', () => { vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser'); }); - it('should return null when PowerShell not found', () => { + it('should return null when PowerShell not found and no credentials file exists', () => { + // Neither PowerShell nor credentials file exists vi.mocked(existsSync).mockReturnValue(false); const result = getCredentialsFromKeychain(); expect(result.token).toBeNull(); expect(result.email).toBeNull(); - expect(result.error).toBe('PowerShell not found'); + // No error because file fallback returns null gracefully when file doesn't exist }); - it('should return credentials from Windows Credential Manager', () => { - // Mock PowerShell path found - vi.mocked(existsSync).mockReturnValue(true); + it('should return credentials from Windows Credential Manager when file is empty', () => { + // Mock PowerShell path found, but credentials file doesn't exist + vi.mocked(existsSync).mockImplementation((path: unknown) => { + const pathStr = String(path); + // PowerShell exists, but credentials file doesn't + return pathStr.includes('PowerShell') || pathStr.includes('powershell'); + }); vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-windows-token-789', @@ -384,9 +390,33 @@ describe('credential-utils', () => { expect(result.email).toBe('windows@example.com'); }); - it('should return null when credential not found', () => { + it('should fall back to file when Credential Manager returns empty', () => { + // Mock PowerShell exists but returns empty (no credential in Credential Manager) + // Mock file exists with valid credentials vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(execFileSync).mockReturnValue(''); + vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-file-fallback-token', + email: 'file@example.com', + }, + })); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBe('sk-ant-file-fallback-token'); + expect(result.email).toBe('file@example.com'); + }); + + it('should return null when both Credential Manager and file have no credentials', () => { + // Mock PowerShell exists but returns empty + // Mock credentials file doesn't exist + vi.mocked(existsSync).mockImplementation((path: unknown) => { + const pathStr = String(path); + // PowerShell exists, but credentials file doesn't + return pathStr.includes('PowerShell') || pathStr.includes('powershell'); + }); + vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty const result = getCredentialsFromKeychain(); @@ -394,14 +424,137 @@ describe('credential-utils', () => { expect(result.email).toBeNull(); }); - it('should handle invalid JSON from Credential Manager', () => { + it('should handle invalid JSON from Credential Manager by falling back to file', () => { vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(execFileSync).mockReturnValue('invalid json'); + vi.mocked(execFileSync).mockReturnValue('invalid json'); // Invalid JSON from Credential Manager + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-file-token-after-cm-failure', + email: 'fallback@example.com', + }, + })); const result = getCredentialsFromKeychain(); + // Should fall back to file and get valid credentials + expect(result.token).toBe('sk-ant-file-token-after-cm-failure'); + expect(result.email).toBe('fallback@example.com'); + }); + + it('should prefer file credentials when both sources have tokens', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-windows-file-token', + email: 'windowsfile@example.com', + }, + })); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-credman-token', + email: 'credman@example.com', + }, + })); + + const result = getCredentialsFromKeychain(); + + // Should prefer file since Claude CLI writes there after login + expect(result.token).toBe('sk-ant-windows-file-token'); + expect(result.email).toBe('windowsfile@example.com'); + }); + }); + + describe('getFullCredentialsFromKeychain (Windows)', () => { + beforeEach(() => { + vi.mocked(isMacOS).mockReturnValue(false); + vi.mocked(isWindows).mockReturnValue(true); + vi.mocked(isLinux).mockReturnValue(false); + vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser'); + clearCredentialCache(); + }); + + it('should return full credentials from file when available', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-full-creds-token', + refreshToken: 'refresh-token-123', + expiresAt: 1700000000000, + email: 'full@example.com', + scopes: ['user:read', 'user:write'], + }, + })); + vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty + + const result = getFullCredentialsFromKeychain(); + + expect(result.token).toBe('sk-ant-full-creds-token'); + expect(result.refreshToken).toBe('refresh-token-123'); + expect(result.expiresAt).toBe(1700000000000); + expect(result.email).toBe('full@example.com'); + expect(result.scopes).toEqual(['user:read', 'user:write']); + }); + + it('should return credentials from Credential Manager when file is empty', () => { + vi.mocked(existsSync).mockImplementation((path: unknown) => { + const pathStr = String(path); + return pathStr.includes('PowerShell') || pathStr.includes('powershell'); + }); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-credman-full-token', + refreshToken: 'credman-refresh', + expiresAt: 1700000000000, + email: 'credman@example.com', + }, + })); + + const result = getFullCredentialsFromKeychain(); + + expect(result.token).toBe('sk-ant-credman-full-token'); + expect(result.refreshToken).toBe('credman-refresh'); + expect(result.email).toBe('credman@example.com'); + }); + + it('should prefer file credentials when both sources have tokens (consistent with basic API)', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-file-full-token', + refreshToken: 'file-refresh', + expiresAt: 1700000000000, + email: 'file@example.com', + }, + })); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-credman-full-token', + refreshToken: 'credman-refresh', + expiresAt: 1800000000000, // Later expiry + email: 'credman@example.com', + }, + })); + + const result = getFullCredentialsFromKeychain(); + + // Should prefer file since Claude CLI writes there after login + // This is consistent with getCredentialsFromKeychain behavior + expect(result.token).toBe('sk-ant-file-full-token'); + expect(result.refreshToken).toBe('file-refresh'); + expect(result.email).toBe('file@example.com'); + }); + + it('should return null when both sources have no credentials', () => { + vi.mocked(existsSync).mockImplementation((path: unknown) => { + const pathStr = String(path); + return pathStr.includes('PowerShell') || pathStr.includes('powershell'); + }); + vi.mocked(execFileSync).mockReturnValue(''); + + const result = getFullCredentialsFromKeychain(); + expect(result.token).toBeNull(); - expect(result.email).toBeNull(); + expect(result.refreshToken).toBeNull(); }); }); diff --git a/apps/frontend/src/main/claude-profile/credential-utils.ts b/apps/frontend/src/main/claude-profile/credential-utils.ts index 215b353a..67cffd48 100644 --- a/apps/frontend/src/main/claude-profile/credential-utils.ts +++ b/apps/frontend/src/main/claude-profile/credential-utils.ts @@ -17,9 +17,9 @@ import { execFileSync } from 'child_process'; import { createHash } from 'crypto'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs'; import { homedir, userInfo } from 'os'; -import { join } from 'path'; +import { dirname, join } from 'path'; import { isMacOS, isWindows, isLinux } from '../platform'; /** @@ -157,6 +157,28 @@ export function calculateConfigDirHash(configDir: string): string { return createHash('sha256').update(configDir).digest('hex').slice(0, 8); } +/** + * Normalize Windows path separators for hash consistency with Claude CLI. + * + * Claude CLI on Windows uses backslashes, so we must too for hash consistency. + * Mixed slashes (C:\Users\bill/.claude-profiles) produce different hashes than + * consistent slashes (C:\Users\bill\.claude-profiles). + * + * Supports: + * - Drive letter paths: C:\Users\... + * - UNC paths with backslashes: \\server\share + * - UNC paths with forward slashes: //server/share (normalized to \\server\share) + * + * @param path - The path to normalize + * @returns The path with forward slashes replaced by backslashes on Windows + */ +export function normalizeWindowsPath(path: string): string { + if (!isWindows()) return path; + // Match: drive letter (C:), UNC with backslashes (\\), or UNC with forward slashes (//) + if (!/^[A-Za-z]:|^[\\/]{2}/.test(path)) return path; + return path.replace(/\//g, '\\'); +} + /** * Get the Keychain service name for a config directory (macOS). * @@ -176,9 +198,11 @@ export function getKeychainServiceName(configDir?: string): string { } // Normalize the configDir: expand ~ and resolve to absolute path - const normalizedConfigDir = configDir.startsWith('~') - ? join(homedir(), configDir.slice(1)) - : configDir; + const normalizedConfigDir = normalizeWindowsPath( + configDir.startsWith('~') + ? join(homedir(), configDir.slice(1)) + : configDir + ); // ALL profiles now use hash-based keychain entries for isolation // This prevents interference with external Claude Code CLI @@ -385,6 +409,195 @@ function parseCredentialJson( return extractFn(data); } +// ============================================================================= +// File-Based Credential Helpers (Shared for Linux and Windows) +// ============================================================================= + +/** + * Shared implementation for reading credentials from a JSON file. + * Used by both Linux and Windows file-based credential storage. + * + * @param credentialsPath - Path to the credentials file + * @param cacheKey - Cache key for storing results + * @param logPrefix - Prefix for log messages (e.g., "Linux", "Windows:File") + * @param forceRefresh - Whether to bypass cache + * @returns Platform credentials with token and email + */ +function getCredentialsFromFile( + credentialsPath: string, + cacheKey: string, + logPrefix: string, + forceRefresh = false +): PlatformCredentials { + const isDebug = process.env.DEBUG === 'true'; + const now = Date.now(); + + // Return cached credentials if available and fresh + const cached = credentialCache.get(cacheKey); + if (!forceRefresh && cached) { + const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS; + if ((now - cached.timestamp) < ttl) { + if (isDebug) { + const cacheAge = now - cached.timestamp; + console.warn(`[CredentialUtils:${logPrefix}:CACHE] Returning cached credentials:`, { + credentialsPath, + hasToken: !!cached.credentials.token, + tokenFingerprint: getTokenFingerprint(cached.credentials.token), + cacheAge: Math.round(cacheAge / 1000) + 's' + }); + } + return cached.credentials; + } + } + + // Defense-in-depth: Validate credentials path is within expected boundaries + if (!isValidCredentialsPath(credentialsPath)) { + if (isDebug) { + console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath }); + } + const invalidResult = { token: null, email: null, error: 'Invalid credentials path' }; + credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); + return invalidResult; + } + + // Check if credentials file exists + if (!existsSync(credentialsPath)) { + if (isDebug) { + console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath); + } + const notFoundResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); + return notFoundResult; + } + + try { + const content = readFileSync(credentialsPath, 'utf-8'); + + // Parse JSON + let data: unknown; + try { + data = JSON.parse(content); + } catch { + console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath); + const errorResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } + + // Validate JSON structure + if (!validateCredentialData(data)) { + console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath); + const invalidResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); + return invalidResult; + } + + const { token, email } = extractCredentials(data); + + // Validate token format if present + if (token && !isValidTokenFormat(token)) { + console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath); + const result = { token: null, email }; + credentialCache.set(cacheKey, { credentials: result, timestamp: now }); + return result; + } + + const credentials = { token, email }; + credentialCache.set(cacheKey, { credentials, timestamp: now }); + + if (isDebug) { + console.warn(`[CredentialUtils:${logPrefix}] Retrieved credentials from file:`, credentialsPath, { + hasToken: !!token, + hasEmail: !!email, + tokenFingerprint: getTokenFingerprint(token), + forceRefresh + }); + } + return credentials; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage); + const errorResult = { token: null, email: null, error: `Failed to read credentials: ${errorMessage}` }; + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } +} + +/** + * Shared implementation for reading full credentials from a JSON file. + * Used by both Linux and Windows file-based credential storage. + * + * @param credentialsPath - Path to the credentials file + * @param logPrefix - Prefix for log messages (e.g., "Linux:Full", "Windows:File:Full") + * @returns Full OAuth credentials including refresh token + */ +function getFullCredentialsFromFile( + credentialsPath: string, + logPrefix: string +): FullOAuthCredentials { + const isDebug = process.env.DEBUG === 'true'; + + // Defense-in-depth: Validate credentials path is within expected boundaries + if (!isValidCredentialsPath(credentialsPath)) { + if (isDebug) { + console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath }); + } + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credentials path' }; + } + + // Check if credentials file exists + if (!existsSync(credentialsPath)) { + if (isDebug) { + console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath); + } + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; + } + + try { + const content = readFileSync(credentialsPath, 'utf-8'); + + // Parse JSON + let data: unknown; + try { + data = JSON.parse(content); + } catch { + console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath); + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; + } + + // Validate JSON structure + if (!validateCredentialData(data)) { + console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath); + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; + } + + const { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier } = extractFullCredentials(data); + + // Validate token format if present + if (token && !isValidTokenFormat(token)) { + console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath); + return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; + } + + if (isDebug) { + console.warn(`[CredentialUtils:${logPrefix}] Retrieved full credentials from file:`, credentialsPath, { + hasToken: !!token, + hasEmail: !!email, + hasRefreshToken: !!refreshToken, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + tokenFingerprint: getTokenFingerprint(token), + subscriptionType, + rateLimitTier + }); + } + return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage); + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Failed to read credentials: ${errorMessage}` }; + } +} + // ============================================================================= // macOS Keychain Implementation // ============================================================================= @@ -653,98 +866,7 @@ function getLinuxCredentialsPath(configDir?: string): string { function getCredentialsFromLinuxFile(configDir?: string, forceRefresh = false): PlatformCredentials { const credentialsPath = getLinuxCredentialsPath(configDir); const cacheKey = `linux:${credentialsPath}`; - const isDebug = process.env.DEBUG === 'true'; - const now = Date.now(); - - // Return cached credentials if available and fresh - const cached = credentialCache.get(cacheKey); - if (!forceRefresh && cached) { - const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS; - if ((now - cached.timestamp) < ttl) { - if (isDebug) { - const cacheAge = now - cached.timestamp; - console.warn('[CredentialUtils:Linux:CACHE] Returning cached credentials:', { - credentialsPath, - hasToken: !!cached.credentials.token, - tokenFingerprint: getTokenFingerprint(cached.credentials.token), - cacheAge: Math.round(cacheAge / 1000) + 's' - }); - } - return cached.credentials; - } - } - - // Defense-in-depth: Validate credentials path is within expected boundaries - if (!isValidCredentialsPath(credentialsPath)) { - if (isDebug) { - console.warn('[CredentialUtils:Linux] Invalid credentials path rejected:', { credentialsPath }); - } - const invalidResult = { token: null, email: null, error: 'Invalid credentials path' }; - credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); - return invalidResult; - } - - // Check if credentials file exists - if (!existsSync(credentialsPath)) { - if (isDebug) { - console.warn('[CredentialUtils:Linux] Credentials file not found:', credentialsPath); - } - const notFoundResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); - return notFoundResult; - } - - try { - const content = readFileSync(credentialsPath, 'utf-8'); - - // Parse JSON - let data: unknown; - try { - data = JSON.parse(content); - } catch { - console.warn('[CredentialUtils:Linux] Failed to parse credentials JSON:', credentialsPath); - const errorResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); - return errorResult; - } - - // Validate JSON structure - if (!validateCredentialData(data)) { - console.warn('[CredentialUtils:Linux] Invalid credentials data structure:', credentialsPath); - const invalidResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); - return invalidResult; - } - - const { token, email } = extractCredentials(data); - - // Validate token format if present - if (token && !isValidTokenFormat(token)) { - console.warn('[CredentialUtils:Linux] Invalid token format in:', credentialsPath); - const result = { token: null, email }; - credentialCache.set(cacheKey, { credentials: result, timestamp: now }); - return result; - } - - const credentials = { token, email }; - credentialCache.set(cacheKey, { credentials, timestamp: now }); - - if (isDebug) { - console.warn('[CredentialUtils:Linux] Retrieved credentials from file:', credentialsPath, { - hasToken: !!token, - hasEmail: !!email, - tokenFingerprint: getTokenFingerprint(token), - forceRefresh - }); - } - return credentials; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn('[CredentialUtils:Linux] Failed to read credentials file:', credentialsPath, errorMessage); - const errorResult = { token: null, email: null, error: `Failed to read credentials: ${errorMessage}` }; - credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); - return errorResult; - } + return getCredentialsFromFile(credentialsPath, cacheKey, 'Linux', forceRefresh); } // ============================================================================= @@ -804,29 +926,60 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef try { // PowerShell script to read from Credential Manager // Uses the Windows Credential Manager API via .NET + // NOTE: The CREDENTIAL struct must use IntPtr for string fields (blittable requirement) + // and strings must be manually marshaled after PtrToStructure + // + // NOTE: This CREDENTIAL struct uses IntPtr for string fields (TargetName, Comment, etc.) + // because CredRead returns a pointer to Windows-allocated memory. We must use a "blittable" + // struct layout where strings are IntPtr, then manually marshal strings via PtrToStringUni. + // This differs from the CredWrite struct (see updateWindowsCredentialManagerCredentials) + // which uses string types because the .NET marshaler can automatically convert strings + // to pointers when CALLING Windows APIs (but not when RECEIVING data from them). const psScript = ` $ErrorActionPreference = 'Stop' - Add-Type -AssemblyName System.Runtime.WindowsRuntime - # Use CredRead from advapi32.dll to read generic credentials - $sig = @' - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr); + # Define the CREDENTIAL struct with IntPtr for string fields (required for CredRead marshaling) + # See comment above for why this differs from the CredWrite struct definition. + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; - [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool CredFree(IntPtr cred); +[StructLayout(LayoutKind.Sequential)] +public struct CREDENTIAL { + public uint Flags; + public uint Type; + public IntPtr TargetName; + public IntPtr Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public uint CredentialBlobSize; + public IntPtr CredentialBlob; + public uint Persist; + public uint AttributeCount; + public IntPtr Attributes; + public IntPtr TargetAlias; + public IntPtr UserName; +} '@ - Add-Type -MemberDefinition $sig -Namespace Win32 -Name Credential + + # Import CredRead and CredFree from advapi32.dll + Add-Type -MemberDefinition @' +[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] +public static extern bool CredRead(string target, uint type, uint reservedFlag, out IntPtr credentialPtr); + +[DllImport("advapi32.dll", SetLastError = true)] +public static extern bool CredFree(IntPtr cred); +'@ -Namespace Win32 -Name CredApi $credPtr = [IntPtr]::Zero # CRED_TYPE_GENERIC = 1 - $success = [Win32.Credential]::CredRead("${escapePowerShellString(targetName)}", 1, 0, [ref]$credPtr) + $success = [Win32.CredApi]::CredRead("${escapePowerShellString(targetName)}", 1, 0, [ref]$credPtr) if ($success) { try { - $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [Type][System.Management.Automation.PSCredential].Assembly.GetType('Microsoft.PowerShell.Commands.CREDENTIAL')) + # Marshal the pointer to our CREDENTIAL struct + $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [Type][CREDENTIAL]) - # Read the credential blob (password field) + # Read the credential blob (password field) - contains the JSON $blobSize = $cred.CredentialBlobSize if ($blobSize -gt 0) { $blob = [byte[]]::new($blobSize) @@ -835,7 +988,7 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef Write-Output $password } } finally { - [Win32.Credential]::CredFree($credPtr) | Out-Null + [Win32.CredApi]::CredFree($credPtr) | Out-Null } } else { # Credential not found - this is expected if user hasn't authenticated @@ -911,6 +1064,67 @@ function findPowerShellPath(): string | null { return null; } +// ============================================================================= +// Windows Credentials File Implementation (Fallback) +// ============================================================================= + +/** + * Get the credentials file path for Windows + * Claude CLI on Windows stores credentials in .credentials.json files, not Windows Credential Manager + */ +function getWindowsCredentialsPath(configDir?: string): string { + const baseDir = configDir || join(homedir(), '.claude'); + return join(baseDir, '.credentials.json'); +} + +/** + * Retrieve credentials from Windows .credentials.json file + * This is the primary storage mechanism used by Claude CLI on Windows + */ +function getCredentialsFromWindowsFile(configDir?: string, forceRefresh = false): PlatformCredentials { + const credentialsPath = getWindowsCredentialsPath(configDir); + const cacheKey = `windows-file:${credentialsPath}`; + return getCredentialsFromFile(credentialsPath, cacheKey, 'Windows:File', forceRefresh); +} + +/** + * Retrieve credentials from Windows - checks both file and Credential Manager, uses the most recent valid token. + * Claude CLI on Windows can store credentials in either location, and they may get out of sync. + * We compare both sources and return the one with the most recent/valid token. + */ +function getCredentialsFromWindows(configDir?: string, forceRefresh = false): PlatformCredentials { + const isDebug = process.env.DEBUG === 'true'; + + // Get credentials from both sources + const fileResult = getCredentialsFromWindowsFile(configDir, forceRefresh); + const credManagerResult = getCredentialsFromWindowsCredentialManager(configDir, forceRefresh); + + // If only one has a token, use that one + if (fileResult.token && !credManagerResult.token) { + if (isDebug) { + console.warn('[CredentialUtils:Windows] Using file credentials (Credential Manager empty)'); + } + return fileResult; + } + if (credManagerResult.token && !fileResult.token) { + if (isDebug) { + console.warn('[CredentialUtils:Windows] Using Credential Manager credentials (file empty)'); + } + return credManagerResult; + } + + // If neither has a token, return file result (which has the appropriate error) + if (!fileResult.token && !credManagerResult.token) { + return fileResult; + } + + // Both have tokens - prefer file since Claude CLI writes there after login + if (isDebug) { + console.warn('[CredentialUtils:Windows] Both sources have tokens, preferring file (Claude CLI primary storage)'); + } + return fileResult; +} + // ============================================================================= // Cross-Platform Public API // ============================================================================= @@ -920,8 +1134,8 @@ function findPowerShellPath(): string | null { * secure storage. * * - macOS: Reads from Keychain - * - Linux: Reads from .credentials.json file - * - Windows: Reads from Windows Credential Manager + * - Linux: Tries Secret Service (via secret-tool), falls back to .credentials.json + * - Windows: Checks both .credentials.json and Credential Manager, prefers file * * For default profile: reads from "Claude Code-credentials" or default config dir * For custom profiles: uses SHA256(configDir).slice(0,8) hash suffix @@ -942,7 +1156,7 @@ export function getCredentialsFromKeychain(configDir?: string, forceRefresh = fa } if (isWindows()) { - return getCredentialsFromWindowsCredentialManager(configDir, forceRefresh); + return getCredentialsFromWindows(configDir, forceRefresh); } // Unknown platform - return empty @@ -967,11 +1181,13 @@ export function clearKeychainCache(configDir?: string): void { const linuxSecretKey = `linux-secret:${getSecretServiceAttribute(configDir)}`; const linuxFileKey = `linux:${getLinuxCredentialsPath(configDir)}`; const windowsKey = `windows:${getWindowsCredentialTarget(configDir)}`; + const windowsFileKey = `windows-file:${getWindowsCredentialsPath(configDir)}`; credentialCache.delete(macOSKey); credentialCache.delete(linuxSecretKey); credentialCache.delete(linuxFileKey); credentialCache.delete(windowsKey); + credentialCache.delete(windowsFileKey); } else { credentialCache.clear(); } @@ -1136,67 +1352,7 @@ function getFullCredentialsFromLinux(configDir?: string): FullOAuthCredentials { */ function getFullCredentialsFromLinuxFile(configDir?: string): FullOAuthCredentials { const credentialsPath = getLinuxCredentialsPath(configDir); - const isDebug = process.env.DEBUG === 'true'; - - // Defense-in-depth: Validate credentials path is within expected boundaries - if (!isValidCredentialsPath(credentialsPath)) { - if (isDebug) { - console.warn('[CredentialUtils:Linux:Full] Invalid credentials path rejected:', { credentialsPath }); - } - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credentials path' }; - } - - // Check if credentials file exists - if (!existsSync(credentialsPath)) { - if (isDebug) { - console.warn('[CredentialUtils:Linux:Full] Credentials file not found:', credentialsPath); - } - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; - } - - try { - const content = readFileSync(credentialsPath, 'utf-8'); - - // Parse JSON - let data: unknown; - try { - data = JSON.parse(content); - } catch { - console.warn('[CredentialUtils:Linux:Full] Failed to parse credentials JSON:', credentialsPath); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; - } - - // Validate JSON structure - if (!validateCredentialData(data)) { - console.warn('[CredentialUtils:Linux:Full] Invalid credentials data structure:', credentialsPath); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; - } - - const { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier } = extractFullCredentials(data); - - // Validate token format if present - if (token && !isValidTokenFormat(token)) { - console.warn('[CredentialUtils:Linux:Full] Invalid token format in:', credentialsPath); - return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; - } - - if (isDebug) { - console.warn('[CredentialUtils:Linux:Full] Retrieved full credentials from file:', credentialsPath, { - hasToken: !!token, - hasEmail: !!email, - hasRefreshToken: !!refreshToken, - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - tokenFingerprint: getTokenFingerprint(token), - subscriptionType, - rateLimitTier - }); - } - return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn('[CredentialUtils:Linux:Full] Failed to read credentials file:', credentialsPath, errorMessage); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Failed to read credentials: ${errorMessage}` }; - } + return getFullCredentialsFromFile(credentialsPath, 'Linux:Full'); } /** @@ -1223,29 +1379,51 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful try { // PowerShell script to read from Credential Manager (same as basic credentials) + // NOTE: The CREDENTIAL struct must use IntPtr for string fields (blittable requirement) const psScript = ` $ErrorActionPreference = 'Stop' - Add-Type -AssemblyName System.Runtime.WindowsRuntime - # Use CredRead from advapi32.dll to read generic credentials - $sig = @' - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr); + # Define the CREDENTIAL struct with IntPtr for string fields (required for marshaling) + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; - [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool CredFree(IntPtr cred); +[StructLayout(LayoutKind.Sequential)] +public struct CREDENTIAL { + public uint Flags; + public uint Type; + public IntPtr TargetName; + public IntPtr Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public uint CredentialBlobSize; + public IntPtr CredentialBlob; + public uint Persist; + public uint AttributeCount; + public IntPtr Attributes; + public IntPtr TargetAlias; + public IntPtr UserName; +} '@ - Add-Type -MemberDefinition $sig -Namespace Win32 -Name Credential + + # Import CredRead and CredFree from advapi32.dll + Add-Type -MemberDefinition @' +[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] +public static extern bool CredRead(string target, uint type, uint reservedFlag, out IntPtr credentialPtr); + +[DllImport("advapi32.dll", SetLastError = true)] +public static extern bool CredFree(IntPtr cred); +'@ -Namespace Win32 -Name CredApi $credPtr = [IntPtr]::Zero # CRED_TYPE_GENERIC = 1 - $success = [Win32.Credential]::CredRead("${escapePowerShellString(targetName)}", 1, 0, [ref]$credPtr) + $success = [Win32.CredApi]::CredRead("${escapePowerShellString(targetName)}", 1, 0, [ref]$credPtr) if ($success) { try { - $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [Type][System.Management.Automation.PSCredential].Assembly.GetType('Microsoft.PowerShell.Commands.CREDENTIAL')) + # Marshal the pointer to our CREDENTIAL struct + $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [Type][CREDENTIAL]) - # Read the credential blob (password field) + # Read the credential blob (password field) - contains the JSON $blobSize = $cred.CredentialBlobSize if ($blobSize -gt 0) { $blob = [byte[]]::new($blobSize) @@ -1254,7 +1432,7 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful Write-Output $password } } finally { - [Win32.Credential]::CredFree($credPtr) | Out-Null + [Win32.CredApi]::CredFree($credPtr) | Out-Null } } else { # Credential not found - this is expected if user hasn't authenticated @@ -1306,6 +1484,56 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful } } +/** + * Retrieve full credentials (including refresh token) from Windows .credentials.json file + * This is the primary storage mechanism used by Claude CLI on Windows + */ +function getFullCredentialsFromWindowsFile(configDir?: string): FullOAuthCredentials { + const credentialsPath = getWindowsCredentialsPath(configDir); + return getFullCredentialsFromFile(credentialsPath, 'Windows:File:Full'); +} + +/** + * Retrieve full credentials from Windows - checks both file and Credential Manager, uses the most recent valid token. + * Claude CLI on Windows can store credentials in either location, and they may get out of sync. + * We compare both sources and return the one with the later expiry time (most recently refreshed). + */ +function getFullCredentialsFromWindows(configDir?: string): FullOAuthCredentials { + const isDebug = process.env.DEBUG === 'true'; + + // Get credentials from both sources + const fileResult = getFullCredentialsFromWindowsFile(configDir); + const credManagerResult = getFullCredentialsFromWindowsCredentialManager(configDir); + + // If only one has a token, use that one + if (fileResult.token && !credManagerResult.token) { + if (isDebug) { + console.warn('[CredentialUtils:Windows:Full] Using file credentials (Credential Manager empty)'); + } + return fileResult; + } + if (credManagerResult.token && !fileResult.token) { + if (isDebug) { + console.warn('[CredentialUtils:Windows:Full] Using Credential Manager credentials (file empty)'); + } + return credManagerResult; + } + + // If neither has a token, return file result (which has the appropriate error) + if (!fileResult.token && !credManagerResult.token) { + return fileResult; + } + + // Both have tokens - prefer file since Claude CLI writes there after login + // This is consistent with getCredentialsFromWindows() which also prefers file. + // Using file as primary ensures consistency: the same token is returned whether + // calling getCredentialsFromKeychain() or getFullCredentialsFromKeychain(). + if (isDebug) { + console.warn('[CredentialUtils:Windows:Full] Both sources have tokens, preferring file (Claude CLI primary storage)'); + } + return fileResult; +} + /** * Get full credentials including refresh token and expiry from platform-specific secure storage. * This is an extended version of getCredentialsFromKeychain that returns all credential data @@ -1324,7 +1552,7 @@ export function getFullCredentialsFromKeychain(configDir?: string): FullOAuthCre } if (isWindows()) { - return getFullCredentialsFromWindowsCredentialManager(configDir); + return getFullCredentialsFromWindows(configDir); } // Unknown platform - return empty @@ -1589,6 +1817,12 @@ function updateLinuxFileCredentials( const credentialsJson = JSON.stringify(newCredentialData, null, 2); + // Ensure directory exists (matching Windows behavior) + const dirPath = dirname(credentialsPath); + if (!existsSync(dirPath)) { + mkdirSync(dirPath, { recursive: true, mode: 0o700 }); + } + // Write to file with secure permissions (0600) writeFileSync(credentialsPath, credentialsJson, { mode: 0o600, encoding: 'utf-8' }); @@ -1658,10 +1892,18 @@ function updateWindowsCredentialManagerCredentials( const base64Json = encodeBase64ForPowerShell(credentialsJson); // PowerShell script to write to Credential Manager + // + // NOTE: This CREDENTIAL struct uses string types for TargetName, Comment, etc. + // because CredWrite accepts data FROM us, and the .NET marshaler can automatically + // convert string fields to the appropriate Unicode pointers when CALLING Windows APIs. + // This differs from the CredRead struct (see getCredentialsFromWindowsCredentialManager) + // which must use IntPtr because we're RECEIVING data from Windows and need to manually + // marshal the strings from Windows-allocated memory. const psScript = ` $ErrorActionPreference = 'Stop' # Use CredWrite from advapi32.dll to write generic credentials + # This struct uses string types (auto-marshaled) unlike CredRead which needs IntPtr. $sig = @' [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct CREDENTIAL { @@ -1738,6 +1980,197 @@ function updateWindowsCredentialManagerCredentials( } } +/** + * Restrict Windows file permissions to current user only using icacls. + * This is a best-effort operation - if it fails, we log a warning but don't fail the overall operation. + * + * @param filePath - Path to the file to secure + */ +function restrictWindowsFilePermissions(filePath: string): void { + const isDebug = process.env.DEBUG === 'true'; + + try { + // Use icacls to: + // 1. Disable inheritance and remove all inherited permissions (/inheritance:r) + // 2. Grant full control to the current user only (/grant:r %USERNAME%:F) + // This mimics Unix 0600 permissions (owner read/write only) + const username = userInfo().username; + + // First, disable inheritance and remove inherited permissions + execFileSync('icacls', [filePath, '/inheritance:r'], { + windowsHide: true, + timeout: 5000, + }); + + // Then grant full control to current user only + execFileSync('icacls', [filePath, '/grant:r', `${username}:F`], { + windowsHide: true, + timeout: 5000, + }); + + if (isDebug) { + console.warn('[CredentialUtils:Windows] Set restrictive permissions on:', filePath); + } + } catch (error) { + // Non-fatal: log warning but don't fail the operation + // The file is still protected by the user's home directory permissions + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[CredentialUtils:Windows] Could not set restrictive file permissions:', errorMessage); + } +} + +/** + * Update credentials in Windows .credentials.json file with new tokens (fallback). + * + * This is the fallback method for Windows when Credential Manager is unavailable. + * Claude CLI on Windows primarily uses file-based storage (.credentials.json), + * so this fallback ensures credentials are persisted even if Credential Manager fails. + * + * Security: We use icacls to restrict file permissions to the current user only, + * mimicking Unix 0600 permissions. This prevents other users on multi-user systems + * from reading the OAuth tokens. + * + * @param configDir - Config directory for the profile (undefined for default profile) + * @param credentials - New credentials to store + * @returns Result indicating success or failure + */ +function updateWindowsFileCredentials( + configDir: string | undefined, + credentials: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes?: string[]; + } +): UpdateCredentialsResult { + const credentialsPath = getWindowsCredentialsPath(configDir); + const isDebug = process.env.DEBUG === 'true'; + + // Defense-in-depth: Validate credentials path + if (!isValidCredentialsPath(credentialsPath)) { + return { success: false, error: 'Invalid credentials path' }; + } + + try { + // Read existing credentials to preserve email and other fields + const existing = getFullCredentialsFromWindowsFile(configDir); + + // Build new credential JSON with all fields + const newCredentialData = { + claudeAiOauth: { + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + expiresAt: credentials.expiresAt, + scopes: credentials.scopes || existing.scopes || [], + email: existing.email || undefined, + emailAddress: existing.email || undefined, + subscriptionType: existing.subscriptionType || undefined, + rateLimitTier: existing.rateLimitTier || undefined + }, + email: existing.email || undefined + }; + + const credentialsJson = JSON.stringify(newCredentialData, null, 2); + + // Ensure directory exists with secure permissions + const dirPath = dirname(credentialsPath); + if (!existsSync(dirPath)) { + mkdirSync(dirPath, { recursive: true }); + // Restrict directory permissions to current user only (mimics Unix 0700) + restrictWindowsFilePermissions(dirPath); + } + + // Atomic file write: write to temp file, set permissions, then rename. + // This prevents a race condition where the file briefly exists with default permissions. + const tempPath = `${credentialsPath}.${Date.now()}.tmp`; + try { + // Write to temp file + writeFileSync(tempPath, credentialsJson, { encoding: 'utf-8' }); + + // Restrict temp file permissions to current user only (mimics Unix 0600) + restrictWindowsFilePermissions(tempPath); + + // Atomic rename (on same filesystem, this is atomic on Windows) + renameSync(tempPath, credentialsPath); + } catch (writeError) { + // Clean up temp file on error + try { + if (existsSync(tempPath)) { + unlinkSync(tempPath); + } + } catch { + // Ignore cleanup errors + } + throw writeError; + } + + if (isDebug) { + console.warn('[CredentialUtils:Windows:Update] Successfully updated credentials file:', credentialsPath); + } + + // Clear cached credentials to ensure fresh values are read + clearCredentialCache(configDir); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[CredentialUtils:Windows:Update] Failed to update credentials file:', errorMessage); + return { success: false, error: `File update failed: ${errorMessage}` }; + } +} + +/** + * Update credentials in Windows - writes to file FIRST (primary storage), then Credential Manager. + * + * Claude CLI on Windows primarily uses file-based storage (.credentials.json). + * We write to file first to ensure Claude CLI always has the latest tokens, + * then update Credential Manager for forward compatibility. + * + * IMPORTANT: The write order matters! If we wrote to Credential Manager first and file + * write failed, Claude CLI would read stale tokens from the file while Credential Manager + * has the new tokens - an inconsistent state. By writing to file first, we ensure the + * primary storage is always up-to-date. + * + * @param configDir - Config directory for the profile (undefined for default profile) + * @param credentials - New credentials to store + * @returns Result indicating success or failure + */ +function updateWindowsCredentials( + configDir: string | undefined, + credentials: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes?: string[]; + } +): UpdateCredentialsResult { + const isDebug = process.env.DEBUG === 'true'; + + // Write to file FIRST - this is what Claude CLI reads on Windows + const fileResult = updateWindowsFileCredentials(configDir, credentials); + if (!fileResult.success) { + // File write failed - don't proceed with Credential Manager to avoid inconsistent state + console.error('[CredentialUtils:Windows:Update] File update failed:', fileResult.error); + return fileResult; + } + + // File write succeeded - now update Credential Manager for forward compatibility + const psPath = findPowerShellPath(); + if (psPath) { + const credManagerResult = updateWindowsCredentialManagerCredentials(configDir, credentials); + if (!credManagerResult.success) { + // Credential Manager failed but file succeeded - this is acceptable + // Claude CLI will use the file, which has the latest tokens + if (isDebug) { + console.warn('[CredentialUtils:Windows:Update] Credential Manager update failed (file update succeeded):', credManagerResult.error); + } + } + } + + // Return success since file (primary storage) was updated successfully + return { success: true }; +} + /** * Update credentials in the platform-specific secure storage with new tokens. * Called after a successful OAuth token refresh to persist the new tokens. @@ -1767,7 +2200,7 @@ export function updateKeychainCredentials( } if (isWindows()) { - return updateWindowsCredentialManagerCredentials(configDir, credentials); + return updateWindowsCredentials(configDir, credentials); } return { success: false, error: `Unsupported platform: ${process.platform}` }; diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 0fb1e53f..3366e831 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -52,6 +52,7 @@ import { setupErrorLogging } from './app-logger'; import { initSentryMain } from './sentry'; import { preWarmToolCache } from './cli-tool-manager'; import { initializeClaudeProfileManager, getClaudeProfileManager } from './claude-profile-manager'; +import { isProfileAuthenticated } from './claude-profile/profile-utils'; import { isMacOS, isWindows } from './platform'; import { ptyDaemonClient } from './terminal/pty-daemon-client'; import type { AppSettings, AuthFailureInfo } from '../shared/types'; @@ -423,9 +424,22 @@ app.whenReady().then(() => { if (migratedProfileIds.length > 0) { console.warn('[main] Found migrated profiles that need re-authentication:', migratedProfileIds); - // If the active profile was migrated, show auth failure modal immediately - if (migratedProfileIds.includes(activeProfile.id)) { - // Wait for renderer to be ready before sending the event + // Check ALL migrated profiles for valid credentials, not just the active one + // This prevents stale migrated flags from triggering unnecessary re-auth prompts + // when the user switches to a different profile later + for (const profileId of migratedProfileIds) { + const profile = profileManager.getProfile(profileId); + if (profile && isProfileAuthenticated(profile)) { + // Credentials are valid - clear the migrated flag + console.warn('[main] Migrated profile has valid credentials via file fallback, clearing migrated flag:', profile.name); + profileManager.clearMigratedProfile(profileId); + } + } + + // Re-check if the active profile still needs re-auth after clearing valid ones + const remainingMigratedIds = profileManager.getMigratedProfileIds(); + if (remainingMigratedIds.includes(activeProfile.id)) { + // Active profile still needs re-auth - show the modal mainWindow.webContents.once('did-finish-load', () => { // Small delay to ensure stores are initialized setTimeout(() => { diff --git a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts index 7c8eeacf..0fde3dfd 100644 --- a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -23,7 +23,7 @@ import { isSecurePath } from '../utils/windows-paths'; import { isWindows, isMacOS, isLinux } from '../platform'; import { getClaudeProfileManager } from '../claude-profile-manager'; import { isValidConfigDir } from '../utils/config-path-validator'; -import { clearKeychainCache } from '../claude-profile/credential-utils'; +import { clearKeychainCache, getCredentialsFromKeychain } from '../claude-profile/credential-utils'; import { getUsageMonitor } from '../claude-profile/usage-monitor'; import semver from 'semver'; @@ -855,7 +855,7 @@ interface AuthCheckResult { * Checks multiple locations based on platform: * - macOS: .claude.json with oauthAccount containing emailAddress * - Linux: .credentials.json OR .claude.json (Claude uses different storage on Linux) - * - Windows: .claude.json with oauthAccount containing emailAddress + * - Windows: .claude.json, .credentials.json, AND Windows Credential Manager * * Also returns the full oauthAccount data so we can update the profile token. */ @@ -890,8 +890,8 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult { } } - // On Linux, also check .credentials.json (Claude CLI may store tokens here) - if (isLinux() && existsSync(credentialsJsonPath)) { + // On Linux and Windows, also check .credentials.json (Claude CLI stores tokens here) + if ((isLinux() || isWindows()) && existsSync(credentialsJsonPath)) { const content = readFileSync(credentialsJsonPath, 'utf-8'); const data = JSON.parse(content); @@ -928,6 +928,22 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult { } } + // On Windows, also check Windows Credential Manager as a fallback + // Credentials may be stored ONLY in Credential Manager (not in files) + if (isWindows()) { + const keychainCreds = getCredentialsFromKeychain(expandedConfigDir); + if (keychainCreds.token) { + return { + authenticated: true, + email: keychainCreds.email || undefined, + oauthAccount: { + accessToken: keychainCreds.token, + emailAddress: keychainCreds.email || undefined + } + }; + } + } + return { authenticated: false }; } catch (error) { console.error('[Claude Code] Error checking authentication:', error);