Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion src/utils/__tests__/usage-token.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Mock } from 'vitest';
import {
Expand Down Expand Up @@ -34,7 +35,7 @@ function encodeAsciiAsHex(value: string): string {
return Buffer.from(value, 'utf8').toString('hex');
}

function makeKeychainBlock(service: string, modifiedAt?: { raw?: string; quoted?: string }): string {
function makeKeychainBlock(service: string, modifiedAt?: { raw?: string; quoted?: string }, account?: string): string {
const lines = [
'keychain: "/Users/example/Library/Keychains/login.keychain-db"',
'version: 512',
Expand All @@ -43,6 +44,10 @@ function makeKeychainBlock(service: string, modifiedAt?: { raw?: string; quoted?
` "svce"<blob>="${service}"`
];

if (account !== undefined) {
lines.push(` "acct"<blob>="${account}"`);
}

if (modifiedAt?.raw && modifiedAt.quoted) {
lines.push(` "mdat"<timedate>=0x${modifiedAt.raw} "${modifiedAt.quoted}"`);
} else if (modifiedAt?.raw) {
Expand Down Expand Up @@ -111,6 +116,7 @@ describe('getUsageToken', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(claudeSettings, 'getClaudeConfigDir').mockReturnValue('/fake/claude');
vi.spyOn(os, 'userInfo').mockReturnValue({ username: 'testuser' } as unknown as os.UserInfo<string>);
mockedExecFileSync.mockReset();
});

Expand Down Expand Up @@ -138,6 +144,64 @@ describe('getUsageToken', () => {
]);
});

it('reads the OS-username account when the service-only lookup returns an item without an OAuth token', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
mockCredentialsFile();
mockedExecFileSync.mockImplementation((command: string, args?: string[]) => {
if (command !== 'security' || !args) {
throw new Error(`Unexpected security args: ${args?.join(' ')}`);
}

const isCredentialsService = args[0] === 'find-generic-password' && args[2] === 'Claude Code-credentials';
if (isCredentialsService && args.includes('-a') && args[args.indexOf('-a') + 1] === 'testuser') {
return makeTokenPayload('account-token');
}

if (isCredentialsService) {
// Service-only match: an unrelated account's MCP-only item.
return JSON.stringify({ mcpOAuth: { some: 'value' } });
}

throw new Error(`Unexpected security args: ${args.join(' ')}`);
});

expect(getUsageToken()).toBe('account-token');
expect(getSecurityCallLog()).toEqual([
'find-generic-password -s Claude Code-credentials -w',
'find-generic-password -s Claude Code-credentials -a testuser -w'
]);
});

it('skips the OS-username account read when the username cannot be resolved', () => {
// os.userInfo() throws when the uid has no passwd entry (some containers).
vi.spyOn(os, 'userInfo').mockImplementation(() => {
throw new Error('no passwd entry');
});
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
mockCredentialsFile(makeTokenPayload('file-token'));
mockedExecFileSync.mockImplementation((command: string, args?: string[]) => {
if (command !== 'security' || !args) {
throw new Error(`Unexpected security args: ${args?.join(' ')}`);
}

if (args[0] === 'find-generic-password' && args[2] === 'Claude Code-credentials') {
throw new Error('missing exact credential');
}

if (args[0] === 'dump-keychain') {
return '';
}

throw new Error(`Unexpected security args: ${args.join(' ')}`);
});

expect(getUsageToken()).toBe('file-token');
expect(getSecurityCallLog()).toEqual([
'find-generic-password -s Claude Code-credentials -w',
'dump-keychain'
]);
});

it('tries the newest hashed macOS keychain candidate after an exact miss', () => {
const dump = [
makeKeychainBlock('Claude Code-credentials-old', { quoted: '20240201010101Z' }),
Expand Down Expand Up @@ -170,9 +234,11 @@ describe('getUsageToken', () => {
expect(getUsageToken()).toBe('hashed-token');
expect(getSecurityCallLog()).toEqual([
'find-generic-password -s Claude Code-credentials -w',
'find-generic-password -s Claude Code-credentials -a testuser -w',
'dump-keychain',
'find-generic-password -s Claude Code-credentials-new -w',
'find-generic-password -s Claude Code-credentials -w',
'find-generic-password -s Claude Code-credentials -a testuser -w',
'dump-keychain',
'find-generic-password -s Claude Code-credentials-new -w'
]);
Expand Down Expand Up @@ -207,9 +273,11 @@ describe('getUsageToken', () => {
expect(getUsageToken()).toBe('file-token');
expect(getSecurityCallLog()).toEqual([
'find-generic-password -s Claude Code-credentials -w',
'find-generic-password -s Claude Code-credentials -a testuser -w',
'dump-keychain',
'find-generic-password -s Claude Code-credentials-hashed -w',
'find-generic-password -s Claude Code-credentials -w',
'find-generic-password -s Claude Code-credentials -a testuser -w',
'dump-keychain',
'find-generic-password -s Claude Code-credentials-hashed -w'
]);
Expand Down
29 changes: 25 additions & 4 deletions src/utils/usage-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,23 +339,43 @@ export function parseMacKeychainCredentialCandidates(rawDump: string, servicePre
.map(candidate => candidate.service);
}

function readMacKeychainSecret(service: string): string | null {
function readMacKeychainSecret(service: string, account?: string): string | null {
try {
const args = account
? ['find-generic-password', '-s', service, '-a', account, '-w']
: ['find-generic-password', '-s', service, '-w'];
return execFileSync(
'security',
['find-generic-password', '-s', service, '-w'],
args,
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true }
).trim();
} catch {
return null;
}
}

function readUsageTokenFromMacKeychainService(service: string): string | null {
const secret = readMacKeychainSecret(service);
function readUsageTokenFromMacKeychainService(service: string, account?: string): string | null {
const secret = readMacKeychainSecret(service, account);
return secret ? parseUsageAccessToken(secret) : null;
}

function getOsUsername(): string | null {
try {
return os.userInfo().username;
} catch {
return null;
}
}

// The login keychain can hold several items under one service, differing only by
// account; a service-only lookup returns whichever matches first, which may be an
// entry lacking `claudeAiOauth`. Claude Code reads/writes with an explicit account
// (the OS username), so mirror that.
function readUsageTokenFromMacKeychainUserAccount(service: string): string | null {
const osUsername = getOsUsername();
return osUsername ? readUsageTokenFromMacKeychainService(service, osUsername) : null;
}

function listMacKeychainCredentialCandidates(): string[] {
try {
const rawDump = execFileSync(
Expand Down Expand Up @@ -403,6 +423,7 @@ export function getUsageToken(): string | null {
}

return readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE)
?? readUsageTokenFromMacKeychainUserAccount(MACOS_USAGE_CREDENTIALS_SERVICE)
?? readUsageTokenFromMacKeychainCandidates()
?? readUsageTokenFromCredentialsFile();
}
Expand Down