fix: read OAuth credentials from macOS Keychain (Claude Code 2.x) (#50)

* fix: read OAuth credentials from macOS Keychain (Claude Code 2.x)

Claude Code 2.x stores OAuth credentials in the macOS Keychain under
"Claude Code-credentials" instead of ~/.claude/.credentials.json.

This caused the usage tracker to silently fail on macOS since the
credentials file doesn't exist.

Changes:
- Add readKeychainCredentials() to read from macOS Keychain via security CLI
- Add 1.5s timeout to prevent HUD hangs if Keychain is slow
- Fall back to file-based credentials if Keychain lacks subscriptionType
- Extract parseCredentialsData() to share validation logic
- Add readKeychain to UsageApiDeps for test isolation
- Add test for Keychain-to-file fallback behavior

The credential lookup order is now:
1. macOS Keychain (Claude Code 2.x on darwin)
2. File-based ~/.claude/.credentials.json (older versions, non-macOS)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address review feedback for keychain credentials

- Increase keychain timeout from 1.5s to 5s to allow time for macOS
  permission prompts (user needs to click "Allow")
- Fix fallback logic: always use keychain token (authoritative) when
  present, supplement subscriptionType from file if needed
- Add happy-path test for complete keychain credentials
- Add test verifying keychain token is used even when subscriptionType
  comes from file

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* security: harden keychain credential reading

- Use execFileSync with absolute path (/usr/bin/security) instead of
  execSync with shell - prevents PATH hijacking and shell injection
- Sanitize debug logging to only log error.message, not full error
  object which may contain stdout/stderr with credential data
- Add 60s backoff on keychain failures to prevent re-prompting user
  on every render cycle after a timeout/denial

Addresses security review feedback from Codex.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Jarrod Watts <jarrod@cubelabs.xyz>
This commit is contained in:
Rareș T. Gosman
2026-01-13 19:26:16 -05:00
committed by GitHub
parent 75128e8624
commit c36738d63c
2 changed files with 235 additions and 27 deletions

View File

@@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as https from 'https';
import { execFileSync } from 'child_process';
import type { UsageData } from './types.js';
import { createDebug } from './debug.js';
@@ -34,6 +35,8 @@ interface UsageApiResponse {
// File-based cache (HUD runs as new process each render, so in-memory cache won't persist)
const CACHE_TTL_MS = 60_000; // 60 seconds
const CACHE_FAILURE_TTL_MS = 15_000; // 15 seconds for failed requests
const KEYCHAIN_TIMEOUT_MS = 5000;
const KEYCHAIN_BACKOFF_MS = 60_000; // Backoff on keychain failures to avoid re-prompting
interface CacheFile {
data: UsageData;
@@ -93,12 +96,14 @@ export type UsageApiDeps = {
homeDir: () => string;
fetchApi: (accessToken: string) => Promise<UsageApiResponse | null>;
now: () => number;
readKeychain: (now: number, homeDir: string) => { accessToken: string; subscriptionType: string } | null;
};
const defaultDeps: UsageApiDeps = {
homeDir: () => os.homedir(),
fetchApi: fetchUsageApi,
now: () => Date.now(),
readKeychain: readKeychainCredentials,
};
/**
@@ -121,7 +126,7 @@ export async function getUsage(overrides: Partial<UsageApiDeps> = {}): Promise<U
}
try {
const credentials = readCredentials(homeDir, now);
const credentials = readCredentials(homeDir, now, deps.readKeychain);
if (!credentials) {
return null;
}
@@ -177,7 +182,94 @@ export async function getUsage(overrides: Partial<UsageApiDeps> = {}): Promise<U
}
}
function readCredentials(homeDir: string, now: number): { accessToken: string; subscriptionType: string } | null {
/**
* Get path for keychain failure backoff cache.
* Separate from usage cache to track keychain-specific failures.
*/
function getKeychainBackoffPath(homeDir: string): string {
return path.join(homeDir, '.claude', 'plugins', 'claude-hud', '.keychain-backoff');
}
/**
* Check if we're in keychain backoff period (recent failure/timeout).
* Prevents re-prompting user on every render cycle.
*/
function isKeychainBackoff(homeDir: string, now: number): boolean {
try {
const backoffPath = getKeychainBackoffPath(homeDir);
if (!fs.existsSync(backoffPath)) return false;
const timestamp = parseInt(fs.readFileSync(backoffPath, 'utf8'), 10);
return now - timestamp < KEYCHAIN_BACKOFF_MS;
} catch {
return false;
}
}
/**
* Record keychain failure for backoff.
*/
function recordKeychainFailure(homeDir: string, now: number): void {
try {
const backoffPath = getKeychainBackoffPath(homeDir);
const dir = path.dirname(backoffPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(backoffPath, String(now), 'utf8');
} catch {
// Ignore write failures
}
}
/**
* Read credentials from macOS Keychain.
* Claude Code 2.x stores OAuth credentials in the macOS Keychain under "Claude Code-credentials".
* Returns null if not on macOS or credentials not found.
*
* Security: Uses execFileSync with absolute path to avoid shell injection and PATH hijacking.
*/
function readKeychainCredentials(now: number, homeDir: string): { accessToken: string; subscriptionType: string } | null {
// Only available on macOS
if (process.platform !== 'darwin') {
return null;
}
// Check backoff to avoid re-prompting on every render after a failure
if (isKeychainBackoff(homeDir, now)) {
debug('Keychain in backoff period, skipping');
return null;
}
try {
// Read from macOS Keychain using security command
// Security: Use execFileSync with absolute path and args array (no shell)
const keychainData = execFileSync(
'/usr/bin/security',
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: KEYCHAIN_TIMEOUT_MS }
).trim();
if (!keychainData) {
return null;
}
const data: CredentialsFile = JSON.parse(keychainData);
return parseCredentialsData(data, now);
} catch (error) {
// Security: Only log error message, not full error object (may contain stdout/stderr with tokens)
const message = error instanceof Error ? error.message : 'unknown error';
debug('Failed to read from macOS Keychain:', message);
// Record failure for backoff to avoid re-prompting
recordKeychainFailure(homeDir, now);
return null;
}
}
/**
* Read credentials from file (legacy method).
* Older versions of Claude Code stored credentials in ~/.claude/.credentials.json
*/
function readFileCredentials(homeDir: string, now: number): { accessToken: string; subscriptionType: string } | null {
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
if (!fs.existsSync(credentialsPath)) {
@@ -187,28 +279,78 @@ function readCredentials(homeDir: string, now: number): { accessToken: string; s
try {
const content = fs.readFileSync(credentialsPath, 'utf8');
const data: CredentialsFile = JSON.parse(content);
const accessToken = data.claudeAiOauth?.accessToken;
const subscriptionType = data.claudeAiOauth?.subscriptionType ?? '';
if (!accessToken) {
return null;
}
// Check if token is expired (expiresAt is Unix ms timestamp)
// Use != null to handle expiresAt=0 correctly (would be expired)
const expiresAt = data.claudeAiOauth?.expiresAt;
if (expiresAt != null && expiresAt <= now) {
return null;
}
return { accessToken, subscriptionType };
return parseCredentialsData(data, now);
} catch (error) {
debug('Failed to read credentials:', error);
debug('Failed to read credentials file:', error);
return null;
}
}
/**
* Parse and validate credentials data from either Keychain or file.
*/
function parseCredentialsData(data: CredentialsFile, now: number): { accessToken: string; subscriptionType: string } | null {
const accessToken = data.claudeAiOauth?.accessToken;
const subscriptionType = data.claudeAiOauth?.subscriptionType ?? '';
if (!accessToken) {
return null;
}
// Check if token is expired (expiresAt is Unix ms timestamp)
// Use != null to handle expiresAt=0 correctly (would be expired)
const expiresAt = data.claudeAiOauth?.expiresAt;
if (expiresAt != null && expiresAt <= now) {
debug('OAuth token expired');
return null;
}
return { accessToken, subscriptionType };
}
/**
* Read OAuth credentials, trying macOS Keychain first (Claude Code 2.x),
* then falling back to file-based credentials (older versions).
*
* Token priority: Keychain token is authoritative (Claude Code 2.x stores current token there).
* SubscriptionType: Can be supplemented from file if keychain lacks it (display-only field).
*/
function readCredentials(
homeDir: string,
now: number,
readKeychain: (now: number, homeDir: string) => { accessToken: string; subscriptionType: string } | null
): { accessToken: string; subscriptionType: string } | null {
// Try macOS Keychain first (Claude Code 2.x)
const keychainCreds = readKeychain(now, homeDir);
if (keychainCreds) {
if (keychainCreds.subscriptionType) {
debug('Using credentials from macOS Keychain');
return keychainCreds;
}
// Keychain has token but no subscriptionType - try to supplement from file
const fileCreds = readFileCredentials(homeDir, now);
if (fileCreds?.subscriptionType) {
debug('Using keychain token with file subscriptionType');
return {
accessToken: keychainCreds.accessToken,
subscriptionType: fileCreds.subscriptionType,
};
}
// No subscriptionType available - use keychain token anyway
debug('Using keychain token without subscriptionType');
return keychainCreds;
}
// Fall back to file-based credentials (older versions or non-macOS)
const fileCreds = readFileCredentials(homeDir, now);
if (fileCreds) {
debug('Using credentials from file');
return fileCreds;
}
return null;
}
function getPlanName(subscriptionType: string): string | null {
const lower = subscriptionType.toLowerCase();
if (lower.includes('max')) return 'Max';

View File

@@ -64,6 +64,7 @@ describe('getUsage', () => {
return null;
},
now: () => 1000,
readKeychain: () => null, // Disable Keychain for tests
});
assert.equal(result, null);
@@ -80,6 +81,7 @@ describe('getUsage', () => {
return buildApiResponse();
},
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result, null);
@@ -96,6 +98,7 @@ describe('getUsage', () => {
return buildApiResponse();
},
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result, null);
@@ -112,12 +115,70 @@ describe('getUsage', () => {
return buildApiResponse();
},
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result, null);
assert.equal(fetchCalls, 0);
});
test('uses complete keychain credentials without falling back to file', async () => {
// No file credentials - keychain should be sufficient
let usedToken = null;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async (token) => {
usedToken = token;
return buildApiResponse();
},
now: () => 1000,
readKeychain: () => ({ accessToken: 'keychain-token', subscriptionType: 'claude_max_2024' }),
});
assert.equal(usedToken, 'keychain-token');
assert.equal(result?.planName, 'Max');
});
test('uses keychain token with file subscriptionType when keychain lacks subscriptionType', async () => {
await writeCredentials(tempHome, buildCredentials({
accessToken: 'old-file-token',
subscriptionType: 'claude_pro_2024',
}));
let usedToken = null;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async (token) => {
usedToken = token;
return buildApiResponse();
},
now: () => 1000,
readKeychain: () => ({ accessToken: 'keychain-token', subscriptionType: '' }),
});
// Must use keychain token (authoritative), but can use file's subscriptionType
assert.equal(usedToken, 'keychain-token', 'should use keychain token, not file token');
assert.equal(result?.planName, 'Pro');
});
test('returns null when keychain has token but no subscriptionType anywhere', async () => {
// No file credentials, keychain has no subscriptionType
// This user is treated as an API user (no usage limits)
let fetchCalls = 0;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => {
fetchCalls += 1;
return buildApiResponse();
},
now: () => 1000,
readKeychain: () => ({ accessToken: 'keychain-token', subscriptionType: '' }),
});
// No subscriptionType means API user, returns null without calling API
assert.equal(result, null);
assert.equal(fetchCalls, 0);
});
test('parses plan name and usage data', async () => {
await writeCredentials(tempHome, buildCredentials({ subscriptionType: 'claude_pro_2024' }));
let fetchCalls = 0;
@@ -128,6 +189,7 @@ describe('getUsage', () => {
return buildApiResponse();
},
now: () => 1000,
readKeychain: () => null,
});
assert.equal(fetchCalls, 1);
@@ -142,6 +204,7 @@ describe('getUsage', () => {
homeDir: () => tempHome,
fetchApi: async () => buildApiResponse(),
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result?.planName, 'Team');
@@ -160,6 +223,7 @@ describe('getUsage', () => {
homeDir: () => tempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(first?.apiUnavailable, true);
assert.equal(fetchCalls, 1);
@@ -169,6 +233,7 @@ describe('getUsage', () => {
homeDir: () => tempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(cached?.apiUnavailable, true);
assert.equal(fetchCalls, 1);
@@ -178,6 +243,7 @@ describe('getUsage', () => {
homeDir: () => tempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(second?.apiUnavailable, true);
assert.equal(fetchCalls, 2);
@@ -206,15 +272,15 @@ describe('getUsage caching behavior', () => {
return buildApiResponse();
};
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue });
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 1);
nowValue += 30_000;
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue });
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 1);
nowValue += 31_000;
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue });
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 2);
});
@@ -227,15 +293,15 @@ describe('getUsage caching behavior', () => {
return null;
};
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue });
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 1);
nowValue += 10_000;
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue });
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 1);
nowValue += 6_000;
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue });
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 2);
});
@@ -247,11 +313,11 @@ describe('getUsage caching behavior', () => {
return buildApiResponse();
};
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => 1000 });
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => 1000, readKeychain: () => null });
assert.equal(fetchCalls, 1);
clearCache(tempHome);
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => 2000 });
await getUsage({ homeDir: () => tempHome, fetchApi, now: () => 2000, readKeychain: () => null });
assert.equal(fetchCalls, 2);
});
});