From 28c840692421b8b356b1d3275425be2a53c9eadf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 01:16:51 +0200 Subject: [PATCH] fix(ai): park a revoked Codex family and stop at a disconnect mid-refresh Under the refresh lock, with the row still holding the token just spent, no peer rotated it, so refresh_token_reused is the issuer refusing the whole family: it now parks the sign-in instead of being asked again after every cooldown. Outside the lock it stays a lost race. A refresh whose compare-and-set write finds the connection gone (the person disconnected Codex meanwhile) now refuses with reconnect-required instead of using the rotated token once more. --- src/lib/ai/provider.ts | 26 +++++++-- .../integration/codex-credential-park.test.ts | 54 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 2a5e24b1e..9b03bfc5d 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -14,6 +14,7 @@ import { decryptCodexCreds, encryptAdminCodexCreds, decryptAdminCodexCreds, + CodexRefreshError, type CodexCreds, } from "./codex-oauth"; import { isPublicUrl } from "@/lib/validations/notifications"; @@ -287,16 +288,33 @@ async function runCodexRefresh( // A sign-in replaced the token while the issuer refused the old one. return { fresh: now.creds }; } - if (!isDeadCodexSignIn(error)) return { failed: error }; + // `refresh_token_reused` is a dead grant here and only here: under the + // lock, with the row still holding the very token just spent, no peer + // of ours rotated it, so the issuer is refusing the whole family (a + // revoked sign-in answers every refresh so). Outside the lock it is + // the lost race it usually is (see DEAD_REFRESH_TOKEN_CODES). + const refused = + oauthErrorOf(error) === "refresh_token_reused" + ? new CodexRefreshError(401, "refresh_token_reused") + : error; + if (!isDeadCodexSignIn(refused)) return { failed: error }; return { - refused: error, + refused, parked: (await store.park(tx, row.refreshEncrypted)) === 1, }; } if ((await store.writeRotated(tx, row.refreshEncrypted, fresh)) === 0) { - // A sign-in replaced the row meanwhile; it wins over this refresh. + // A sign-in replaced the row meanwhile; it wins over this refresh. A + // row with no usable credential left was disconnected meanwhile: the + // person took Codex away, so nothing may be sent with it any more. const now = await store.read(tx); - if (now?.creds) return { fresh: now.creds }; + if (now?.status === "connected" && now.creds) { + return { fresh: now.creds }; + } + return { + refused: codexReconnectRequired("disconnected during refresh"), + parked: false, + }; } return { fresh }; }, diff --git a/tests/integration/codex-credential-park.test.ts b/tests/integration/codex-credential-park.test.ts index 811fefd0c..e4d283eb4 100644 --- a/tests/integration/codex-credential-park.test.ts +++ b/tests/integration/codex-credential-park.test.ts @@ -319,3 +319,57 @@ describe("two refreshes of one Codex token", () => { expect(parkSignals("codex")).toHaveLength(0); }); }); + +describe("a Codex refresh under the lock", () => { + it("parks a sign-in whose every refresh the issuer answers as reused", async () => { + const user = await seedCodexUser(); + // The issuer revoked the token family: no peer rotated anything, yet each + // refresh comes back reused. + vi.mocked(refreshDeviceTokens).mockRejectedValue( + new CodexRefreshError(400, "refresh_token_reused"), + ); + + const chain = await resolveProviderChain(user.id); + + expect(chain.map((p) => p.providerType)).not.toContain("codex"); + expect( + ( + await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { codexConnectionStatus: true }, + }) + ).codexConnectionStatus, + ).toBe("expired"); + expect(parkSignals("codex")).toHaveLength(1); + expect(parkSignals("codex")[0][0].meta?.oauth_error).toBe( + "refresh_token_reused", + ); + }); + + it("does not use Codex once more when the person disconnected it meanwhile", async () => { + const user = await seedCodexUser(); + vi.mocked(refreshDeviceTokens).mockImplementation(async () => { + await prisma.user.update({ + where: { id: user.id }, + data: { + codexAccessTokenEncrypted: null, + codexRefreshTokenEncrypted: null, + codexConnectionStatus: "disconnected", + }, + }); + return creds("rotated", 10 * HOUR_MS); + }); + + const chain = await resolveProviderChain(user.id); + + expect(chain.map((p) => p.providerType)).not.toContain("codex"); + expect( + ( + await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { codexConnectionStatus: true }, + }) + ).codexConnectionStatus, + ).toBe("disconnected"); + }); +});