Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -412,24 +413,29 @@ 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));
};
update();
const iv = setInterval(update, 30000);
return () => clearInterval(iv);
}, [isOAuth, effectiveExpiresAt]);
}, [hasExpirySource, effectiveExpiresAt]);

useEffect(() => {
const checkCooldown = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
28 changes: 26 additions & 2 deletions src/lib/db/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
117 changes: 117 additions & 0 deletions src/shared/utils/webCookieExpiry.ts
Original file line number Diff line number Diff line change
@@ -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;
}
91 changes: 91 additions & 0 deletions tests/unit/web-cookie-expiry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading