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:
@@ -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}` };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user