Merge PR 3030 into release/v3.8.8

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-01 03:02:34 -03:00
committed by GitHub
3 changed files with 96 additions and 0 deletions

View File

@@ -63,6 +63,15 @@
### Fixed
- **codex/providers:** `POST /api/providers/[id]/refresh` (the manual/auto "refresh
token" endpoint) no longer rotates rotating-refresh providers (Codex/OpenAI share
one Auth0 `client_id`). This was the last unguarded proactive-refresh entry point:
when the dashboard auto-refreshed every expiring connection on a page load (or an
old cached frontend bulk-called it), each Codex account's single-use refresh_token
was rotated, and Auth0 revoked the whole token family (`openai/codex#9648`) — every
account but the last died with `[403] <!DOCTYPE`. The endpoint now skips proactive
rotation for rotating providers and defers to the reactive, serialized 401 path
(same guard as `refreshAndUpdateCredentials` and the connection-test route).
- **codex/quota:** opening the Quota / Providers dashboard no longer disconnects
Codex multi-account setups. The quota-sync path
(`refreshAndUpdateCredentials`) proactively refreshed every connection — for

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById, updateProviderConnection } from "@/lib/db/providers";
import { getAccessToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts";
type RefreshResult = {
accessToken?: string;
@@ -44,6 +45,33 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id
}
const provider = connection.provider;
// Codex multi-account family-revocation cascade guard.
// Rotating-refresh providers (Codex/OpenAI share one Auth0 client_id, etc.)
// mint a single-use refresh_token on every refresh. This endpoint is invoked
// per-connection by the dashboard (incl. an OLD cached frontend that bulk-
// refreshes every expiring connection on a page load); rotating several
// sibling accounts makes Auth0 revoke the whole token family
// (openai/codex#9648), killing every account but the last. Never proactively
// rotate a rotating provider here — the access_token is reused as-is and
// genuine expiry is handled by the reactive, serialized 401 path on the next
// real request. This was the last unguarded proactive-refresh entry point
// (refreshAndUpdateCredentials and the connection-test route are already
// guarded). Non-rotating providers keep refreshing on demand below.
if (rotationGroupFor(provider) !== null) {
return NextResponse.json({
success: true,
skipped: true,
connectionId: id,
provider,
message:
"Rotating-refresh provider: the token refreshes automatically on the next request. " +
"Manual/bulk refresh is intentionally skipped to avoid Auth0 token-family revocation.",
expiresAt: connection.tokenExpiresAt || connection.expiresAt || null,
refreshedAt: new Date().toISOString(),
});
}
const credentials = {
connectionId: id,
accessToken: connection.accessToken,

View File

@@ -0,0 +1,59 @@
/**
* Codex multi-account family-revocation cascade — manual/auto token refresh guard.
*
* `POST /api/providers/[id]/refresh` is the explicit "refresh this token" endpoint.
* It rotates the refresh_token via getAccessToken. For rotating-refresh providers
* (Codex/OpenAI share one Auth0 client_id) rotating several sibling accounts —
* which happens when the dashboard auto-refreshes every expiring connection on a
* page load, or when an OLD cached frontend bulk-calls this endpoint — makes Auth0
* revoke the whole token family (openai/codex#9648) and kills every account but
* the last. This was the LAST unguarded proactive-refresh entry point for rotating
* providers (refreshAndUpdateCredentials and the connection-test route are already
* guarded). It must skip the proactive refresh and defer to the reactive,
* serialized 401 path. Non-rotating providers keep refreshing on demand.
*
* Mirrors the source-assertion style of token-refresh-race-comprehensive.test.ts.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import path from "node:path";
const root = path.resolve(import.meta.dirname, "../..");
const ROUTE = path.join(root, "src/app/api/providers/[id]/refresh/route.ts");
const read = () => readFile(ROUTE, "utf8");
test("manual refresh route imports rotationGroupFor", async () => {
const src = await read();
assert.match(
src,
/import\s*\{[^}]*rotationGroupFor[^}]*\}\s*from\s*["'][^"']*refreshSerializer/,
"refresh route must import rotationGroupFor to detect rotating providers"
);
});
test("manual refresh route skips proactive refresh for rotating providers BEFORE calling getAccessToken", async () => {
const src = await read();
const guardIdx = src.search(/rotationGroupFor\s*\(\s*[\w.]*provider[\w.]*\s*\)\s*!==\s*null/);
assert.ok(
guardIdx >= 0,
"refresh route must guard with `rotationGroupFor(provider) !== null` to skip rotating providers"
);
const getAccessTokenIdx = src.indexOf("getAccessToken(");
assert.ok(getAccessTokenIdx >= 0, "refresh route still calls getAccessToken for non-rotating providers");
assert.ok(
guardIdx < getAccessTokenIdx,
"the rotating-provider guard must run BEFORE getAccessToken so the rotating refresh_token is never exercised"
);
// The guard short-circuits with an early return (no token rotation).
const guardBlock = src.slice(guardIdx, getAccessTokenIdx);
assert.match(
guardBlock,
/return\b/,
"the rotating-provider guard must return early (defer to the reactive 401 path) instead of refreshing"
);
});