From be43641be4b6e651aa93c1eb2de3c166b2988b7d Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:36:09 -0300 Subject: [PATCH] feat(auth): shared dashboard session verifier that requires the authenticated claim Refs #13298 --- src/shared/utils/dashboardSessionToken.ts | 42 +++++++++ .../dashboard-session-token-13298.test.ts | 88 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/shared/utils/dashboardSessionToken.ts create mode 100644 tests/unit/dashboard-session-token-13298.test.ts diff --git a/src/shared/utils/dashboardSessionToken.ts b/src/shared/utils/dashboardSessionToken.ts new file mode 100644 index 0000000000..227b272695 --- /dev/null +++ b/src/shared/utils/dashboardSessionToken.ts @@ -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 { + 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; + } +} diff --git a/tests/unit/dashboard-session-token-13298.test.ts b/tests/unit/dashboard-session-token-13298.test.ts new file mode 100644 index 0000000000..d6f0cfc31f --- /dev/null +++ b/tests/unit/dashboard-session-token-13298.test.ts @@ -0,0 +1,88 @@ +/** + * #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 { 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, + 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); +});