Skip to content

Commit 0de138a

Browse files
committed
fix(oauth): durable generation-bound refresh intent prevents Anthropic stale-lock token replay (#209)
1 parent 2e07c8b commit 0de138a

4 files changed

Lines changed: 132 additions & 9 deletions

File tree

devlog/_plan/260722_issue_bug_sweep/040_merge_readiness_review.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737

3838
### B2 — #209 Anthropic: stale-lock이 소모된 회전 토큰 재전송 (High)
3939

40+
> **해결됨 (2026-07-22, WP-fix-2)**: `store.ts`에 generation-bound durable refresh-intent 사이드카(`auth.refresh.<provider>.<hash>.lock.json`, 토큰 미저장, atomicWrite 0600) 도입. `refreshAnthropicAccountWithLock``def.refresh` 전에 intent를 durable 기록하고, 진입 시 동일 generation intent가 있으면 재전송하지 않고 신규 Claude 자격 채택 또는 recovery(needsReauth, 소스 무관)로 해소. recovery 시 intent 유지(재진입 replay 차단), ENOENT만 부재로 처리(그 외 read/parse 실패는 uncertain=fail-closed). 회귀: 연속 2회 same-generation refreshCalls=0, corrupt-sidecar refreshCalls=0(둘 다 수정 전 RED). 리뷰어 2라운드(FAIL 2건→PASS). xAI 비회귀. oauth 45 pass, tsc 0.
41+
4042
- 위치: `src/oauth/index.ts:320` 인접 + 락 프리미티브 `src/oauth/store.ts:79-85`.
4143
- 트리거: Anthropic이 회전 토큰을 소모한 뒤 `mergeAccountCredential` persist 전에 프로세스 종료/120s 락 stale.
4244
- 영향: 이후 프로세스가 stale 락 제거 후 옛 토큰 재제출 → invalid_grant로 영구 needsReauth, 이슈 재현·no-blind-replay 위반.

src/oauth/index.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { parseCallbackInput } from "./callback-server";
33
import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
44
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
55
import { maskEmail } from "../lib/privacy";
6-
import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration } from "./store";
6+
import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store";
77
import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
88
import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
99
import { loginKimi, refreshKimiToken } from "./kimi";
@@ -296,40 +296,52 @@ export async function refreshAnthropicAccountWithLock(
296296
const stored = getAccountCredential(provider, accountId);
297297
if (!stored) throw new OAuthLoginRequiredError(provider);
298298
const account = getAccountSet(provider)?.accounts.find(candidate => candidate.id === accountId);
299+
const generation = credentialGeneration(stored);
300+
const pendingIntent = readOAuthRefreshIntent(provider, accountId);
299301
const disk = newerClaudeCredential(stored, now());
300302
if (disk) {
301303
const outcome = await mergeAccountCredential(provider, accountId, disk, {
302304
expectedGeneration: credentialGeneration(stored),
303305
afterPrePersistRead: deps.afterPrePersistRead,
304306
});
305307
if (outcome.superseded) {
308+
if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
306309
if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
307310
throw new OAuthLoginRequiredError(provider);
308311
}
312+
if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
309313
return disk.access;
310314
}
311-
if (account?.needsReauth && stored.source === "local-cli") {
315+
if (pendingIntent?.uncertain || pendingIntent?.generation === generation) {
316+
await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
317+
throw new OAuthLoginRequiredError(provider);
318+
}
319+
if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
320+
if (account?.needsReauth) {
312321
throw new OAuthLoginRequiredError(provider);
313322
}
314323
if (credentialGeneration(stored) !== credentialGeneration(callerCredential) && stored.expires > now() + REFRESH_SKEW_MS) {
315324
return stored.access;
316325
}
317326

318-
const generation = credentialGeneration(stored);
319327
try {
328+
writeOAuthRefreshIntent(provider, accountId, generation, now());
320329
const fresh = merged(await def.refresh(stored.refresh), stored);
321330
const outcome = await mergeAccountCredential(provider, accountId, fresh, {
322331
expectedGeneration: generation,
323332
afterPrePersistRead: deps.afterPrePersistRead,
324333
});
325334
if (outcome.superseded) {
335+
clearOAuthRefreshIntent(provider, accountId, generation);
326336
if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
327337
throw new OAuthLoginRequiredError(provider);
328338
}
339+
clearOAuthRefreshIntent(provider, accountId, generation);
329340
return fresh.access;
330341
} catch (error) {
331342
if (!terminal(error)) throw error;
332343
await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
344+
clearOAuthRefreshIntent(provider, accountId, generation);
333345
throw new OAuthLoginRequiredError(provider);
334346
}
335347
} finally {

src/oauth/store.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,38 @@ export function getAuthRefreshIntentLockPath(provider: string, accountId: string
3636
const accountHash = createHash("sha256").update(accountId).digest("hex").slice(0, 24);
3737
return join(getConfigDir(), `auth.refresh.${safeProvider}.${accountHash}.lock`);
3838
}
39+
export function getAuthRefreshIntentPath(provider: string, accountId: string): string {
40+
return `${getAuthRefreshIntentLockPath(provider, accountId)}.json`;
41+
}
42+
export interface OAuthRefreshIntent { version: 1; provider: string; accountId: string; generation: string; createdAt: number; uncertain?: true }
43+
export function readOAuthRefreshIntent(provider: string, accountId: string): OAuthRefreshIntent | undefined {
44+
const path = getAuthRefreshIntentPath(provider, accountId);
45+
try {
46+
hardenConfigDir();
47+
hardenExistingSecret(path);
48+
const value = JSON.parse(readFileSync(path, "utf8")) as Partial<OAuthRefreshIntent>;
49+
if (value.version !== 1 || value.provider !== provider || value.accountId !== accountId || typeof value.generation !== "string" || typeof value.createdAt !== "number") {
50+
return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
51+
}
52+
return value as OAuthRefreshIntent;
53+
} catch (error) {
54+
if (errorCode(error) === "ENOENT") return undefined;
55+
return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
56+
}
57+
}
58+
export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now()): void {
59+
const dir = getConfigDir();
60+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
61+
hardenConfigDir();
62+
const intent: OAuthRefreshIntent = { version: 1, provider, accountId, generation, createdAt };
63+
atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify(intent)}\n`);
64+
}
65+
export function clearOAuthRefreshIntent(provider: string, accountId: string, generation: string): boolean {
66+
const current = readOAuthRefreshIntent(provider, accountId);
67+
if (!current || current.generation !== generation) return false;
68+
try { unlinkSync(getAuthRefreshIntentPath(provider, accountId)); return true; }
69+
catch (error) { if (errorCode(error) === "ENOENT") return false; throw error; }
70+
}
3971
export function credentialGeneration(cred: OAuthCredentials): string {
4072
return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires])).digest("hex");
4173
}

tests/oauth-refresh.test.ts

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { getValidAccessToken, OAuthLoginRequiredError, OAUTH_PROVIDERS, refreshAnthropicAccountWithLock } from "../src/oauth";
77
import { AnthropicTokenError } from "../src/oauth/anthropic";
8-
import { getAccountCredential, getAccountSet, getCredential, markAccountNeedsReauth, saveCredential } from "../src/oauth/store";
8+
import { credentialGeneration, getAccountCredential, getAccountSet, getAuthRefreshIntentPath, getCredential, markAccountNeedsReauth, readOAuthRefreshIntent, saveCredential, writeOAuthRefreshIntent } from "../src/oauth/store";
99

1010
const origHome = process.env.HOME;
1111
const origOcxHome = process.env.OPENCODEX_HOME;
@@ -271,14 +271,14 @@ describe("oauth refresh hardening", () => {
271271
});
272272

273273
test("Anthropic transient failures do not mark needsReauth", async () => {
274-
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
275-
const id = getAccountSet("anthropic")!.activeAccountId;
276-
for (const error of [
274+
for (const [index, error] of [
277275
new AnthropicTokenError("server", 503, undefined),
278276
new AnthropicTokenError("timeout", undefined, undefined),
279-
]) {
277+
].entries()) {
278+
await saveCredential("anthropic", { access: `old-${index}`, refresh: `rt-old-${index}`, expires: 1, accountId: `acct-${index}` });
279+
const id = getAccountSet("anthropic")!.activeAccountId;
280280
await expect(refreshAnthropicAccountWithLock("anthropic", id, { ...OAUTH_PROVIDERS.anthropic!, refresh: async () => { throw error; } }, getAccountCredential("anthropic", id)!)).rejects.toBe(error);
281-
expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBeUndefined();
281+
expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined();
282282
}
283283
});
284284

@@ -290,6 +290,83 @@ describe("oauth refresh hardening", () => {
290290
expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBe(true);
291291
});
292292

293+
test("Anthropic never replays an outstanding oauth-source generation across re-entry", async () => {
294+
await saveCredential("anthropic", { access: "old", refresh: "rt-consumed", expires: 1, accountId: "acct" });
295+
const id = getAccountSet("anthropic")!.activeAccountId;
296+
const credential = getAccountCredential("anthropic", id)!;
297+
writeOAuthRefreshIntent("anthropic", id, credentialGeneration(credential), Date.now() - 120_001);
298+
let refreshCalls = 0;
299+
300+
const attempt = () => refreshAnthropicAccountWithLock("anthropic", id, {
301+
...OAUTH_PROVIDERS.anthropic!,
302+
refresh: async () => { refreshCalls++; throw new Error("must not replay"); },
303+
}, credential);
304+
305+
await expect(attempt()).rejects.toBeInstanceOf(OAuthLoginRequiredError);
306+
await expect(attempt()).rejects.toBeInstanceOf(OAuthLoginRequiredError);
307+
308+
expect(refreshCalls).toBe(0);
309+
expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBe(true);
310+
expect(readOAuthRefreshIntent("anthropic", id)?.generation).toBe(credentialGeneration(credential));
311+
});
312+
313+
test("Anthropic treats a corrupt durable intent as outstanding and never refreshes", async () => {
314+
await saveCredential("anthropic", { access: "old", refresh: "rt-consumed", expires: 1, accountId: "acct" });
315+
const id = getAccountSet("anthropic")!.activeAccountId;
316+
const credential = getAccountCredential("anthropic", id)!;
317+
writeFileSync(getAuthRefreshIntentPath("anthropic", id), "not-json");
318+
let refreshCalls = 0;
319+
320+
await expect(refreshAnthropicAccountWithLock("anthropic", id, {
321+
...OAUTH_PROVIDERS.anthropic!,
322+
refresh: async () => { refreshCalls++; throw new Error("must not replay"); },
323+
}, credential)).rejects.toBeInstanceOf(OAuthLoginRequiredError);
324+
325+
expect(refreshCalls).toBe(0);
326+
expect(readOAuthRefreshIntent("anthropic", id)?.uncertain).toBe(true);
327+
});
328+
329+
test("Anthropic outstanding intent adopts a newer Claude credential without replay", async () => {
330+
await saveCredential("anthropic", { access: "old", refresh: "rt-consumed", expires: 1, source: "local-cli" });
331+
const id = getAccountSet("anthropic")!.activeAccountId;
332+
const credential = getAccountCredential("anthropic", id)!;
333+
writeOAuthRefreshIntent("anthropic", id, credentialGeneration(credential));
334+
seedClaudeCredentials("disk", "rt-new", Date.now() + 3600_000);
335+
let refreshCalls = 0;
336+
337+
await expect(refreshAnthropicAccountWithLock("anthropic", id, {
338+
...OAUTH_PROVIDERS.anthropic!,
339+
refresh: async () => { refreshCalls++; throw new Error("must not replay"); },
340+
}, credential)).resolves.toBe("disk");
341+
342+
expect(refreshCalls).toBe(0);
343+
expect(getAccountCredential("anthropic", id)?.refresh).toBe("rt-new");
344+
expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined();
345+
});
346+
347+
test("Anthropic successful refresh clears its intent and the new generation can refresh", async () => {
348+
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
349+
const id = getAccountSet("anthropic")!.activeAccountId;
350+
const calls: string[] = [];
351+
const def = {
352+
...OAUTH_PROVIDERS.anthropic!,
353+
refresh: async (refresh: string) => {
354+
calls.push(refresh);
355+
return calls.length === 1
356+
? { access: "fresh-1", refresh: "rt-new-1", expires: 1 }
357+
: { access: "fresh-2", refresh: "rt-new-2", expires: Date.now() + 3600_000 };
358+
},
359+
};
360+
361+
await expect(refreshAnthropicAccountWithLock("anthropic", id, def, getAccountCredential("anthropic", id)!)).resolves.toBe("fresh-1");
362+
expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined();
363+
await expect(refreshAnthropicAccountWithLock("anthropic", id, def, getAccountCredential("anthropic", id)!)).resolves.toBe("fresh-2");
364+
365+
expect(calls).toEqual(["rt-old", "rt-new-1"]);
366+
expect(getAccountCredential("anthropic", id)?.refresh).toBe("rt-new-2");
367+
expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined();
368+
});
369+
293370
test("Anthropic late terminal failure does not mark a superseding generation", async () => {
294371
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
295372
const id = getAccountSet("anthropic")!.activeAccountId;

0 commit comments

Comments
 (0)