fix(auth): dashboard session requires the authenticated claim — Cursor CLI tokens are not sessions (#13375)

Merged as part of the owner batch of 2026-09-11.

This PR had a live worktree in another session, so it sat outside the main 39. Merged on your explicit call, validated first rather than taken on trust: boarded with the other 10 worktree-held PRs into a consolidated worktree off `release/v3.8.51`.

- ESLint over every changed file: no errors
- `typecheck:core` clean; `check:dashboard-typecheck` OK; `check:changelog-integrity` OK
- complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437
- 203 of 208 assertions green. The 5 remaining (`guide-settings-route` ×4, `hard-session-lease-bypass-inventory` ×1) reproduce on the pure tip with nothing from this batch applied.
- `imageGeneration.ts` rebaselined 3259 → 3293 for #12945's image-only-model guard, landed separately in #13392 so nothing was pushed onto a live branch.

⚠️ base-red inherited: #12732 — provider count 356 vs 358 and `open-sse/utils/stream.ts` 3115 > frozen 3098, both reproducing on the pure tip.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-11 22:29:12 -03:00
committed by GitHub
parent 6dc66921a7
commit db6feb6fad
21 changed files with 225 additions and 54 deletions

View File

@@ -0,0 +1 @@
- **fix(auth):** a dashboard session now requires the `authenticated: true` claim that login, OIDC and the session refresh already emit — a JWT merely signed with `JWT_SECRET` (for example the Cursor CLI passthrough token, which any API-key holder can obtain) no longer verifies as the `auth_token` cookie on any route, the WebSocket handshake or the live server; existing sessions keep working ([#13298](https://github.com/diegosouzapw/OmniRoute/issues/13298))

View File

@@ -35,6 +35,14 @@ For dashboard pages and admin operations.
Cookie: auth_token=<JWT signed with JWT_SECRET>
```
A cookie is a session only when the JWT verifies **and** carries `authenticated: true`
(`src/shared/utils/dashboardSessionToken.ts``verifyDashboardSessionToken`). Every
consumer of the cookie (route guard, authz pipeline refresh, WebSocket handshake, live
server, `/api/settings/require-login`, `/api/auth/status`) goes through that helper.
Other JWTs signed with `JWT_SECRET` exist — the Cursor CLI passthrough mints
`iss "omniroute" / aud "cursor-cli"` tokens for key holders — and are never sessions
(#13298).
Verified by `isDashboardSessionAuthenticated()` in `src/shared/utils/apiAuth.ts`. The pipeline auto-refreshes the JWT when it has fewer than 7 days left in its 30-day lifetime.
Some management routes accept **either** mode: cookie OR `Bearer <key>` when the API key has the `manage` (or `admin`) scope. This is what enables the "configurable via API calls" workflow added in v3.8.

View File

@@ -1,25 +1,23 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
function getJwtSecret(): Uint8Array | null {
const secret = process.env.JWT_SECRET?.trim();
return secret ? new TextEncoder().encode(secret) : null;
}
import {
getDashboardJwtSecret,
verifyDashboardSessionToken,
} from "@/shared/utils/dashboardSessionToken";
export async function GET() {
try {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
const secret = getJwtSecret();
const secret = getDashboardJwtSecret();
if (!token || !secret) {
return NextResponse.json({ authenticated: false });
}
await jwtVerify(token, secret);
return NextResponse.json({ authenticated: true });
const payload = await verifyDashboardSessionToken(token, secret);
return NextResponse.json({ authenticated: payload !== null });
} catch {
return NextResponse.json({ authenticated: false });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { getSettings, updateSettings } from "@/lib/db/settings";
import {
@@ -8,23 +7,19 @@ import {
hashManagementPassword,
} from "@/lib/auth/managementPassword";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
getDashboardJwtSecret,
verifyDashboardSessionToken,
} from "@/shared/utils/dashboardSessionToken";
import { getNodeRuntimeSupport } from "@/shared/utils/nodeRuntimeSupport.ts";
import { updateRequireLoginSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
function getJwtSecret(): Uint8Array | null {
const secret = process.env.JWT_SECRET?.trim();
return secret ? new TextEncoder().encode(secret) : null;
}
async function checkSessionAuthenticated(): Promise<boolean> {
try {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
const secret = getJwtSecret();
if (!token || !secret) return false;
await jwtVerify(token, secret);
return true;
return (await verifyDashboardSessionToken(token, getDashboardJwtSecret())) !== null;
} catch {
return false;
}

View File

@@ -1,4 +1,4 @@
import { jwtVerify } from "jose";
import { verifyDashboardSessionToken } from "@/shared/utils/dashboardSessionToken";
import { getSettings } from "@/lib/db/settings";
import { validateApiKey } from "@/lib/db/apiKeys";
@@ -44,12 +44,7 @@ async function hasValidSessionCookie(request: Request): Promise<boolean> {
const token = getCookieValue(request.headers.get("cookie"), "auth_token");
if (!token) return false;
try {
await jwtVerify(token, new TextEncoder().encode(secretValue));
return true;
} catch {
return false;
}
return (await verifyDashboardSessionToken(token, new TextEncoder().encode(secretValue))) !== null;
}
export function extractWsTokenFromUrl(input: string | URL): string | null {

View File

@@ -1,8 +1,9 @@
import { jwtVerify, SignJWT } from "jose";
import { SignJWT } from "jose";
import { NextResponse, type NextRequest } from "next/server";
import { getCachedSettings } from "../../lib/db/readCache";
import { isDraining } from "../../lib/gracefulShutdown";
import { checkBodySize, getBodySizeLimit } from "../../shared/middleware/bodySizeGuard";
import { verifyDashboardSessionToken } from "@/shared/utils/dashboardSessionToken";
import { generateRequestId } from "../../shared/utils/requestId";
import { applyCorsHeaders } from "../cors/origins";
import { validateBrowserMutationOrigin } from "../origin/publicOrigin";
@@ -153,7 +154,13 @@ async function refreshDashboardSessionIfNeeded(
if (!token) return;
try {
const { payload } = await jwtVerify(token, secret);
const payload = await verifyDashboardSessionToken(token, secret);
if (!payload) {
// Not a dashboard session (foreign/expired/claim-less token): drop it so a
// Cursor CLI token can never ride along as the cookie (#13298).
response.cookies.delete("auth_token");
return;
}
const exp = typeof payload.exp === "number" ? payload.exp : null;
if (!exp) return;

View File

@@ -18,9 +18,9 @@
*/
import { WebSocketServer, WebSocket } from "ws";
import { jwtVerify } from "jose";
import { createServer, type IncomingMessage, type ServerResponse } from "http";
import { randomUUID } from "crypto";
import { verifyDashboardSessionToken } from "@/shared/utils/dashboardSessionToken";
// ── Types ─────────────────────────────────────────────────────────────────
@@ -191,13 +191,7 @@ async function isDashboardCookieAuthenticated(
): Promise<boolean> {
const token = getCookieValueFromHeader(request.headers, "auth_token");
if (!token || !process.env.JWT_SECRET) return false;
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
await jwtVerify(token, secret);
return true;
} catch {
return false;
}
return (await verifyDashboardSessionToken(token)) !== null;
}
function extractBearerToken(request: import("http").IncomingMessage): string | null {

View File

@@ -7,10 +7,10 @@
* @module shared/utils/apiAuth
*/
import { jwtVerify } from "jose";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/db/settings";
import { isPublicApiRoute } from "@/shared/constants/publicApiRoutes";
import { verifyDashboardSessionToken } from "@/shared/utils/dashboardSessionToken";
import { extractApiKey } from "@/sse/services/auth";
type RequestLike = {
@@ -247,13 +247,7 @@ export async function isDashboardSessionAuthenticated(
if (!token) return false;
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
await jwtVerify(token, secret);
return true;
} catch {
return false;
}
return (await verifyDashboardSessionToken(token)) !== null;
}
// ──────────────── Auth Verification ────────────────

View File

@@ -0,0 +1,42 @@
/**
* Dashboard session token — the ONE verifier for the `auth_token` cookie.
*
* A dashboard session is a JWT that verifies against JWT_SECRET AND carries
* `authenticated: true` — the claim every session minter emits
* (`api/auth/login`, `api/auth/oidc/callback`, the authz pipeline refresh).
* Other tokens signed with the same secret exist (the Cursor CLI passthrough
* mints `iss "omniroute" / aud "cursor-cli"` tokens for any key holder) and
* MUST NOT verify as a session: before #13298 any such token forged the
* cookie and reached instance-wide operations. Every place that trusts the
* cookie goes through `verifyDashboardSessionToken`; a bare `jwtVerify` on
* `auth_token` is a regression (guarded by
* tests/unit/dashboard-session-verifier-source-guard.test.ts).
*/
import { jwtVerify, type JWTPayload } from "jose";
export const DASHBOARD_SESSION_COOKIE = "auth_token";
export const DASHBOARD_SESSION_CLAIM = "authenticated";
export function getDashboardJwtSecret(): Uint8Array | null {
const secret = process.env.JWT_SECRET?.trim();
return secret ? new TextEncoder().encode(secret) : null;
}
/**
* Returns the verified payload when `token` is a dashboard session, else null.
* Never throws: a malformed, expired, foreign-secret or claim-less token is
* simply "not a session". Pass `secret` explicitly when the caller already
* resolved it (the authz pipeline does); `null` means "no secret → no session".
*/
export async function verifyDashboardSessionToken(
token: string | null | undefined,
secret: Uint8Array | null = getDashboardJwtSecret()
): Promise<JWTPayload | null> {
if (!token || typeof token !== "string" || !secret) return null;
try {
const { payload } = await jwtVerify(token, secret);
return payload[DASHBOARD_SESSION_CLAIM] === true ? payload : null;
} catch {
return null;
}
}

View File

@@ -24,7 +24,7 @@ const testRoots = new Set<string>();
async function createAuthCookie(): Promise<string> {
process.env.JWT_SECRET = "test-cli-tools-apply-secret";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ sub: "test-user" })
const token = await new SignJWT({ authenticated: true, sub: "test-user" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")

View File

@@ -12,7 +12,7 @@ const originalApiKeySecret = process.env.API_KEY_SECRET;
async function createAuthCookie() {
process.env.JWT_SECRET = "test-cli-tools-keys-secret";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ sub: "test-user" })
const token = await new SignJWT({ authenticated: true, sub: "test-user" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")

View File

@@ -18,7 +18,7 @@ const route = await import("../../src/app/api/cli-tools/codex-settings/route.ts"
const authCookie = async (): Promise<string> => {
process.env.JWT_SECRET = "codex-wire-api-default-test-secret";
const token = await new SignJWT({ sub: "codex-wire-api-default-test" })
const token = await new SignJWT({ authenticated: true, sub: "codex-wire-api-default-test" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")

View File

@@ -0,0 +1,100 @@
/**
* #13298 — a dashboard session is a JWT that (a) verifies against JWT_SECRET AND
* (b) carries `authenticated: true`, the claim every dashboard minter emits
* (login, OIDC callback, pipeline refresh). Any other JWT signed with the same
* secret — notably the Cursor CLI passthrough token (iss "omniroute", aud
* "cursor-cli", no claim) — is NOT a session.
*/
import "../_setup/isolateDataDir.ts";
import { test } from "node:test";
import assert from "node:assert/strict";
import { SignJWT } from "jose";
process.env.JWT_SECRET = "dashboard-session-token-13298-secret";
const { verifyDashboardSessionToken, getDashboardJwtSecret, DASHBOARD_SESSION_COOKIE } =
await import("../../src/shared/utils/dashboardSessionToken.ts");
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const sign = (
claims: Record<string, unknown>,
opts: { exp?: string; iss?: string; aud?: string } = {}
) => {
let j = new SignJWT(claims)
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(opts.exp ?? "1h");
if (opts.iss) j = j.setIssuer(opts.iss);
if (opts.aud) j = j.setAudience(opts.aud);
return j.sign(secret);
};
test("cookie name constant", () => {
assert.equal(DASHBOARD_SESSION_COOKIE, "auth_token");
});
test("getDashboardJwtSecret encodes the trimmed env secret, null when unset/blank", () => {
assert.ok(getDashboardJwtSecret() instanceof Uint8Array);
const saved = process.env.JWT_SECRET;
process.env.JWT_SECRET = " ";
assert.equal(getDashboardJwtSecret(), null);
delete process.env.JWT_SECRET;
assert.equal(getDashboardJwtSecret(), null);
process.env.JWT_SECRET = saved;
});
test("accepts the login-shaped token { authenticated: true } and returns its payload", async () => {
const payload = await verifyDashboardSessionToken(await sign({ authenticated: true }));
assert.ok(payload, "login-shaped token is a session");
assert.equal(payload!.authenticated, true);
assert.equal(typeof payload!.exp, "number");
});
test("REJECTS the Cursor CLI passthrough token (same secret, iss omniroute / aud cursor-cli, no claim)", async () => {
const cursorToken = await sign({ name: "some-key" }, { iss: "omniroute", aud: "cursor-cli" });
assert.equal(await verifyDashboardSessionToken(cursorToken), null);
});
test("rejects a verified token whose claim is missing or not strictly true", async () => {
assert.equal(await verifyDashboardSessionToken(await sign({ sub: "admin" })), null);
assert.equal(await verifyDashboardSessionToken(await sign({ authenticated: "true" })), null);
assert.equal(await verifyDashboardSessionToken(await sign({ authenticated: 1 })), null);
});
test("rejects expired, wrong-secret, garbage, empty and missing tokens without throwing", async () => {
assert.equal(
await verifyDashboardSessionToken(await sign({ authenticated: true }, { exp: "-1s" })),
null
);
const other = new TextEncoder().encode("another-secret");
const foreign = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(other);
assert.equal(await verifyDashboardSessionToken(foreign), null);
assert.equal(await verifyDashboardSessionToken("not.a.jwt"), null);
assert.equal(await verifyDashboardSessionToken(""), null);
assert.equal(await verifyDashboardSessionToken(null), null);
assert.equal(await verifyDashboardSessionToken(undefined), null);
});
test("an explicit secret argument wins over the env; a null secret means no session", async () => {
const other = new TextEncoder().encode("explicit-secret");
const tok = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(other);
assert.ok(await verifyDashboardSessionToken(tok, other));
assert.equal(await verifyDashboardSessionToken(tok), null);
assert.equal(await verifyDashboardSessionToken(await sign({ authenticated: true }), null), null);
});
test("isDashboardSessionAuthenticated(): login token → true, Cursor CLI token → false (route-level)", async () => {
const { isDashboardSessionAuthenticated } = await import("../../src/shared/utils/apiAuth.ts");
const login = await sign({ authenticated: true });
const cursor = await sign({ name: "k" }, { iss: "omniroute", aud: "cursor-cli" });
const req = (cookie: string) =>
new Request("http://localhost/api/settings", { headers: { cookie: `auth_token=${cookie}` } });
assert.equal(await isDashboardSessionAuthenticated(req(login)), true);
assert.equal(await isDashboardSessionAuthenticated(req(cursor)), false);
assert.equal(await isDashboardSessionAuthenticated(req("garbage")), false);
});

View File

@@ -0,0 +1,37 @@
/**
* #13298 source guard: every consumer of the `auth_token` cookie must verify it
* through verifyDashboardSessionToken (which requires `authenticated: true`).
* A bare jose `jwtVerify` (called or aliased) in one of these files re-opens the
* forgeable-session hole (Cursor CLI tokens share JWT_SECRET).
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const ROOT = path.resolve(import.meta.dirname, "../..");
const VERIFIERS = [
"src/shared/utils/apiAuth.ts",
"src/server/authz/pipeline.ts",
"src/lib/ws/handshake.ts",
"src/server/ws/liveServer.ts",
"src/app/api/settings/require-login/route.ts",
"src/app/api/auth/status/route.ts",
];
for (const rel of VERIFIERS) {
test(`${rel} verifies auth_token only through verifyDashboardSessionToken`, () => {
const src = fs.readFileSync(path.join(ROOT, rel), "utf8");
assert.match(src, /verifyDashboardSessionToken\s*\(/, "must call the shared verifier");
assert.doesNotMatch(src, /\bjwtVerify\b/, "any bare jwtVerify is the #13298 regression");
});
}
test("the helper itself is the only src file that calls jwtVerify on the dashboard cookie", () => {
const helper = fs.readFileSync(
path.join(ROOT, "src/shared/utils/dashboardSessionToken.ts"),
"utf8"
);
assert.match(helper, /jwtVerify\(token, secret\)/);
assert.match(helper, /=== true/);
});

View File

@@ -49,7 +49,7 @@ test.after(() => {
async function sessionCookie(): Promise<string> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const jwt = await new SignJWT({ sub: "admin" })
const jwt = await new SignJWT({ authenticated: true, sub: "admin" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(secret);

View File

@@ -37,7 +37,7 @@ type FlagPayload = {
async function authCookie(): Promise<string> {
process.env.JWT_SECRET = "test-feature-flags-route-secret";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ sub: "test-user" })
const token = await new SignJWT({ authenticated: true, sub: "test-user" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")

View File

@@ -21,7 +21,7 @@ const originalJwtSecret = process.env.JWT_SECRET;
async function createAuthCookie() {
process.env.JWT_SECRET = "test-cli-tools-secret";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ sub: "test-user" })
const token = await new SignJWT({ authenticated: true, sub: "test-user" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")

View File

@@ -45,7 +45,7 @@ test.after(() => {
async function authCookie(): Promise<string> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const jwt = await new SignJWT({ sub: "admin" })
const jwt = await new SignJWT({ authenticated: true, sub: "admin" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(secret);

View File

@@ -203,7 +203,7 @@ test("v1 image generation POST accepts a dashboard session when REQUIRE_API_KEY
try {
const { SignJWT } = await import("jose");
const token = await new SignJWT({ sub: "dashboard" })
const token = await new SignJWT({ authenticated: true, sub: "dashboard" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(new TextEncoder().encode(process.env.JWT_SECRET));

View File

@@ -32,7 +32,7 @@ const KEY_SECRET = created.key;
async function sessionCookie(): Promise<string> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const jwt = await new SignJWT({ sub: "admin" })
const jwt = await new SignJWT({ authenticated: true, sub: "admin" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(secret);

View File

@@ -19,7 +19,7 @@ const route = await import("../../src/app/api/cli-tools/qwen-settings/route.ts")
const authCookie = async (): Promise<string> => {
process.env.JWT_SECRET = "qwen-settings-route-test-secret";
const token = await new SignJWT({ sub: "qwen-route-test" })
const token = await new SignJWT({ authenticated: true, sub: "qwen-route-test" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")