Skip to content

Commit 71f3798

Browse files
committed
fix(oauth): preserve Anthropic intent across uncertain refresh outcomes
A timeout, lost response, unreadable body, or credential-store failure can happen after Anthropic consumed and rotated a refresh token. Clearing the durable intent lets the next attempt replay the old token, risking reuse handling and forced reauthentication. The store's uncertain flag does not cover these request outcomes. Track the pre-dispatch boundary explicitly. Clear a non-terminal intent only before dispatch or after the adapter reports an explicit non-success HTTP response; retain it after all other post-dispatch outcomes and after provider success followed by persistence failure. Intent cleanup is secondary to the refresh result. A failed unlink now leaves the replay guard in place without replacing the original provider error, pre-dispatch abort, terminal login result, or successfully persisted credential. Cover definite 503 retry, uncertain post-dispatch failure, pre-dispatch abort, cleanup failure, post-provider persistence failure, and post-persist cleanup failure.
1 parent e5e265f commit 71f3798

2 files changed

Lines changed: 181 additions & 18 deletions

File tree

src/oauth/index.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,45 @@ function terminal(error:unknown):boolean{
562562
if (error instanceof RefreshIntentIOError) return false;
563563
return isTerminalRefreshError(error);
564564
}
565+
566+
/**
567+
* True when the token endpoint definitively answered and rejected the request.
568+
*
569+
* The Anthropic adapter attaches an HTTP status only to an explicit non-success response,
570+
* which is the retryable rejection this PR handles. Everything else (timeout, dropped
571+
* connection, a body that could not be read or parsed, or a local persistence fault) leaves
572+
* the outcome unknown: the server may already have rotated the token, and a blind replay
573+
* could trip refresh-token-reuse revocation. Those cases must keep the refresh intent.
574+
*
575+
* Deliberately narrower than `terminal()`, which asks whether the CREDENTIAL is dead.
576+
* This asks the different question of whether the ATTEMPT is known to have failed.
577+
*/
578+
function definitivelyAnswered(error: unknown): boolean {
579+
if (error instanceof AnthropicTokenError) return error.httpStatus !== undefined;
580+
return false;
581+
}
582+
583+
/**
584+
* Intent cleanup is secondary to the refresh outcome it protects.
585+
*
586+
* A filesystem failure here must not replace a provider error, a pre-dispatch abort, or a
587+
* successfully persisted credential. Leaving the intent in place is the conservative fallback:
588+
* it blocks replay until the local persistence problem is repaired.
589+
*/
590+
function clearAnthropicRefreshIntentBestEffort(
591+
provider: string,
592+
accountId: string,
593+
generation: string,
594+
): boolean {
595+
try {
596+
return clearOAuthRefreshIntent(provider, accountId, generation);
597+
} catch {
598+
console.warn(
599+
"[opencodex] Anthropic refresh intent cleanup failed; preserving the durable replay guard.",
600+
);
601+
return false;
602+
}
603+
}
565604
function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;}
566605
function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials {
567606
return {
@@ -677,39 +716,50 @@ export async function refreshAnthropicAccountWithLock(
677716
return stored.access;
678717
}
679718

719+
let refreshMayHaveReachedProvider = false;
680720
try {
681721
writeOAuthRefreshIntent(provider, accountId, generation, now(), deps.flight?.flightId);
682722
if (deps.signal?.aborted) throw deps.signal.reason;
723+
// From this point on, even a synchronous client error is conservatively post-dispatch:
724+
// the provider may have received and rotated the refresh token before the caller learned
725+
// the outcome.
726+
refreshMayHaveReachedProvider = true;
683727
if (deps.flight) deps.flight.dispatched = true;
684728
const fresh = merged(await def.refresh(stored.refresh, deps.signal), stored);
685729
const outcome = await mergeAccountCredential(provider, accountId, fresh, {
686730
expectedGeneration: generation,
687731
afterPrePersistRead: deps.afterPrePersistRead,
688732
});
689733
if (outcome.superseded) {
690-
clearOAuthRefreshIntent(provider, accountId, generation);
734+
clearAnthropicRefreshIntentBestEffort(provider, accountId, generation);
691735
if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
692736
throw new OAuthLoginRequiredError(provider);
693737
}
694-
clearOAuthRefreshIntent(provider, accountId, generation);
738+
// The rotated credential is durable now. A cleanup failure must not turn that committed
739+
// success into a refresh failure; the old-generation intent remains a conservative guard.
740+
clearAnthropicRefreshIntentBestEffort(provider, accountId, generation);
695741
return fresh.access;
696742
} catch (error) {
697743
if (error instanceof OAuthMutationBusyError || error instanceof OAuthTokenRefreshStaleError) throw error;
698744
if (!terminal(error)) {
699-
// A non-terminal failure means the credential was never rejected, so the caller is
700-
// told to retry. Leaving the intent behind contradicted that: the next attempt hit
701-
// the pending-intent branch above and raised OAuthLoginRequiredError, so one 503 or
702-
// timeout locked the account out of refresh entirely until manual re-auth — even
703-
// once upstream recovered. Clear it so the promised retry can actually happen.
745+
// A non-terminal failure tells the caller to retry, but the intent outlived it, so
746+
// the next attempt hit the pending-intent branch above and raised
747+
// OAuthLoginRequiredError. One 503 locked the account out of refresh until manual
748+
// re-auth even after upstream recovered.
704749
//
705-
// The replay guard is preserved by `uncertain`: a refresh whose outcome is genuinely
706-
// unknown surfaces as an uncertain intent from the store, which this path never
707-
// clears, and a superseded owner still leaves through OAuthTokenRefreshStaleError.
708-
clearOAuthRefreshIntent(provider, accountId, generation);
750+
// Only clear the intent when the server DEFINITIVELY answered and rejected the
751+
// request. The adapter attaches an HTTP status only to that explicit non-success
752+
// response. A timeout, a dropped connection, or an unreadable/unparseable body
753+
// carries no status: the server may already have
754+
// rotated the token, and replaying it could trip refresh-token-reuse revocation.
755+
// Those outcomes keep the intent so the guard still refuses a blind replay.
756+
if (!refreshMayHaveReachedProvider || definitivelyAnswered(error)) {
757+
clearAnthropicRefreshIntentBestEffort(provider, accountId, generation);
758+
}
709759
throw error;
710760
}
711761
await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
712-
clearOAuthRefreshIntent(provider, accountId, generation);
762+
clearAnthropicRefreshIntentBestEffort(provider, accountId, generation);
713763
throw new OAuthLoginRequiredError(provider);
714764
}
715765
} finally {

tests/oauth-refresh.test.ts

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -518,13 +518,12 @@ describe("oauth refresh hardening", () => {
518518
});
519519

520520
/**
521-
* A non-terminal refresh failure is reported as retryable, but the refresh intent outlived
522-
* it. The next attempt then hit the pending-intent branch and raised OAuthLoginRequiredError,
523-
* so a single 503 or timeout locked the account out of refresh until manual re-authentication
524-
* even after upstream recovered. The replay guard is unaffected: an intent whose outcome is
525-
* genuinely unknown is reported `uncertain` by the store and is still never cleared here.
521+
* A definitive non-terminal HTTP failure is retryable: the endpoint answered with a failure,
522+
* so the durable intent must not turn that promised retry into OAuthLoginRequiredError.
523+
* A timeout or unreadable response is different — the provider may already have rotated the
524+
* token, so that post-dispatch intent remains as the replay guard.
526525
*/
527-
test("a transient Anthropic failure leaves the account refreshable", async () => {
526+
test("a definitive transient Anthropic HTTP failure leaves the account refreshable", async () => {
528527
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
529528
const id = getAccountSet("anthropic")!.activeAccountId;
530529
const transient = new AnthropicTokenError("server", 503, undefined);
@@ -542,6 +541,120 @@ describe("oauth refresh hardening", () => {
542541
expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined();
543542
});
544543

544+
test("an Anthropic refresh with an uncertain post-dispatch outcome preserves its intent", async () => {
545+
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
546+
const id = getAccountSet("anthropic")!.activeAccountId;
547+
const credential = getAccountCredential("anthropic", id)!;
548+
const generation = credentialGeneration(credential);
549+
const timeout = new AnthropicTokenError("timeout", undefined, undefined);
550+
551+
await expect(refreshAnthropicAccountWithLock("anthropic", id, {
552+
...OAUTH_PROVIDERS.anthropic!,
553+
refresh: async () => { throw timeout; },
554+
}, credential)).rejects.toBe(timeout);
555+
556+
expect(readOAuthRefreshIntent("anthropic", id)).toMatchObject({ generation });
557+
expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined();
558+
});
559+
560+
test("a pre-dispatch Anthropic abort clears its unconsumed refresh intent", async () => {
561+
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
562+
const id = getAccountSet("anthropic")!.activeAccountId;
563+
const credential = getAccountCredential("anthropic", id)!;
564+
const aborted = new Error("aborted before dispatch");
565+
const controller = new AbortController();
566+
controller.abort(aborted);
567+
let calls = 0;
568+
569+
await expect(refreshAnthropicAccountWithLock("anthropic", id, {
570+
...OAUTH_PROVIDERS.anthropic!,
571+
refresh: async () => { calls += 1; throw new Error("must not run"); },
572+
}, credential, { signal: controller.signal })).rejects.toBe(aborted);
573+
574+
expect(calls).toBe(0);
575+
expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined();
576+
});
577+
578+
test("intent cleanup failure preserves the original Anthropic HTTP error", async () => {
579+
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
580+
const id = getAccountSet("anthropic")!.activeAccountId;
581+
const credential = getAccountCredential("anthropic", id)!;
582+
const generation = credentialGeneration(credential);
583+
const transient = new AnthropicTokenError("server", 503, undefined);
584+
const clearFailure = new Error("intent unlink failed");
585+
const clearSpy = spyOn(storeModule, "clearOAuthRefreshIntent").mockImplementation(() => {
586+
throw clearFailure;
587+
});
588+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
589+
try {
590+
await expect(refreshAnthropicAccountWithLock("anthropic", id, {
591+
...OAUTH_PROVIDERS.anthropic!,
592+
refresh: async () => { throw transient; },
593+
}, credential)).rejects.toBe(transient);
594+
595+
expect(clearSpy).toHaveBeenCalled();
596+
expect(readOAuthRefreshIntent("anthropic", id)).toMatchObject({ generation });
597+
expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined();
598+
} finally {
599+
warnSpy.mockRestore();
600+
clearSpy.mockRestore();
601+
}
602+
});
603+
604+
test("Anthropic persistence failure after provider success preserves the replay guard", async () => {
605+
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
606+
const id = getAccountSet("anthropic")!.activeAccountId;
607+
const credential = getAccountCredential("anthropic", id)!;
608+
const generation = credentialGeneration(credential);
609+
const persistenceFailure = new Error("credential persistence failed");
610+
const mergeSpy = spyOn(storeModule, "mergeAccountCredential").mockImplementation(async () => {
611+
throw persistenceFailure;
612+
});
613+
try {
614+
await expect(refreshAnthropicAccountWithLock("anthropic", id, {
615+
...OAUTH_PROVIDERS.anthropic!,
616+
refresh: async () => ({
617+
access: "fresh",
618+
refresh: "rt-fresh",
619+
expires: Date.now() + 3_600_000,
620+
}),
621+
}, credential)).rejects.toBe(persistenceFailure);
622+
623+
expect(readOAuthRefreshIntent("anthropic", id)).toMatchObject({ generation });
624+
expect(getAccountCredential("anthropic", id)).toEqual(credential);
625+
expect(getAccountSet("anthropic")!.accounts.find(account => account.id === id)!.needsReauth).toBeUndefined();
626+
} finally {
627+
mergeSpy.mockRestore();
628+
}
629+
});
630+
631+
test("post-persist intent cleanup failure does not turn Anthropic refresh success into failure", async () => {
632+
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
633+
const id = getAccountSet("anthropic")!.activeAccountId;
634+
const credential = getAccountCredential("anthropic", id)!;
635+
const oldGeneration = credentialGeneration(credential);
636+
const clearSpy = spyOn(storeModule, "clearOAuthRefreshIntent").mockImplementation(() => {
637+
throw new Error("intent unlink failed");
638+
});
639+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
640+
try {
641+
await expect(refreshAnthropicAccountWithLock("anthropic", id, {
642+
...OAUTH_PROVIDERS.anthropic!,
643+
refresh: async () => ({
644+
access: "fresh",
645+
refresh: "rt-fresh",
646+
expires: Date.now() + 3_600_000,
647+
}),
648+
}, credential)).resolves.toBe("fresh");
649+
650+
expect(getAccountCredential("anthropic", id)?.access).toBe("fresh");
651+
expect(readOAuthRefreshIntent("anthropic", id)).toMatchObject({ generation: oldGeneration });
652+
} finally {
653+
warnSpy.mockRestore();
654+
clearSpy.mockRestore();
655+
}
656+
});
657+
545658
test("Anthropic post-dispatch stale flight replacement stays retryable without replay or reauth", async () => {
546659
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
547660
const id = getAccountSet("anthropic")!.activeAccountId;

0 commit comments

Comments
 (0)