Files
openchamber/packages/web/server/lib/quota/providers/claude/auth.js
T

114 lines
3.9 KiB
JavaScript
Raw Normal View History

/**
* Claude credential discovery.
*
* Claude Code is the primary source: on macOS it keeps its OAuth tokens in the
* login Keychain, elsewhere in a credentials file. OpenCode's own `auth.json`
* entry is the fallback for users who signed into Anthropic through OpenCode
* instead of Claude Code.
*
* Every source is read-only. Claude rotates a Keychain/credentials entry from
* under us whenever Claude Code refreshes, so credentials are read fresh per
* request rather than cached; a stale cached token would outlive the record it
* came from.
*
* @module quota/providers/claude/auth
*/
import { execFileSync } from 'child_process';
import os from 'os';
import path from 'path';
import { readAuthFile } from '../../../opencode/auth.js';
import { asObject, asNonEmptyString, normalizeTimestamp, getAuthEntry, normalizeAuthEntry, readJsonFile } from '../../utils/index.js';
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
const OPENCODE_AUTH_ALIASES = ['anthropic', 'claude'];
/**
* @typedef {object} ClaudeCredential
* @property {string} accessToken
* @property {string|null} refreshToken
* @property {number|null} expiresAt Epoch milliseconds, when the source reports it.
* @property {string|null} planLabel Subscription tier reported by Claude Code, e.g. `max`.
* @property {'keychain'|'credentials-file'|'opencode-auth'|'env'} source
*/
const claudeConfigDirectory = () => {
const override = asNonEmptyString(process.env.CLAUDE_CONFIG_DIR);
return override ? path.resolve(override) : path.join(os.homedir(), '.claude');
};
/**
* Claude Code writes one JSON blob holding both its own OAuth tokens
* (`claudeAiOauth`) and unrelated MCP server tokens. Only the former is read.
*/
const parseClaudeCodeBlob = (blob, source) => {
const oauth = asObject(asObject(blob)?.claudeAiOauth);
const accessToken = asNonEmptyString(oauth?.accessToken);
if (!accessToken) return null;
return {
accessToken,
refreshToken: asNonEmptyString(oauth.refreshToken),
expiresAt: normalizeTimestamp(oauth.expiresAt),
planLabel: asNonEmptyString(oauth.subscriptionType),
source
};
};
const readKeychainCredential = () => {
if (process.platform !== 'darwin') return null;
let raw;
try {
raw = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], {
encoding: 'utf8',
timeout: 10_000,
stdio: ['ignore', 'pipe', 'ignore']
});
} catch {
// No entry, or the user denied Keychain access. Both mean "try the next source".
return null;
}
try {
return parseClaudeCodeBlob(JSON.parse(raw.trim()), 'keychain');
} catch {
console.warn('Claude quota: Keychain credentials are not valid JSON');
return null;
}
};
const readCredentialsFile = () =>
parseClaudeCodeBlob(readJsonFile(path.join(claudeConfigDirectory(), '.credentials.json')), 'credentials-file');
const readOpenCodeCredential = () => {
const entry = normalizeAuthEntry(getAuthEntry(readAuthFile(), OPENCODE_AUTH_ALIASES));
const accessToken = asNonEmptyString(entry?.access) ?? asNonEmptyString(entry?.token);
if (!accessToken) return null;
return {
accessToken,
refreshToken: asNonEmptyString(entry.refresh),
expiresAt: normalizeTimestamp(entry.expires),
planLabel: null,
source: 'opencode-auth'
};
};
const readEnvCredential = () => {
const accessToken = asNonEmptyString(process.env.CLAUDE_CODE_OAUTH_TOKEN);
if (!accessToken) return null;
return { accessToken, refreshToken: null, expiresAt: null, planLabel: null, source: 'env' };
};
/**
* First credential a source can produce, in priority order.
*
* The Keychain wins over the credentials file because on macOS the file is a
* leftover that Claude Code no longer updates.
*
* @returns {ClaudeCredential|null}
*/
export const loadClaudeCredential = () =>
readKeychainCredential()
?? readCredentialsFile()
?? readOpenCodeCredential()
?? readEnvCredential();