From 026d26edb7f0d6bcc6ee351f6badaf5df5f05fe2 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:22:34 +0700 Subject: [PATCH] feat(providers): derive + surface expiry for JWT-bearing web cookies (#11497) (#11505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../[id]/components/ConnectionRow.tsx | 14 ++- .../ProviderLimits/parts/QuotaCardHeader.tsx | 5 +- src/lib/db/providers.ts | 28 ++++- src/shared/utils/webCookieExpiry.ts | 117 ++++++++++++++++++ tests/unit/web-cookie-expiry.test.ts | 91 ++++++++++++++ 5 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 src/shared/utils/webCookieExpiry.ts create mode 100644 tests/unit/web-cookie-expiry.test.ts diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx index ddfe36ba2e..a6e6f3ec31 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx @@ -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(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 = () => { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardHeader.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardHeader.tsx index c7cebb3428..a5bd4ada00 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardHeader.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardHeader.tsx @@ -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; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index e63b85bf09..ae79516db2 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -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 | null) ?? null ); } + +function withDerivedCookieExpiryForProvider( + provider: string | null, + providerSpecificData: unknown, + credentials: { accessToken?: unknown; refreshToken?: unknown } | unknown +): Record { + const key = String(provider || "").toLowerCase(); + if (!(WEB_COOKIE_PROVIDERS as Record)[key]) { + // Both branches must satisfy the Codex seed signature below; the + // passthrough keeps whatever shape normalization already returned. + return (providerSpecificData ?? {}) as Record; + } + const source = credentials as Record | 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, diff --git a/src/shared/utils/webCookieExpiry.ts b/src/shared/utils/webCookieExpiry.ts new file mode 100644 index 0000000000..56961da340 --- /dev/null +++ b/src/shared/utils/webCookieExpiry.ts @@ -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).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 { + 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).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 { + const base = + providerSpecificData && typeof providerSpecificData === "object" + ? { ...(providerSpecificData as Record) } + : {}; + if (typeof credential !== "string" || !credential.trim()) return base; + const iso = deriveCookieExpiryIso(credential); + if (iso) { + base.cookieExpiresAt = iso; + } else { + delete base.cookieExpiresAt; + } + return base; +} diff --git a/tests/unit/web-cookie-expiry.test.ts b/tests/unit/web-cookie-expiry.test.ts new file mode 100644 index 0000000000..ea51a8ea86 --- /dev/null +++ b/tests/unit/web-cookie-expiry.test.ts @@ -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); + }); +});