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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]> Co-authored-by: Andy <[email protected]> Co-authored-by: AndyMik90 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
Andy
AndyMik90
parent
900dd43600
commit
1e19971679
@@ -43,7 +43,7 @@ import {
|
|||||||
shouldProactivelySwitch as shouldProactivelySwitchImpl,
|
shouldProactivelySwitch as shouldProactivelySwitchImpl,
|
||||||
getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl
|
getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl
|
||||||
} from './claude-profile/profile-scorer';
|
} from './claude-profile/profile-scorer';
|
||||||
import { getCredentialsFromKeychain } from './claude-profile/credential-utils';
|
import { getCredentialsFromKeychain, normalizeWindowsPath } from './claude-profile/credential-utils';
|
||||||
import {
|
import {
|
||||||
CLAUDE_PROFILES_DIR,
|
CLAUDE_PROFILES_DIR,
|
||||||
generateProfileId as generateProfileIdImpl,
|
generateProfileId as generateProfileIdImpl,
|
||||||
@@ -498,9 +498,12 @@ export class ClaudeProfileManager {
|
|||||||
// This prevents interference with external Claude Code CLI usage
|
// This prevents interference with external Claude Code CLI usage
|
||||||
if (profile?.configDir) {
|
if (profile?.configDir) {
|
||||||
// Expand ~ to home directory for the environment variable
|
// Expand ~ to home directory for the environment variable
|
||||||
const expandedConfigDir = profile.configDir.startsWith('~')
|
const expandedConfigDir = normalizeWindowsPath(
|
||||||
? profile.configDir.replace(/^~/, homedir())
|
profile.configDir.startsWith('~')
|
||||||
: profile.configDir;
|
? profile.configDir.replace(/^~/, homedir())
|
||||||
|
: profile.configDir
|
||||||
|
);
|
||||||
|
|
||||||
env.CLAUDE_CONFIG_DIR = expandedConfigDir;
|
env.CLAUDE_CONFIG_DIR = expandedConfigDir;
|
||||||
if (process.env.DEBUG === 'true') {
|
if (process.env.DEBUG === 'true') {
|
||||||
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
|
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
|
// Expand ~ to home directory for the environment variable
|
||||||
const expandedConfigDir = profile.configDir.startsWith('~')
|
const expandedConfigDir = normalizeWindowsPath(
|
||||||
? profile.configDir.replace(/^~/, require('os').homedir())
|
profile.configDir.startsWith('~')
|
||||||
: profile.configDir;
|
? profile.configDir.replace(/^~/, require('os').homedir())
|
||||||
|
: profile.configDir
|
||||||
|
);
|
||||||
|
|
||||||
if (process.env.DEBUG === 'true') {
|
if (process.env.DEBUG === 'true') {
|
||||||
console.warn('[ClaudeProfileManager] getProfileEnv:', {
|
console.warn('[ClaudeProfileManager] getProfileEnv:', {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
getKeychainServiceName,
|
getKeychainServiceName,
|
||||||
getWindowsCredentialTarget,
|
getWindowsCredentialTarget,
|
||||||
getCredentialsFromKeychain,
|
getCredentialsFromKeychain,
|
||||||
|
getFullCredentialsFromKeychain,
|
||||||
getCredentials,
|
getCredentials,
|
||||||
clearKeychainCache,
|
clearKeychainCache,
|
||||||
clearCredentialCache,
|
clearCredentialCache,
|
||||||
@@ -358,19 +359,24 @@ describe('credential-utils', () => {
|
|||||||
vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser');
|
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);
|
vi.mocked(existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
const result = getCredentialsFromKeychain();
|
const result = getCredentialsFromKeychain();
|
||||||
|
|
||||||
expect(result.token).toBeNull();
|
expect(result.token).toBeNull();
|
||||||
expect(result.email).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', () => {
|
it('should return credentials from Windows Credential Manager when file is empty', () => {
|
||||||
// Mock PowerShell path found
|
// Mock PowerShell path found, but credentials file doesn't exist
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
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({
|
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
|
||||||
claudeAiOauth: {
|
claudeAiOauth: {
|
||||||
accessToken: 'sk-ant-windows-token-789',
|
accessToken: 'sk-ant-windows-token-789',
|
||||||
@@ -384,9 +390,33 @@ describe('credential-utils', () => {
|
|||||||
expect(result.email).toBe('[email protected]');
|
expect(result.email).toBe('[email protected]');
|
||||||
});
|
});
|
||||||
|
|
||||||
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(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: '[email protected]',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = getCredentialsFromKeychain();
|
||||||
|
|
||||||
|
expect(result.token).toBe('sk-ant-file-fallback-token');
|
||||||
|
expect(result.email).toBe('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
const result = getCredentialsFromKeychain();
|
||||||
|
|
||||||
@@ -394,14 +424,137 @@ describe('credential-utils', () => {
|
|||||||
expect(result.email).toBeNull();
|
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(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: '[email protected]',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const result = getCredentialsFromKeychain();
|
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('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
|
||||||
|
claudeAiOauth: {
|
||||||
|
accessToken: 'sk-ant-credman-token',
|
||||||
|
email: '[email protected]',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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('[email protected]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
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('[email protected]');
|
||||||
|
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: '[email protected]',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = getFullCredentialsFromKeychain();
|
||||||
|
|
||||||
|
expect(result.token).toBe('sk-ant-credman-full-token');
|
||||||
|
expect(result.refreshToken).toBe('credman-refresh');
|
||||||
|
expect(result.email).toBe('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
|
||||||
|
claudeAiOauth: {
|
||||||
|
accessToken: 'sk-ant-credman-full-token',
|
||||||
|
refreshToken: 'credman-refresh',
|
||||||
|
expiresAt: 1800000000000, // Later expiry
|
||||||
|
email: '[email protected]',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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('[email protected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
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.token).toBeNull();
|
||||||
expect(result.email).toBeNull();
|
expect(result.refreshToken).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
|
|
||||||
import { execFileSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
import { createHash } from 'crypto';
|
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 { homedir, userInfo } from 'os';
|
||||||
import { join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { isMacOS, isWindows, isLinux } from '../platform';
|
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);
|
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).
|
* 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
|
// Normalize the configDir: expand ~ and resolve to absolute path
|
||||||
const normalizedConfigDir = configDir.startsWith('~')
|
const normalizedConfigDir = normalizeWindowsPath(
|
||||||
? join(homedir(), configDir.slice(1))
|
configDir.startsWith('~')
|
||||||
: configDir;
|
? join(homedir(), configDir.slice(1))
|
||||||
|
: configDir
|
||||||
|
);
|
||||||
|
|
||||||
// ALL profiles now use hash-based keychain entries for isolation
|
// ALL profiles now use hash-based keychain entries for isolation
|
||||||
// This prevents interference with external Claude Code CLI
|
// This prevents interference with external Claude Code CLI
|
||||||
@@ -385,6 +409,195 @@ function parseCredentialJson<T extends PlatformCredentials>(
|
|||||||
return extractFn(data);
|
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
|
// macOS Keychain Implementation
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -653,98 +866,7 @@ function getLinuxCredentialsPath(configDir?: string): string {
|
|||||||
function getCredentialsFromLinuxFile(configDir?: string, forceRefresh = false): PlatformCredentials {
|
function getCredentialsFromLinuxFile(configDir?: string, forceRefresh = false): PlatformCredentials {
|
||||||
const credentialsPath = getLinuxCredentialsPath(configDir);
|
const credentialsPath = getLinuxCredentialsPath(configDir);
|
||||||
const cacheKey = `linux:${credentialsPath}`;
|
const cacheKey = `linux:${credentialsPath}`;
|
||||||
const isDebug = process.env.DEBUG === 'true';
|
return getCredentialsFromFile(credentialsPath, cacheKey, 'Linux', forceRefresh);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -804,29 +926,60 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef
|
|||||||
try {
|
try {
|
||||||
// PowerShell script to read from Credential Manager
|
// PowerShell script to read from Credential Manager
|
||||||
// Uses the Windows Credential Manager API via .NET
|
// 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 = `
|
const psScript = `
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
|
||||||
|
|
||||||
# Use CredRead from advapi32.dll to read generic credentials
|
# Define the CREDENTIAL struct with IntPtr for string fields (required for CredRead marshaling)
|
||||||
$sig = @'
|
# See comment above for why this differs from the CredWrite struct definition.
|
||||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
Add-Type -TypeDefinition @'
|
||||||
public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr);
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
[DllImport("advapi32.dll", SetLastError = true)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
public static extern bool CredFree(IntPtr cred);
|
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
|
$credPtr = [IntPtr]::Zero
|
||||||
# CRED_TYPE_GENERIC = 1
|
# 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) {
|
if ($success) {
|
||||||
try {
|
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
|
$blobSize = $cred.CredentialBlobSize
|
||||||
if ($blobSize -gt 0) {
|
if ($blobSize -gt 0) {
|
||||||
$blob = [byte[]]::new($blobSize)
|
$blob = [byte[]]::new($blobSize)
|
||||||
@@ -835,7 +988,7 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef
|
|||||||
Write-Output $password
|
Write-Output $password
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
[Win32.Credential]::CredFree($credPtr) | Out-Null
|
[Win32.CredApi]::CredFree($credPtr) | Out-Null
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
# Credential not found - this is expected if user hasn't authenticated
|
# Credential not found - this is expected if user hasn't authenticated
|
||||||
@@ -911,6 +1064,67 @@ function findPowerShellPath(): string | null {
|
|||||||
return 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
|
// Cross-Platform Public API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -920,8 +1134,8 @@ function findPowerShellPath(): string | null {
|
|||||||
* secure storage.
|
* secure storage.
|
||||||
*
|
*
|
||||||
* - macOS: Reads from Keychain
|
* - macOS: Reads from Keychain
|
||||||
* - Linux: Reads from .credentials.json file
|
* - Linux: Tries Secret Service (via secret-tool), falls back to .credentials.json
|
||||||
* - Windows: Reads from Windows Credential Manager
|
* - Windows: Checks both .credentials.json and Credential Manager, prefers file
|
||||||
*
|
*
|
||||||
* For default profile: reads from "Claude Code-credentials" or default config dir
|
* For default profile: reads from "Claude Code-credentials" or default config dir
|
||||||
* For custom profiles: uses SHA256(configDir).slice(0,8) hash suffix
|
* For custom profiles: uses SHA256(configDir).slice(0,8) hash suffix
|
||||||
@@ -942,7 +1156,7 @@ export function getCredentialsFromKeychain(configDir?: string, forceRefresh = fa
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isWindows()) {
|
if (isWindows()) {
|
||||||
return getCredentialsFromWindowsCredentialManager(configDir, forceRefresh);
|
return getCredentialsFromWindows(configDir, forceRefresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown platform - return empty
|
// Unknown platform - return empty
|
||||||
@@ -967,11 +1181,13 @@ export function clearKeychainCache(configDir?: string): void {
|
|||||||
const linuxSecretKey = `linux-secret:${getSecretServiceAttribute(configDir)}`;
|
const linuxSecretKey = `linux-secret:${getSecretServiceAttribute(configDir)}`;
|
||||||
const linuxFileKey = `linux:${getLinuxCredentialsPath(configDir)}`;
|
const linuxFileKey = `linux:${getLinuxCredentialsPath(configDir)}`;
|
||||||
const windowsKey = `windows:${getWindowsCredentialTarget(configDir)}`;
|
const windowsKey = `windows:${getWindowsCredentialTarget(configDir)}`;
|
||||||
|
const windowsFileKey = `windows-file:${getWindowsCredentialsPath(configDir)}`;
|
||||||
|
|
||||||
credentialCache.delete(macOSKey);
|
credentialCache.delete(macOSKey);
|
||||||
credentialCache.delete(linuxSecretKey);
|
credentialCache.delete(linuxSecretKey);
|
||||||
credentialCache.delete(linuxFileKey);
|
credentialCache.delete(linuxFileKey);
|
||||||
credentialCache.delete(windowsKey);
|
credentialCache.delete(windowsKey);
|
||||||
|
credentialCache.delete(windowsFileKey);
|
||||||
} else {
|
} else {
|
||||||
credentialCache.clear();
|
credentialCache.clear();
|
||||||
}
|
}
|
||||||
@@ -1136,67 +1352,7 @@ function getFullCredentialsFromLinux(configDir?: string): FullOAuthCredentials {
|
|||||||
*/
|
*/
|
||||||
function getFullCredentialsFromLinuxFile(configDir?: string): FullOAuthCredentials {
|
function getFullCredentialsFromLinuxFile(configDir?: string): FullOAuthCredentials {
|
||||||
const credentialsPath = getLinuxCredentialsPath(configDir);
|
const credentialsPath = getLinuxCredentialsPath(configDir);
|
||||||
const isDebug = process.env.DEBUG === 'true';
|
return getFullCredentialsFromFile(credentialsPath, 'Linux:Full');
|
||||||
|
|
||||||
// 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}` };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1223,29 +1379,51 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// PowerShell script to read from Credential Manager (same as basic credentials)
|
// 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 = `
|
const psScript = `
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
|
||||||
|
|
||||||
# Use CredRead from advapi32.dll to read generic credentials
|
# Define the CREDENTIAL struct with IntPtr for string fields (required for marshaling)
|
||||||
$sig = @'
|
Add-Type -TypeDefinition @'
|
||||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
using System;
|
||||||
public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr);
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
[DllImport("advapi32.dll", SetLastError = true)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
public static extern bool CredFree(IntPtr cred);
|
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
|
$credPtr = [IntPtr]::Zero
|
||||||
# CRED_TYPE_GENERIC = 1
|
# 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) {
|
if ($success) {
|
||||||
try {
|
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
|
$blobSize = $cred.CredentialBlobSize
|
||||||
if ($blobSize -gt 0) {
|
if ($blobSize -gt 0) {
|
||||||
$blob = [byte[]]::new($blobSize)
|
$blob = [byte[]]::new($blobSize)
|
||||||
@@ -1254,7 +1432,7 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful
|
|||||||
Write-Output $password
|
Write-Output $password
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
[Win32.Credential]::CredFree($credPtr) | Out-Null
|
[Win32.CredApi]::CredFree($credPtr) | Out-Null
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
# Credential not found - this is expected if user hasn't authenticated
|
# 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.
|
* 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
|
* This is an extended version of getCredentialsFromKeychain that returns all credential data
|
||||||
@@ -1324,7 +1552,7 @@ export function getFullCredentialsFromKeychain(configDir?: string): FullOAuthCre
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isWindows()) {
|
if (isWindows()) {
|
||||||
return getFullCredentialsFromWindowsCredentialManager(configDir);
|
return getFullCredentialsFromWindows(configDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown platform - return empty
|
// Unknown platform - return empty
|
||||||
@@ -1589,6 +1817,12 @@ function updateLinuxFileCredentials(
|
|||||||
|
|
||||||
const credentialsJson = JSON.stringify(newCredentialData, null, 2);
|
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)
|
// Write to file with secure permissions (0600)
|
||||||
writeFileSync(credentialsPath, credentialsJson, { mode: 0o600, encoding: 'utf-8' });
|
writeFileSync(credentialsPath, credentialsJson, { mode: 0o600, encoding: 'utf-8' });
|
||||||
|
|
||||||
@@ -1658,10 +1892,18 @@ function updateWindowsCredentialManagerCredentials(
|
|||||||
const base64Json = encodeBase64ForPowerShell(credentialsJson);
|
const base64Json = encodeBase64ForPowerShell(credentialsJson);
|
||||||
|
|
||||||
// PowerShell script to write to Credential Manager
|
// 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 = `
|
const psScript = `
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
# Use CredWrite from advapi32.dll to write generic credentials
|
# Use CredWrite from advapi32.dll to write generic credentials
|
||||||
|
# This struct uses string types (auto-marshaled) unlike CredRead which needs IntPtr.
|
||||||
$sig = @'
|
$sig = @'
|
||||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
public struct CREDENTIAL {
|
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.
|
* Update credentials in the platform-specific secure storage with new tokens.
|
||||||
* Called after a successful OAuth token refresh to persist the new tokens.
|
* Called after a successful OAuth token refresh to persist the new tokens.
|
||||||
@@ -1767,7 +2200,7 @@ export function updateKeychainCredentials(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isWindows()) {
|
if (isWindows()) {
|
||||||
return updateWindowsCredentialManagerCredentials(configDir, credentials);
|
return updateWindowsCredentials(configDir, credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, error: `Unsupported platform: ${process.platform}` };
|
return { success: false, error: `Unsupported platform: ${process.platform}` };
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { setupErrorLogging } from './app-logger';
|
|||||||
import { initSentryMain } from './sentry';
|
import { initSentryMain } from './sentry';
|
||||||
import { preWarmToolCache } from './cli-tool-manager';
|
import { preWarmToolCache } from './cli-tool-manager';
|
||||||
import { initializeClaudeProfileManager, getClaudeProfileManager } from './claude-profile-manager';
|
import { initializeClaudeProfileManager, getClaudeProfileManager } from './claude-profile-manager';
|
||||||
|
import { isProfileAuthenticated } from './claude-profile/profile-utils';
|
||||||
import { isMacOS, isWindows } from './platform';
|
import { isMacOS, isWindows } from './platform';
|
||||||
import { ptyDaemonClient } from './terminal/pty-daemon-client';
|
import { ptyDaemonClient } from './terminal/pty-daemon-client';
|
||||||
import type { AppSettings, AuthFailureInfo } from '../shared/types';
|
import type { AppSettings, AuthFailureInfo } from '../shared/types';
|
||||||
@@ -423,9 +424,22 @@ app.whenReady().then(() => {
|
|||||||
if (migratedProfileIds.length > 0) {
|
if (migratedProfileIds.length > 0) {
|
||||||
console.warn('[main] Found migrated profiles that need re-authentication:', migratedProfileIds);
|
console.warn('[main] Found migrated profiles that need re-authentication:', migratedProfileIds);
|
||||||
|
|
||||||
// If the active profile was migrated, show auth failure modal immediately
|
// Check ALL migrated profiles for valid credentials, not just the active one
|
||||||
if (migratedProfileIds.includes(activeProfile.id)) {
|
// This prevents stale migrated flags from triggering unnecessary re-auth prompts
|
||||||
// Wait for renderer to be ready before sending the event
|
// 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', () => {
|
mainWindow.webContents.once('did-finish-load', () => {
|
||||||
// Small delay to ensure stores are initialized
|
// Small delay to ensure stores are initialized
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { isSecurePath } from '../utils/windows-paths';
|
|||||||
import { isWindows, isMacOS, isLinux } from '../platform';
|
import { isWindows, isMacOS, isLinux } from '../platform';
|
||||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
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 { getUsageMonitor } from '../claude-profile/usage-monitor';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
|
|
||||||
@@ -855,7 +855,7 @@ interface AuthCheckResult {
|
|||||||
* Checks multiple locations based on platform:
|
* Checks multiple locations based on platform:
|
||||||
* - macOS: .claude.json with oauthAccount containing emailAddress
|
* - macOS: .claude.json with oauthAccount containing emailAddress
|
||||||
* - Linux: .credentials.json OR .claude.json (Claude uses different storage on Linux)
|
* - 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.
|
* 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)
|
// On Linux and Windows, also check .credentials.json (Claude CLI stores tokens here)
|
||||||
if (isLinux() && existsSync(credentialsJsonPath)) {
|
if ((isLinux() || isWindows()) && existsSync(credentialsJsonPath)) {
|
||||||
const content = readFileSync(credentialsJsonPath, 'utf-8');
|
const content = readFileSync(credentialsJsonPath, 'utf-8');
|
||||||
const data = JSON.parse(content);
|
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 };
|
return { authenticated: false };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Claude Code] Error checking authentication:', error);
|
console.error('[Claude Code] Error checking authentication:', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user