mirror of
https://github.com/jarrodwatts/claude-hud.git
synced 2026-04-16 14:52:41 +00:00
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:
180
src/usage-api.ts
180
src/usage-api.ts
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user