mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
feat(auth): shared dashboard session verifier that requires the authenticated claim
Refs #13298
This commit is contained in:
42
src/shared/utils/dashboardSessionToken.ts
Normal file
42
src/shared/utils/dashboardSessionToken.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
88
tests/unit/dashboard-session-token-13298.test.ts
Normal file
88
tests/unit/dashboard-session-token-13298.test.ts
Normal file
@@ -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<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);
|
||||
});
|
||||
Reference in New Issue
Block a user