read Claude's OAuth from the macOS Keychain, and keep it ahead of expiry
On a Mac, Claude Code stores its credentials in the login Keychain and never writes ~/.claude/.credentials.json — the only file this proxy knew how to read. The workaround was to copy the Keychain blob into that file by hand, which is a snapshot: a refresh ROTATES the refresh token and revokes the previous one, so the two stores were not redundant copies but competitors, and whichever refreshed second got `401 OAuth access token has been revoked`. That is not hypothetical. On 2026-08-08 it took out every chat turn from the iPad for six hours while the terminal CLI beside it worked fine — the harness spawned, retried for three minutes and wrote the 401 into the transcript, which from the app looks like an agent that simply never answers. So on darwin the Keychain is the authority and the file is a mirror, holding the same token rather than a different rotation of it. Everywhere else — every Linux server — the file is still the authority and nothing changes. Detection is process.platform, and a machine with no `security` binary or no such item falls through to the file rather than failing. Three recoveries, cheapest first: - a watchdog checks every 30 minutes and refreshes when under an hour remains. It checks rather than refreshing on a blind schedule because each refresh rotates the token, so a needless one is another chance for the stores to disagree. - an upstream 401 now RE-READS before refreshing. When a token has genuinely been revoked the machine usually already holds a good one, because Claude Code refreshed it into the Keychain minutes ago; spending our own refresh token there is what caused the divergence in the first place. - only if nobody else has moved do we refresh ourselves. The Keychain write goes through argv, which is the only non-interactive form `security` offers, and matches on the service AND account pair — the account is read off the existing item rather than assumed, or the update would silently create a second entry instead of replacing the one Claude Code reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir, userInfo } from 'node:os';
|
||||||
import { getState, updateState } from './state';
|
import { getState, updateState } from './state';
|
||||||
|
|
||||||
const PROXY_PORT = Number(process.env.ANTHROPIC_PROXY_PORT ?? '5051');
|
const PROXY_PORT = Number(process.env.ANTHROPIC_PROXY_PORT ?? '5051');
|
||||||
@@ -11,6 +11,38 @@ const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|||||||
// Buffer: refresh 5 minutes before expiry
|
// Buffer: refresh 5 minutes before expiry
|
||||||
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/*
|
||||||
|
macOS keeps Claude Code's OAuth credentials in the login Keychain, NOT in ~/.claude/.credentials.json.
|
||||||
|
|
||||||
|
This proxy used to read the file only, which on a Mac is a file Claude Code never writes. The
|
||||||
|
workaround was to copy the Keychain blob into it by hand — and that is a snapshot, so it dies the
|
||||||
|
first time the real client rotates the token. Worse than dying: a refresh ROTATES the refresh token
|
||||||
|
and revokes the previous one, so two stores holding what looks like the same credential are actually
|
||||||
|
racing, and whichever refreshes second gets `401 OAuth access token has been revoked`. That is the
|
||||||
|
failure this file now exists to prevent, and it took out every mobile chat turn for six hours on
|
||||||
|
2026-08-08 while the terminal CLI beside it worked fine.
|
||||||
|
|
||||||
|
So on darwin the Keychain is the authority and the file is a mirror. Elsewhere — every Linux server —
|
||||||
|
the file is the authority and nothing here changes.
|
||||||
|
*/
|
||||||
|
const IS_MACOS = process.platform === 'darwin';
|
||||||
|
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
||||||
|
|
||||||
|
/** Which store a credential came from, so a refresh is written back to the one that owns it. */
|
||||||
|
type CredentialSource = 'keychain' | 'file';
|
||||||
|
type LoadedCredentials = { creds: CredentialsFile; source: CredentialSource };
|
||||||
|
|
||||||
|
/*
|
||||||
|
How far ahead the watchdog works.
|
||||||
|
|
||||||
|
It CHECKS every 30 minutes and refreshes only when the token has under an hour left, rather than
|
||||||
|
refreshing on a blind schedule. The distinction matters: every refresh rotates the token, so a
|
||||||
|
pointless refresh is not free — it is another chance for the two stores to disagree. This keeps a
|
||||||
|
valid token permanently ahead of the next request without spending one to do it.
|
||||||
|
*/
|
||||||
|
const WATCHDOG_INTERVAL_MS = 30 * 60 * 1000;
|
||||||
|
const WATCHDOG_REFRESH_WITHIN_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
type OAuthCredentials = {
|
type OAuthCredentials = {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
@@ -39,6 +71,92 @@ async function writeCredentials(creds: CredentialsFile): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run `security`, returning stdout on success and null on any failure. Never throws. */
|
||||||
|
async function runSecurity(args: string[]): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(['security', ...args], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
||||||
|
return code === 0 ? out : null;
|
||||||
|
} catch {
|
||||||
|
// No `security` binary, or spawning is not permitted. Both mean "no Keychain here".
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The Keychain account the item is filed under — the macOS short username in practice.
|
||||||
|
|
||||||
|
Read from the existing item rather than assumed, because `add-generic-password -U` matches on the
|
||||||
|
service AND account pair: guess the account wrong and it silently creates a SECOND item instead of
|
||||||
|
updating the one Claude Code reads, which would look like a write that worked and changed nothing.
|
||||||
|
*/
|
||||||
|
let keychainAccount: string | null = null;
|
||||||
|
async function getKeychainAccount(): Promise<string> {
|
||||||
|
if (keychainAccount) return keychainAccount;
|
||||||
|
const out = await runSecurity(['find-generic-password', '-s', KEYCHAIN_SERVICE]);
|
||||||
|
keychainAccount = out?.match(/"acct"<blob>="([^"]*)"/)?.[1] || userInfo().username;
|
||||||
|
return keychainAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readKeychainCredentials(): Promise<CredentialsFile | null> {
|
||||||
|
if (!IS_MACOS) return null;
|
||||||
|
const raw = await runSecurity(['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']);
|
||||||
|
if (!raw?.trim()) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw.trim()) as CredentialsFile;
|
||||||
|
// An item with no access token is not a credential. Fall through to the file rather than
|
||||||
|
// adopting it — an empty Keychain entry must not shadow a working one on disk.
|
||||||
|
return parsed.claudeAiOauth?.accessToken ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeKeychainCredentials(creds: CredentialsFile): Promise<boolean> {
|
||||||
|
if (!IS_MACOS) return false;
|
||||||
|
const account = await getKeychainAccount();
|
||||||
|
// The blob goes through argv, which is the only interface `security` offers for a non-interactive
|
||||||
|
// write — it has no stdin form. Acceptable here: this is a single-user laptop whose Keychain the
|
||||||
|
// caller already holds, and the alternative is leaving Claude Code's own store stale.
|
||||||
|
const out = await runSecurity([
|
||||||
|
'add-generic-password',
|
||||||
|
'-U',
|
||||||
|
'-a',
|
||||||
|
account,
|
||||||
|
'-s',
|
||||||
|
KEYCHAIN_SERVICE,
|
||||||
|
'-w',
|
||||||
|
JSON.stringify(creds),
|
||||||
|
]);
|
||||||
|
return out !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The credentials to work from, and where they came from.
|
||||||
|
*
|
||||||
|
* Keychain first on macOS: it is what the real Claude Code reads and writes, so it is the copy that
|
||||||
|
* is current even when nothing here has run for hours.
|
||||||
|
*/
|
||||||
|
async function loadCredentials(): Promise<LoadedCredentials | null> {
|
||||||
|
const fromKeychain = await readKeychainCredentials();
|
||||||
|
if (fromKeychain) return { creds: fromKeychain, source: 'keychain' };
|
||||||
|
const fromFile = await readCredentials();
|
||||||
|
return fromFile ? { creds: fromFile, source: 'file' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a refreshed credential to the store that owns it, and mirror it to the file.
|
||||||
|
*
|
||||||
|
* The mirror is deliberate. Both copies then hold the SAME token rather than two rotations of it,
|
||||||
|
* which is the difference between a redundant copy and the race this file was written to end.
|
||||||
|
*/
|
||||||
|
async function saveCredentials(creds: CredentialsFile, source: CredentialSource): Promise<void> {
|
||||||
|
if (source === 'keychain' && !(await writeKeychainCredentials(creds))) {
|
||||||
|
console.error('[claude:proxy] could not write the Keychain — the file mirror is now the only copy');
|
||||||
|
}
|
||||||
|
await writeCredentials(creds);
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshOAuthToken(refreshToken: string): Promise<OAuthCredentials | null> {
|
async function refreshOAuthToken(refreshToken: string): Promise<OAuthCredentials | null> {
|
||||||
try {
|
try {
|
||||||
console.log('[claude:proxy] refreshing OAuth token...');
|
console.log('[claude:proxy] refreshing OAuth token...');
|
||||||
@@ -79,10 +197,11 @@ async function refreshOAuthToken(refreshToken: string): Promise<OAuthCredentials
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getValidToken(): Promise<string | null> {
|
async function getValidToken(): Promise<string | null> {
|
||||||
const creds = await readCredentials();
|
const loaded = await loadCredentials();
|
||||||
if (!creds?.claudeAiOauth) return null;
|
const oauth = loaded?.creds.claudeAiOauth;
|
||||||
|
if (!loaded || !oauth) return null;
|
||||||
|
|
||||||
const oauth = creds.claudeAiOauth;
|
const { creds, source } = loaded;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Check if token is still valid (with buffer)
|
// Check if token is still valid (with buffer)
|
||||||
@@ -95,7 +214,7 @@ async function getValidToken(): Promise<string | null> {
|
|||||||
const refreshed = await refreshOAuthToken(oauth.refreshToken);
|
const refreshed = await refreshOAuthToken(oauth.refreshToken);
|
||||||
if (refreshed?.accessToken) {
|
if (refreshed?.accessToken) {
|
||||||
creds.claudeAiOauth = { ...oauth, ...refreshed };
|
creds.claudeAiOauth = { ...oauth, ...refreshed };
|
||||||
await writeCredentials(creds);
|
await saveCredentials(creds, source);
|
||||||
return refreshed.accessToken.trim();
|
return refreshed.accessToken.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,17 +224,71 @@ async function getValidToken(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function forceRefresh(): Promise<string | null> {
|
async function forceRefresh(): Promise<string | null> {
|
||||||
const creds = await readCredentials();
|
const loaded = await loadCredentials();
|
||||||
const refreshToken = creds?.claudeAiOauth?.refreshToken;
|
const oauth = loaded?.creds.claudeAiOauth;
|
||||||
if (!refreshToken) return null;
|
if (!loaded || !oauth?.refreshToken) return null;
|
||||||
|
|
||||||
const refreshed = await refreshOAuthToken(refreshToken);
|
const refreshed = await refreshOAuthToken(oauth.refreshToken);
|
||||||
if (refreshed?.accessToken && creds?.claudeAiOauth) {
|
if (!refreshed?.accessToken) return null;
|
||||||
creds.claudeAiOauth = { ...creds.claudeAiOauth, ...refreshed };
|
|
||||||
await writeCredentials(creds);
|
loaded.creds.claudeAiOauth = { ...oauth, ...refreshed };
|
||||||
return refreshed.accessToken;
|
await saveCredentials(loaded.creds, loaded.source);
|
||||||
|
return refreshed.accessToken.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover from an upstream 401, in the order that costs least.
|
||||||
|
*
|
||||||
|
* Re-reading comes FIRST, and on macOS that is the whole fix: when a token has genuinely been revoked,
|
||||||
|
* the machine usually already holds a good one — Claude Code refreshed it into the Keychain minutes
|
||||||
|
* ago and never touched our copy. Spending our own refresh token in that situation is not just
|
||||||
|
* wasteful, it is how the two stores got out of step to begin with.
|
||||||
|
*
|
||||||
|
* Only when nobody else has moved do we refresh ourselves.
|
||||||
|
*/
|
||||||
|
async function recoverFromUnauthorized(usedToken: string): Promise<string | null> {
|
||||||
|
const loaded = await loadCredentials();
|
||||||
|
const current = loaded?.creds.claudeAiOauth?.accessToken?.trim();
|
||||||
|
if (current && current !== usedToken) {
|
||||||
|
console.log('[claude:proxy] 401 — a newer token was already on disk, retrying with it');
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
return null;
|
console.log('[claude:proxy] 401 — refreshing');
|
||||||
|
return forceRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep a valid token ahead of the next request, so nobody discovers an expired one by being told to
|
||||||
|
* wait three minutes for a chat that then fails to authenticate.
|
||||||
|
*
|
||||||
|
* Runs once at start-up and every {@link WATCHDOG_INTERVAL_MS} after. Every failure is logged and
|
||||||
|
* swallowed: a refresh that cannot happen must not take the proxy down with it, because the token on
|
||||||
|
* hand may still have hours left.
|
||||||
|
*/
|
||||||
|
export function startCredentialWatchdog(): void {
|
||||||
|
const tick = async () => {
|
||||||
|
const loaded = await loadCredentials();
|
||||||
|
const oauth = loaded?.creds.claudeAiOauth;
|
||||||
|
if (!loaded || !oauth?.refreshToken) return;
|
||||||
|
|
||||||
|
const remaining = (oauth.expiresAt ?? 0) - Date.now();
|
||||||
|
if (remaining > WATCHDOG_REFRESH_WITHIN_MS) return;
|
||||||
|
|
||||||
|
const refreshed = await refreshOAuthToken(oauth.refreshToken);
|
||||||
|
if (!refreshed?.accessToken) {
|
||||||
|
console.error('[claude:proxy] watchdog refresh failed — the next request will try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loaded.creds.claudeAiOauth = { ...oauth, ...refreshed };
|
||||||
|
await saveCredentials(loaded.creds, loaded.source);
|
||||||
|
console.log(`[claude:proxy] watchdog refreshed the token from the ${loaded.source}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
tick().catch((err) => console.error('[claude:proxy] watchdog error:', err));
|
||||||
|
};
|
||||||
|
run();
|
||||||
|
setInterval(run, WATCHDOG_INTERVAL_MS).unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProxySecret(): string {
|
export function getProxySecret(): string {
|
||||||
@@ -199,39 +372,30 @@ export function startAnthropicProxy() {
|
|||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If upstream returns 401, try one refresh and retry
|
// If upstream returns 401, recover the credential and retry once
|
||||||
if (upstreamRes.status === 401) {
|
if (upstreamRes.status === 401) {
|
||||||
const creds = await readCredentials();
|
const recovered = await recoverFromUnauthorized(token);
|
||||||
const refreshToken = creds?.claudeAiOauth?.refreshToken;
|
if (recovered) {
|
||||||
if (refreshToken) {
|
headers.set('Authorization', `Bearer ${recovered}`);
|
||||||
const refreshed = await refreshOAuthToken(refreshToken);
|
const retryBody = body ? new Uint8Array(body) : null;
|
||||||
if (refreshed?.accessToken) {
|
const retryRes = await fetch(upstream, {
|
||||||
if (creds?.claudeAiOauth) {
|
method: req.method,
|
||||||
creds.claudeAiOauth = { ...creds.claudeAiOauth, ...refreshed };
|
headers,
|
||||||
await writeCredentials(creds);
|
body: retryBody,
|
||||||
}
|
});
|
||||||
|
|
||||||
headers.set('Authorization', `Bearer ${refreshed.accessToken.trim()}`);
|
const retryHeaders = new Headers();
|
||||||
const retryBody = body ? new Uint8Array(body) : null;
|
for (const [key, value] of retryRes.headers) {
|
||||||
const retryRes = await fetch(upstream, {
|
const lower = key.toLowerCase();
|
||||||
method: req.method,
|
if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue;
|
||||||
headers,
|
retryHeaders.set(key, value);
|
||||||
body: retryBody,
|
|
||||||
});
|
|
||||||
|
|
||||||
const retryHeaders = new Headers();
|
|
||||||
for (const [key, value] of retryRes.headers) {
|
|
||||||
const lower = key.toLowerCase();
|
|
||||||
if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue;
|
|
||||||
retryHeaders.set(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(retryRes.body, {
|
|
||||||
status: retryRes.status,
|
|
||||||
statusText: retryRes.statusText,
|
|
||||||
headers: retryHeaders,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new Response(retryRes.body, {
|
||||||
|
status: retryRes.status,
|
||||||
|
statusText: retryRes.statusText,
|
||||||
|
headers: retryHeaders,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,4 +416,6 @@ export function startAnthropicProxy() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[claude:proxy] listening on 127.0.0.1:${PROXY_PORT}`);
|
console.log(`[claude:proxy] listening on 127.0.0.1:${PROXY_PORT}`);
|
||||||
|
if (IS_MACOS) console.log(`[claude:proxy] macOS — reading credentials from the "${KEYCHAIN_SERVICE}" Keychain item`);
|
||||||
|
startCredentialWatchdog();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user