feat(providers): derive + surface expiry for JWT-bearing web cookies (#11497) (#11505)

Validated in a combined 3-PR batch worktree off release/v3.8.51 tip (a sibling PR from the same author, #11495, was held out — see its own comment for the isolated finding, unrelated to this diff).
- Focused test: web-cookie-expiry.test.ts — part of batch's 94/94 node:test run
- typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity — all OK
- Full-repo lint: 228 pre-existing dashboard react-hooks/* findings, unrelated to this diff

Thanks for closing a real trust gap — operators deserve to know a cookie is about to expire before a live request fails.
This commit is contained in:
Paijo
2026-08-26 04:22:34 +07:00
committed by GitHub
parent 8a8d0cf336
commit 026d26edb7
5 changed files with 248 additions and 7 deletions

View File

@@ -4,6 +4,7 @@
// ConnectionRow (and its local helpers CooldownTimer, inferErrorType,
// getStatusPresentation) moved out of ProviderDetailPageClient.tsx.
import { readCookieExpiresAt } from "@/shared/utils/webCookieExpiry";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Badge, Button, Toggle } from "@/shared/components";
@@ -412,16 +413,21 @@ export default function ConnectionRow({
// T12: token expiry status — lazy init avoids calling Date.now() during render;
// updates every 30s via interval only (no sync setState in effect body).
// Prefer tokenExpiresAt (updated on each refresh) over expiresAt (original grant date).
const effectiveExpiresAt = connection.tokenExpiresAt || connection.expiresAt;
// #11497: cookie rows with a decodable JWT credential carry a persisted
// cookieExpiresAt — feed it into the same countdown badge OAuth rows use.
const cookieExpiresAt = readCookieExpiresAt(connection.providerSpecificData);
const effectiveExpiresAt =
connection.tokenExpiresAt || connection.expiresAt || cookieExpiresAt;
const hasExpirySource = isOAuth || Boolean(cookieExpiresAt);
const getTokenMinsLeft = () => {
if (!isOAuth || !effectiveExpiresAt) return null;
if (!hasExpirySource || !effectiveExpiresAt) return null;
const expiresMs = new Date(effectiveExpiresAt).getTime();
return Math.floor((expiresMs - Date.now()) / 60000);
};
const [tokenMinsLeft, setTokenMinsLeft] = useState<number | null>(getTokenMinsLeft);
useEffect(() => {
if (!isOAuth || !effectiveExpiresAt) return;
if (!hasExpirySource || !effectiveExpiresAt) return;
const update = () => {
const expiresMs = new Date(effectiveExpiresAt).getTime();
setTokenMinsLeft(Math.floor((expiresMs - Date.now()) / 60000));
@@ -429,7 +435,7 @@ export default function ConnectionRow({
update();
const iv = setInterval(update, 30000);
return () => clearInterval(iv);
}, [isOAuth, effectiveExpiresAt]);
}, [hasExpirySource, effectiveExpiresAt]);
useEffect(() => {
const checkCooldown = () => {

View File

@@ -4,6 +4,7 @@ import { useTranslations } from "next-intl";
import Badge from "@/shared/components/Badge";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import { readCookieExpiresAt } from "@/shared/utils/webCookieExpiry";
import { formatCountdown, type CardStatus } from "../utils";
import { translateUsageOrFallback } from "../i18nFallback";
@@ -53,10 +54,12 @@ export default function QuotaCardHeader({
// OAuth token expiry — informative only. Shown small/blue for connections that
// expose a concrete token expiry (e.g. Codex), so an operator can see at a
// glance when the access token rotates. Hidden for API-key / no-expiry connections.
// #11497: cookie rows persist a derived cookieExpiresAt when their pasted
// credential embeds a JWT with an exp claim — surface the same countdown.
const tokenExpiryIso =
connection.authType === "oauth"
? connection.tokenExpiresAt || connection.expiresAt || null
: null;
: readCookieExpiresAt(connection.providerSpecificData);
const tokenExpiryMs = tokenExpiryIso ? new Date(tokenExpiryIso).getTime() : NaN;
const hasTokenExpiry = Number.isFinite(tokenExpiryMs);
const tokenCountdown = hasTokenExpiry ? formatCountdown(tokenExpiryIso) : null;

View File

@@ -19,6 +19,8 @@ import {
} from "@omniroute/open-sse/services/apiKeyRotator.ts";
import { invalidateReasoningRoutingRuleCache } from "./reasoningRoutingRules";
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
import { withDerivedCookieExpiry } from "@/shared/utils/webCookieExpiry";
import { WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers";
import { ensureCodexFingerprintSeed } from "@omniroute/open-sse/config/codexIdentity.ts";
import { bumpProxyConfigGeneration, getSettings } from "./settings";
import {
@@ -52,13 +54,35 @@ function normalizeConnectionProviderSpecificData(
existingProviderSpecificData?: unknown
) {
const normalized = normalizeProviderSpecificData(provider, providerSpecificData);
if (provider !== "codex") return normalized;
const withExpiry = withDerivedCookieExpiryForProvider(provider, normalized, credentials);
if (provider !== "codex") return withExpiry;
return ensureCodexFingerprintSeed(
normalized,
withExpiry,
credentials,
(existingProviderSpecificData as Record<string, unknown> | null) ?? null
);
}
function withDerivedCookieExpiryForProvider(
provider: string | null,
providerSpecificData: unknown,
credentials: { accessToken?: unknown; refreshToken?: unknown } | unknown
): Record<string, unknown> {
const key = String(provider || "").toLowerCase();
if (!(WEB_COOKIE_PROVIDERS as Record<string, unknown>)[key]) {
// Both branches must satisfy the Codex seed signature below; the
// passthrough keeps whatever shape normalization already returned.
return (providerSpecificData ?? {}) as Record<string, unknown>;
}
const source = credentials as Record<string, unknown> | null;
const credential =
source && typeof source === "object"
? (typeof source.apiKey === "string" && source.apiKey) ||
(typeof source.cookie === "string" && source.cookie) ||
null
: null;
return withDerivedCookieExpiry(providerSpecificData, credential);
}
import {
withNullableMaxConcurrent,
withNullableQuotaWindowThresholds,

View File

@@ -0,0 +1,117 @@
/**
* Cookie expiry derivation (#11497).
*
* Some web-cookie credentials embed a standard JWT whose payload carries an
* `exp` claim (ChatGPT's `__Secure-next-auth.session-token`, the Qwen/Z.ai
* localStorage tokens users paste as their cookie). Others are opaque
* (`claude` sessionKey, grok `sso`) and MUST stay undated — no false precision.
*
* Pure module: no DB, no network. Shared by the connection save path (server)
* and the dashboard expiry badges (client), hence it lives under src/shared.
*/
const MAX_SCANNED_VALUE_CHARS = 4096;
function base64UrlToJson(value: string): unknown {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
// Guard against prototype-pollution style payloads: JSON.parse only, no reviver.
return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
}
function expMsFromPayload(payload: unknown): number | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
const exp = (payload as Record<string, unknown>).exp;
if (typeof exp !== "number" || !Number.isFinite(exp) || exp <= 0) return null;
return exp * 1000;
}
/**
* Decode the `exp` claim of a JWT-shaped string into epoch milliseconds.
* Returns null for anything that is not exactly `header.payload.signature`
* with a JSON-object payload carrying a positive numeric `exp`.
*/
export function decodeJwtPayloadExp(value: unknown): number | null {
if (typeof value !== "string") return null;
const raw = value.trim();
if (!raw || raw.length > MAX_SCANNED_VALUE_CHARS) return null;
const parts = raw.split(".");
if (parts.length !== 3) return null;
const [header, payloadSeg] = parts;
if (!header || !payloadSeg) return null;
try {
return expMsFromPayload(base64UrlToJson(payloadSeg));
} catch {
return null;
}
}
function* candidateJwtValues(credential: string): Generator<string> {
const trimmed = credential.trim();
if (!trimmed || trimmed.length > MAX_SCANNED_VALUE_CHARS * 8) return;
// Whole-credential first (users often paste just the token), then each
// cookie-pair value of a pasted Cookie header.
yield trimmed;
for (const pair of trimmed.split(";")) {
const eq = pair.indexOf("=");
if (eq <= 0) continue;
const value = pair.slice(eq + 1).trim();
if (value) yield value;
}
}
/**
* First derivable expiry (epoch ms) found in a pasted cookie credential —
* either the credential itself is a JWT or one of its cookie-pair values is.
*/
export function deriveCookieExpiryMs(credential: unknown): number | null {
if (typeof credential !== "string") return null;
for (const value of candidateJwtValues(credential)) {
const ms = decodeJwtPayloadExp(value);
if (ms !== null) return ms;
}
return null;
}
/** ISO string form of {@link deriveCookieExpiryMs}, or null. */
export function deriveCookieExpiryIso(credential: unknown): string | null {
const ms = deriveCookieExpiryMs(credential);
return ms === null ? null : new Date(ms).toISOString();
}
/** Structural view of stored providerSpecificData this module reads/writes. */
export interface CookieExpiryData {
cookieExpiresAt?: string | null;
}
export function readCookieExpiresAt(providerSpecificData: unknown): string | null {
if (!providerSpecificData || typeof providerSpecificData !== "object") return null;
const value = (providerSpecificData as Record<string, unknown>).cookieExpiresAt;
return typeof value === "string" && value ? value : null;
}
/**
* Merge a derived `cookieExpiresAt` into providerSpecificData for a save.
*
* Recomputed on EVERY call so re-pasting a fresh cookie refreshes the date,
* and an opaque replacement cookie DROPS the stale date instead of leaving a
* lie on the row. Explicit user-provided values win only when no credential
* is present to re-derive from.
*/
export function withDerivedCookieExpiry(
providerSpecificData: unknown,
credential: unknown
): Record<string, unknown> {
const base =
providerSpecificData && typeof providerSpecificData === "object"
? { ...(providerSpecificData as Record<string, unknown>) }
: {};
if (typeof credential !== "string" || !credential.trim()) return base;
const iso = deriveCookieExpiryIso(credential);
if (iso) {
base.cookieExpiresAt = iso;
} else {
delete base.cookieExpiresAt;
}
return base;
}

View File

@@ -0,0 +1,91 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
decodeJwtPayloadExp,
deriveCookieExpiryIso,
readCookieExpiresAt,
withDerivedCookieExpiry,
} from "../../src/shared/utils/webCookieExpiry.ts";
function jwt(payload: object): string {
const enc = (obj: object) =>
Buffer.from(JSON.stringify(obj)).toString("base64url");
return `${enc({ alg: "HS256", typ: "JWT" })}.${enc(payload)}.sig`;
}
const EXP_AT = 1_800_000_000_000; // 2027-01-15T06:40:00.000Z
describe("web-cookie expiry derivation (#11497)", () => {
it("decodes a standard JWT exp into epoch ms", () => {
assert.equal(decodeJwtPayloadExp(jwt({ exp: EXP_AT / 1000 })), EXP_AT);
});
it("accepts url-safe base64 without padding", () => {
const seg = Buffer.from(JSON.stringify({ exp: 1234567890 }))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const token = `aaa.${seg}.bbb`;
assert.equal(decodeJwtPayloadExp(token), 1234567890000);
});
it("scans cookie-pair values of a pasted Cookie header", () => {
const header = `cf_clearance=x.y.z; __Secure-next-auth.session-token=${jwt({
exp: EXP_AT / 1000,
})}; other=1`;
assert.equal(deriveCookieExpiryIso(header), new Date(EXP_AT).toISOString());
});
it("returns null for opaque cookies (claude sessionKey, grok sso)", () => {
assert.equal(decodeJwtPayloadExp("sk-ant-sid01-abcdef"), null);
assert.equal(
deriveCookieExpiryIso("sso=eyJhbGciOiJIUzUxMiJ9.notjson.sig; sso-rw=1"),
null
);
});
it("returns null for malformed or non-positive payloads", () => {
assert.equal(decodeJwtPayloadExp("a.b"), null);
assert.equal(decodeJwtPayloadExp("a.b.c"), null);
assert.equal(decodeJwtPayloadExp(jwt({})), null);
assert.equal(decodeJwtPayloadExp(jwt({ exp: 0 })), null);
assert.equal(decodeJwtPayloadExp(jwt({ exp: -5 })), null);
assert.equal(decodeJwtPayloadExp(`a.${Buffer.from("[1]").toString("base64url")}.c`), null);
assert.equal(decodeJwtPayloadExp(`a.${Buffer.from("null").toString("base64url")}.c`), null);
assert.equal(decodeJwtPayloadExp(null), null);
assert.equal(decodeJwtPayloadExp(42), null);
});
it("readCookieExpiresAt only accepts non-empty strings", () => {
assert.equal(readCookieExpiresAt({ cookieExpiresAt: "2027-01-01T00:00:00.000Z" }), "2027-01-01T00:00:00.000Z");
assert.equal(readCookieExpiresAt({ cookieExpiresAt: "" }), null);
assert.equal(readCookieExpiresAt({}), null);
assert.equal(readCookieExpiresAt(null), null);
assert.equal(readCookieExpiresAt("str"), null);
});
it("withDerivedCookieExpiry sets the date and preserves sibling keys", () => {
const out = withDerivedCookieExpiry(
{ spaceId: "s1" },
jwt({ exp: EXP_AT / 1000 })
);
assert.equal(out.cookieExpiresAt, new Date(EXP_AT).toISOString());
assert.equal(out.spaceId, "s1");
});
it("withDerivedCookieExpiry drops the stale date when replaced by an opaque cookie", () => {
const out = withDerivedCookieExpiry(
{ cookieExpiresAt: "2020-01-01T00:00:00.000Z", spaceId: "s1" },
"sk-ant-sid01-new-opaque"
);
assert.equal("cookieExpiresAt" in out, false);
assert.equal(out.spaceId, "s1");
});
it("withDerivedCookieExpiry leaves data untouched when no credential is present", () => {
const original = { cookieExpiresAt: "2020-01-01T00:00:00.000Z" };
const out = withDerivedCookieExpiry(original, "");
assert.deepEqual(out, original);
});
});