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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
Co-authored-by: AndyMik90 <andre@mikalsenutvikling.no>
This commit is contained in:
kaigler
2026-01-30 04:39:21 -06:00
committed by GitHub
parent 900dd43600
commit 1e19971679
5 changed files with 832 additions and 211 deletions
@@ -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:', {
@@ -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();
});
});
@@ -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<T extends PlatformCredentials>(
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}` };
+17 -3
View File
@@ -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(() => {
@@ -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);