Skip to content
Merged
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
43 changes: 26 additions & 17 deletions runtimes/opencode/plugins/claude-code-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type CallbackResult = { code: string; state: string };
type AccountRecord = OAuthStored & { addedAt: number; lastUsed: number };
type AccountStore = { version: number; activeIndex: number; accounts: AccountRecord[] };
type RefreshFailure = { auth: OAuthStored; error: unknown };
type AuthSyncClient = { auth?: { set?: (input: { providerID: string; auth: OAuthStored }) => Promise<unknown> } };

const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
Expand Down Expand Up @@ -83,6 +84,11 @@ async function writeAnthropicAuth(auth: OAuthStored) {
await writeJson(file, data);
}

async function setAnthropicAuth(auth: OAuthStored, client?: AuthSyncClient) {
await writeAnthropicAuth(auth);
await client?.auth?.set?.({ providerID: "anthropic", auth }).catch(() => undefined);
}

async function readAnthropicAuth() {
const data = await readJson<Record<string, unknown>>(authFilePath(), {});
const auth = data.anthropic;
Expand Down Expand Up @@ -201,7 +207,7 @@ async function rememberAnthropicOAuth(auth: OAuthStored) {
await saveAccountStore(store);
}

async function rotateAnthropicAccount(auth: OAuthStored) {
async function rotateAnthropicAccount(auth: OAuthStored, client?: AuthSyncClient) {
const store = await loadAccountStore();
if (store.accounts.length < 2) return undefined;

Expand All @@ -219,7 +225,7 @@ async function rotateAnthropicAccount(auth: OAuthStored) {
access: nextAccount.access,
expires: nextAccount.expires,
};
await writeAnthropicAuth(nextAuth);
await setAnthropicAuth(nextAuth, client);
return nextAuth;
}

Expand Down Expand Up @@ -603,7 +609,7 @@ function mergeBetas(existing: string | null, required: string[]) {
return [...new Set([...required, ...(existing || "").split(",").map((s) => s.trim()).filter(Boolean)])].join(",");
}

async function getFreshOAuth(getAuth: () => Promise<OAuthStored | { type: string }>) {
async function getFreshOAuth(getAuth: () => Promise<OAuthStored | { type: string }>, client?: AuthSyncClient) {
const auth = await getAuth();
if (auth.type !== "oauth") return undefined;
const oauth = auth as OAuthStored;
Expand All @@ -621,7 +627,7 @@ async function getFreshOAuth(getAuth: () => Promise<OAuthStored | { type: string
for (const candidate of candidates) {
try {
const refreshed = await refreshAnthropicToken(candidate.refresh);
await writeAnthropicAuth(refreshed);
await setAnthropicAuth(refreshed, client);
replaceAccount(store, candidate, refreshed);
await saveAccountStore(store);
return refreshed;
Expand All @@ -639,44 +645,46 @@ async function getFreshOAuth(getAuth: () => Promise<OAuthStored | { type: string
});
}

async function refreshOAuthAfterAuthFailure(auth: OAuthStored) {
async function refreshOAuthAfterAuthFailure(auth: OAuthStored, client?: AuthSyncClient) {
return withRefreshLock(async () => {
const latest = await readAnthropicAuth();
if (latest && !sameOAuth(latest, auth) && usableAccessToken(latest)) return latest;

const store = await loadAccountStore();
const refreshed = await refreshAnthropicToken(auth.refresh);
await writeAnthropicAuth(refreshed);
await setAnthropicAuth(refreshed, client);
replaceAccount(store, auth, refreshed);
await saveAccountStore(store);
return refreshed;
});
}

async function rotateAndRefreshAnthropicAccount(auth: OAuthStored) {
const rotated = await rotateAnthropicAccount(auth);
async function rotateAndRefreshAnthropicAccount(auth: OAuthStored, client?: AuthSyncClient) {
const rotated = await rotateAnthropicAccount(auth, client);
if (!rotated || sameOAuth(rotated, auth)) return undefined;
try {
return await refreshOAuthAfterAuthFailure(rotated);
return await refreshOAuthAfterAuthFailure(rotated, client);
} catch {
return undefined;
}
}

async function getFreshOAuthOrRotate(getAuth: () => Promise<OAuthStored | { type: string }>) {
async function getFreshOAuthOrRotate(getAuth: () => Promise<OAuthStored | { type: string }>, client?: AuthSyncClient) {
try {
return await getFreshOAuth(getAuth);
return await getFreshOAuth(getAuth, client);
} catch (error) {
const auth = await readAnthropicAuth();
if (auth) {
const rotated = await rotateAndRefreshAnthropicAccount(auth);
const rotated = await rotateAndRefreshAnthropicAccount(auth, client);
if (rotated && !sameOAuth(rotated, auth)) return rotated;
}
throw error;
}
}

const claudeCodeAuthPlugin: Plugin = async () => ({
const claudeCodeAuthPlugin: Plugin = async (input) => {
const client = input.client as AuthSyncClient | undefined;
return {
auth: {
provider: "anthropic",
async loader(getAuth: () => Promise<OAuthStored | { type: string }>, provider: { models: Record<string, { cost?: unknown }> }) {
Expand All @@ -690,7 +698,7 @@ const claudeCodeAuthPlugin: Plugin = async () => ({
if (!url || !ANTHROPIC_HOSTS.has(url.hostname)) return fetch(input, init);
const originalBody = typeof init?.body === "string" ? init.body : input instanceof Request ? await input.clone().text().catch(() => undefined) : undefined;
const rewritten = rewriteRequestPayload(originalBody);
const freshAuth = await getFreshOAuthOrRotate(getAuth);
const freshAuth = await getFreshOAuthOrRotate(getAuth, client);
if (!freshAuth) return fetch(input, init);
const headers = new Headers(init?.headers);
if (input instanceof Request) input.headers.forEach((value, key) => { if (!headers.has(key)) headers.set(key, value); });
Expand All @@ -705,7 +713,7 @@ const claudeCodeAuthPlugin: Plugin = async () => ({
if (!response.ok) {
const bodyText = await response.clone().text().catch(() => "");
if (shouldRotateAuth(response.status, bodyText)) {
const refreshed = await refreshOAuthAfterAuthFailure(freshAuth).catch(() => undefined);
const refreshed = await refreshOAuthAfterAuthFailure(freshAuth, client).catch(() => undefined);
if (refreshed) {
headers.set("authorization", `Bearer ${refreshed.access}`);
response = await fetch(input, { ...(init ?? {}), body: rewritten.body, headers });
Expand All @@ -715,7 +723,7 @@ const claudeCodeAuthPlugin: Plugin = async () => ({
if (!response.ok) {
const bodyText = await response.clone().text().catch(() => "");
if (shouldRotateAuth(response.status, bodyText)) {
const rotated = await rotateAndRefreshAnthropicAccount(await readAnthropicAuth() ?? freshAuth);
const rotated = await rotateAndRefreshAnthropicAccount(await readAnthropicAuth() ?? freshAuth, client);
if (rotated) {
headers.set("authorization", `Bearer ${rotated.access}`);
response = await fetch(input, { ...(init ?? {}), body: rewritten.body, headers });
Expand All @@ -731,6 +739,7 @@ const claudeCodeAuthPlugin: Plugin = async () => ({
{ provider: "anthropic", label: "Manually enter API Key", type: "api" },
],
},
});
};
};

export { claudeCodeAuthPlugin };
6 changes: 4 additions & 2 deletions tests/opencode-claude-auth-refresh-hardening.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ require_source "summarizeRefreshFailures" "redacted refresh failure diagnostics"
require_source "getFreshOAuthOrRotate" "request path refresh fallback wrapper"
require_source "function replaceAccount" "rotated refresh token replaces stale account entry"
require_source "replaceAccount(store, candidate, refreshed)" "normal refresh replaces stale account entry"
require_source "async function setAnthropicAuth" "auth file and live OpenCode auth sync helper"
require_source "client?.auth?.set?.({ providerID: \"anthropic\", auth })" "live OpenCode auth state sync after credential changes"
require_source "async function refreshOAuthAfterAuthFailure" "auth failure refresh retry helper"
require_source "async function rotateAndRefreshAnthropicAccount" "rotated account refresh helper"
require_source "const refreshed = await refreshOAuthAfterAuthFailure(freshAuth).catch(() => undefined)" "401 retry refreshes current credential"
require_source "const rotated = await rotateAndRefreshAnthropicAccount(await readAnthropicAuth() ?? freshAuth)" "401 retry refreshes rotated credential"
require_source "const refreshed = await refreshOAuthAfterAuthFailure(freshAuth, client).catch(() => undefined)" "401 retry refreshes current credential and syncs OpenCode auth state"
require_source "const rotated = await rotateAndRefreshAnthropicAccount(await readAnthropicAuth() ?? freshAuth, client)" "401 retry refreshes rotated credential and syncs OpenCode auth state"

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