From 74204011476d4fe6bd9aa55b1624da4685fb71af Mon Sep 17 00:00:00 2001 From: Tyler Gillispie Date: Mon, 24 Aug 2026 13:11:17 -0700 Subject: [PATCH] fix(cookie): dynamically calculate parent wildcard domain (.atyg.org) to prevent host-scoping redirect loop --- server/auth-session.ts | 20 ++++++++++++++++++++ server/main.test.ts | 10 ++++++++++ server/main.ts | 25 +++++++++++++++++++------ ui/mod.ts | 9 ++++++--- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/server/auth-session.ts b/server/auth-session.ts index 06114b1..d415865 100644 --- a/server/auth-session.ts +++ b/server/auth-session.ts @@ -9,6 +9,26 @@ export interface AuthenticatedUser { username: string; } +/** + * Calculates the wildcard parent cookie domain (e.g. auth.atyg.org -> .atyg.org) + * to ensure cookies are sent to all subdomains (ed-droid.atyg.org, grafana.atyg.org, etc.). + */ +export function getCookieDomain(customRpId?: string): string | undefined { + const envDomain = Deno.env.get("COOKIE_DOMAIN"); + if (envDomain) { + return envDomain.startsWith(".") ? envDomain : `.${envDomain}`; + } + const targetId = customRpId || Deno.env.get("RP_ID") || ""; + if (!targetId || !targetId.includes(".") || targetId === "localhost") { + return undefined; + } + const parts = targetId.split(".").filter(Boolean); + if (parts.length >= 2) { + return `.${parts.slice(-2).join(".")}`; + } + return `.${targetId}`; +} + /** * Helper to get authenticated user from session cookie. * Checks Valkey cache first, with automatic PostgreSQL sessions table fallback. diff --git a/server/main.test.ts b/server/main.test.ts index d02b1cd..1693448 100644 --- a/server/main.test.ts +++ b/server/main.test.ts @@ -418,3 +418,13 @@ Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () = sqlWrapper.sql = originalSql; } }); + +Deno.test("Cookie Domain Scoping - getCookieDomain derives wildcard parent domain", async () => { + const { getCookieDomain } = await import("./auth-session.ts"); + + assertEquals(getCookieDomain("auth.atyg.org"), ".atyg.org"); + assertEquals(getCookieDomain("ed-droid.atyg.org"), ".atyg.org"); + assertEquals(getCookieDomain("atyg.org"), ".atyg.org"); + assertEquals(getCookieDomain("localhost"), undefined); + assertEquals(getCookieDomain(""), undefined); +}); diff --git a/server/main.ts b/server/main.ts index 3679c2c..8c7bc3b 100644 --- a/server/main.ts +++ b/server/main.ts @@ -48,6 +48,22 @@ const origin = Deno.env.get("ORIGIN") || (import.meta.main ? undefined : "http://localhost"); const requireHardwareToken = Deno.env.get("REQUIRE_HARDWARE_TOKEN") === "true"; +export function getCookieDomain(customRpId?: string): string | undefined { + const envDomain = Deno.env.get("COOKIE_DOMAIN"); + if (envDomain) { + return envDomain.startsWith(".") ? envDomain : `.${envDomain}`; + } + const targetId = customRpId || Deno.env.get("RP_ID") || ""; + if (!targetId || !targetId.includes(".") || targetId === "localhost") { + return undefined; + } + const parts = targetId.split(".").filter(Boolean); + if (parts.length >= 2) { + return `.${parts.slice(-2).join(".")}`; + } + return `.${targetId}`; +} + if (!rpID || !origin) { throw new Error( "Missing critical environment variables: RP_ID and ORIGIN must be set.", @@ -544,8 +560,7 @@ app.post("/api/register/verify", async (c) => { maxAge: 0, }); - const cookieDomain = Deno.env.get("COOKIE_DOMAIN") || - (rpID && rpID.includes(".") ? `.${rpID}` : undefined); + const cookieDomain = getCookieDomain(rpID); if (cookieDomain) { deleteCookie(c, "session_id", { domain: cookieDomain, path: "/" }); } @@ -735,8 +750,7 @@ app.post("/api/login/verify", async (c) => { } } - const cookieDomain = Deno.env.get("COOKIE_DOMAIN") || - (rpID && rpID.includes(".") ? `.${rpID}` : undefined); + const cookieDomain = getCookieDomain(rpID); // Clear any existing host-scoped cookie to prevent domain duplication deleteCookie(c, "session_id", { path: "/" }); @@ -2080,8 +2094,7 @@ app.post("/api/revoke", async (c) => { // ignore } - const cookieDomain = Deno.env.get("COOKIE_DOMAIN") || - (rpID && rpID.includes(".") ? `.${rpID}` : undefined); + const cookieDomain = getCookieDomain(rpID); if (cookieDomain) { deleteCookie(c, "session_id", { diff --git a/ui/mod.ts b/ui/mod.ts index 9a178e5..78f3789 100644 --- a/ui/mod.ts +++ b/ui/mod.ts @@ -3,7 +3,11 @@ import { serveStatic } from "jsr:@hono/hono@4/deno"; import { deleteCookie, getCookie } from "jsr:@hono/hono@4/cookie"; import { sql } from "../server/db.ts"; import { valkey } from "../server/valkey.ts"; -import { getAuthenticatedUser, isGlobalAdmin } from "../server/auth-session.ts"; +import { + getAuthenticatedUser, + getCookieDomain, + isGlobalAdmin, +} from "../server/auth-session.ts"; import { LoginPage } from "./components/LoginPage.tsx"; import { RegisterPage } from "./components/RegisterPage.tsx"; import { SessionsPage } from "./components/SessionsPage.tsx"; @@ -37,8 +41,7 @@ uiApp.get("/logout", async (c) => { } const rpID = Deno.env.get("RP_ID") || ""; - const cookieDomain = Deno.env.get("COOKIE_DOMAIN") || - (rpID.includes(".") ? `.${rpID}` : undefined); + const cookieDomain = getCookieDomain(rpID); if (cookieDomain) { deleteCookie(c, "session_id", {