mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 09:02:11 +03:00
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:
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
117
src/shared/utils/webCookieExpiry.ts
Normal file
117
src/shared/utils/webCookieExpiry.ts
Normal 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;
|
||||
}
|
||||
91
tests/unit/web-cookie-expiry.test.ts
Normal file
91
tests/unit/web-cookie-expiry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user