fix(auth): every auth_token consumer requires the authenticated claim — Cursor CLI tokens are not dashboard sessions

Closes #13298
This commit is contained in:
diegosouzapw
2026-09-11 17:54:38 -03:00
parent be43641be4
commit 28d320b5e3
8 changed files with 75 additions and 44 deletions

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 ─────────────────────────────────────────────────────────────────
@@ -190,13 +190,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

@@ -86,3 +86,14 @@ test("an explicit secret argument wins over the env; a null secret means no sess
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(` 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/, "must import/use the shared verifier");
assert.doesNotMatch(src, /\bjwtVerify\s*\(/, "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/);
});