Skip to content

Commit f8bc5e7

Browse files
committed
fix: refresh Anthropic OAuth after auth failures
1 parent 9289c46 commit f8bc5e7

2 files changed

Lines changed: 54 additions & 3 deletions

File tree

runtimes/opencode/plugins/claude-code-auth.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,17 @@ function upsertAccount(store: AccountStore, auth: OAuthStored, now = Date.now())
184184
store.activeIndex = index;
185185
}
186186

187+
function replaceAccount(store: AccountStore, previous: OAuthStored, next: OAuthStored, now = Date.now()) {
188+
const index = store.accounts.findIndex((account) => account.refresh === previous.refresh || account.access === previous.access || account.refresh === next.refresh || account.access === next.access);
189+
if (index < 0) {
190+
upsertAccount(store, next, now);
191+
return;
192+
}
193+
const existing = store.accounts[index];
194+
store.accounts[index] = { ...next, addedAt: existing?.addedAt ?? now, lastUsed: now };
195+
store.activeIndex = index;
196+
}
197+
187198
async function rememberAnthropicOAuth(auth: OAuthStored) {
188199
const store = await loadAccountStore();
189200
upsertAccount(store, auth);
@@ -611,7 +622,7 @@ async function getFreshOAuth(getAuth: () => Promise<OAuthStored | { type: string
611622
try {
612623
const refreshed = await refreshAnthropicToken(candidate.refresh);
613624
await writeAnthropicAuth(refreshed);
614-
upsertAccount(store, refreshed);
625+
replaceAccount(store, candidate, refreshed);
615626
await saveAccountStore(store);
616627
return refreshed;
617628
} catch (error) {
@@ -628,13 +639,37 @@ async function getFreshOAuth(getAuth: () => Promise<OAuthStored | { type: string
628639
});
629640
}
630641

642+
async function refreshOAuthAfterAuthFailure(auth: OAuthStored) {
643+
return withRefreshLock(async () => {
644+
const latest = await readAnthropicAuth();
645+
if (latest && !sameOAuth(latest, auth) && usableAccessToken(latest)) return latest;
646+
647+
const store = await loadAccountStore();
648+
const refreshed = await refreshAnthropicToken(auth.refresh);
649+
await writeAnthropicAuth(refreshed);
650+
replaceAccount(store, auth, refreshed);
651+
await saveAccountStore(store);
652+
return refreshed;
653+
});
654+
}
655+
656+
async function rotateAndRefreshAnthropicAccount(auth: OAuthStored) {
657+
const rotated = await rotateAnthropicAccount(auth);
658+
if (!rotated || sameOAuth(rotated, auth)) return undefined;
659+
try {
660+
return await refreshOAuthAfterAuthFailure(rotated);
661+
} catch {
662+
return undefined;
663+
}
664+
}
665+
631666
async function getFreshOAuthOrRotate(getAuth: () => Promise<OAuthStored | { type: string }>) {
632667
try {
633668
return await getFreshOAuth(getAuth);
634669
} catch (error) {
635670
const auth = await readAnthropicAuth();
636671
if (auth) {
637-
const rotated = await rotateAnthropicAccount(auth);
672+
const rotated = await rotateAndRefreshAnthropicAccount(auth);
638673
if (rotated && !sameOAuth(rotated, auth)) return rotated;
639674
}
640675
throw error;
@@ -670,7 +705,17 @@ const claudeCodeAuthPlugin: Plugin = async () => ({
670705
if (!response.ok) {
671706
const bodyText = await response.clone().text().catch(() => "");
672707
if (shouldRotateAuth(response.status, bodyText)) {
673-
const rotated = await rotateAnthropicAccount(freshAuth);
708+
const refreshed = await refreshOAuthAfterAuthFailure(freshAuth).catch(() => undefined);
709+
if (refreshed) {
710+
headers.set("authorization", `Bearer ${refreshed.access}`);
711+
response = await fetch(input, { ...(init ?? {}), body: rewritten.body, headers });
712+
}
713+
}
714+
}
715+
if (!response.ok) {
716+
const bodyText = await response.clone().text().catch(() => "");
717+
if (shouldRotateAuth(response.status, bodyText)) {
718+
const rotated = await rotateAndRefreshAnthropicAccount(await readAnthropicAuth() ?? freshAuth);
674719
if (rotated) {
675720
headers.set("authorization", `Bearer ${rotated.access}`);
676721
response = await fetch(input, { ...(init ?? {}), body: rewritten.body, headers });

tests/opencode-claude-auth-refresh-hardening.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,11 @@ require_source "function isInvalidGrantFailure" "invalid_grant refresh failure c
2323
require_source "const candidates = dedupeOAuthCandidates([latest, oauth, active, ...store.accounts])" "remembered account retry candidates"
2424
require_source "summarizeRefreshFailures" "redacted refresh failure diagnostics"
2525
require_source "getFreshOAuthOrRotate" "request path refresh fallback wrapper"
26+
require_source "function replaceAccount" "rotated refresh token replaces stale account entry"
27+
require_source "replaceAccount(store, candidate, refreshed)" "normal refresh replaces stale account entry"
28+
require_source "async function refreshOAuthAfterAuthFailure" "auth failure refresh retry helper"
29+
require_source "async function rotateAndRefreshAnthropicAccount" "rotated account refresh helper"
30+
require_source "const refreshed = await refreshOAuthAfterAuthFailure(freshAuth).catch(() => undefined)" "401 retry refreshes current credential"
31+
require_source "const rotated = await rotateAndRefreshAnthropicAccount(await readAnthropicAuth() ?? freshAuth)" "401 retry refreshes rotated credential"
2632

2733
echo "PASS: tests/opencode-claude-auth-refresh-hardening.sh"

0 commit comments

Comments
 (0)