From 693fe21da40b6f5fb4ff522e75e48c6e60eba3db Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:45:23 +0300 Subject: [PATCH] Make session cookie host-only --- app/features/auth/core/session.server.test.ts | 137 ++++++++++++++++++ app/features/auth/core/session.server.ts | 133 +++++++++++++++-- .../auth/core/user-middleware.server.ts | 18 ++- 3 files changed, 270 insertions(+), 18 deletions(-) create mode 100644 app/features/auth/core/session.server.test.ts diff --git a/app/features/auth/core/session.server.test.ts b/app/features/auth/core/session.server.test.ts new file mode 100644 index 000000000..e422dcd96 --- /dev/null +++ b/app/features/auth/core/session.server.test.ts @@ -0,0 +1,137 @@ +import { createCookieSessionStorage } from "react-router"; +import { describe, expect, test } from "vitest"; +import { createAuthSession } from "./session.server"; + +const SECRET = "test-secret"; + +const secureAuthSession = createAuthSession({ + isSecureContext: true, + secret: SECRET, +}); + +const legacyStorage = createCookieSessionStorage({ + cookie: { + name: "__session", + domain: "sendou.ink", + path: "/", + httpOnly: true, + secrets: [SECRET], + secure: true, + }, +}); + +const legacyCookieHeader = async (data: Record) => { + const session = await legacyStorage.getSession(); + for (const [key, value] of Object.entries(data)) { + session.set(key, value); + } + return toCookieHeader(await legacyStorage.commitSession(session)); +}; + +const hostOnlyCookieHeader = async (data: Record) => { + const session = await secureAuthSession.storage.getSession(); + for (const [key, value] of Object.entries(data)) { + session.set(key, value); + } + return toCookieHeader(await secureAuthSession.storage.commitSession(session)); +}; + +describe("createAuthSession", () => { + test("issues a host-only __Host- cookie in a secure context", async () => { + const session = await secureAuthSession.storage.getSession(); + session.set("user", 1); + + const setCookie = await secureAuthSession.storage.commitSession(session); + + expect(setCookie.startsWith("__Host-session=")).toBe(true); + expect(setCookie).toContain("Secure"); + expect(setCookie).toContain("Path=/"); + expect(setCookie).not.toContain("Domain"); + }); + + test("keeps the plain cookie name outside a secure context", () => { + const authSession = createAuthSession({ + isSecureContext: false, + secret: SECRET, + }); + + expect(authSession.cookieName).toBe("__session"); + }); + + test("reads the session from the legacy domain cookie when no host-only cookie exists", async () => { + const session = await secureAuthSession.storage.getSession( + await legacyCookieHeader({ user: 1 }), + ); + + expect(session.get("user")).toBe(1); + }); + + test("prefers the host-only cookie over the legacy cookie", async () => { + const cookieHeader = `${await legacyCookieHeader({ user: 1 })}; ${await hostOnlyCookieHeader({ user: 2 })}`; + + const session = await secureAuthSession.storage.getSession(cookieHeader); + + expect(session.get("user")).toBe(2); + }); +}); + +describe("legacySessionMigrationCookies", () => { + test("returns null without a legacy cookie", async () => { + const result = await secureAuthSession.legacySessionMigrationCookies( + await hostOnlyCookieHeader({ user: 1 }), + ); + + expect(result).toBeNull(); + }); + + test("returns null outside a secure context", async () => { + const authSession = createAuthSession({ + isSecureContext: false, + secret: SECRET, + }); + + const result = await authSession.legacySessionMigrationCookies( + await legacyCookieHeader({ user: 1 }), + ); + + expect(result).toBeNull(); + }); + + test("re-issues the legacy session host-only and expires the legacy domain cookie", async () => { + const result = await secureAuthSession.legacySessionMigrationCookies( + await legacyCookieHeader({ user: 1, impersonated_user: 2 }), + ); + + const migrated = await secureAuthSession.storage.getSession( + toCookieHeader(result!.hostOnly!), + ); + expect(migrated.get("user")).toBe(1); + expect(migrated.get("impersonated_user")).toBe(2); + expect(result!.expireLegacy.startsWith("__session=;")).toBe(true); + expect(result!.expireLegacy).toContain("Domain=sendou.ink"); + expect(result!.expireLegacy).toContain("Expires=Thu, 01 Jan 1970"); + }); + + test("only expires the legacy cookie when a host-only cookie already exists", async () => { + const cookieHeader = `${await legacyCookieHeader({ user: 1 })}; ${await hostOnlyCookieHeader({ user: 2 })}`; + + const result = + await secureAuthSession.legacySessionMigrationCookies(cookieHeader); + + expect(result?.hostOnly).toBeNull(); + expect(result?.expireLegacy).toContain("Domain=sendou.ink"); + }); + + test("does not issue a host-only cookie for an empty legacy session", async () => { + const result = await secureAuthSession.legacySessionMigrationCookies( + await legacyCookieHeader({}), + ); + + expect(result?.hostOnly).toBeNull(); + expect(result?.expireLegacy).toContain("Domain=sendou.ink"); + }); +}); + +function toCookieHeader(setCookie: string) { + return setCookie.split(";")[0]; +} diff --git a/app/features/auth/core/session.server.ts b/app/features/auth/core/session.server.ts index 06fb8ab36..6ae4b8247 100644 --- a/app/features/auth/core/session.server.ts +++ b/app/features/auth/core/session.server.ts @@ -1,21 +1,124 @@ -import { createCookieSessionStorage } from "react-router"; +import { createCookieSessionStorage, type SessionStorage } from "react-router"; import { ServerConfig } from "~/config.server"; import { IS_E2E_TEST_RUN } from "~/utils/e2e"; const ONE_YEAR_IN_SECONDS = 31_536_000; -export const AUTH_SESSION_COOKIE_NAME = "__session"; +const LEGACY_COOKIE_NAME = "__session"; +const LEGACY_COOKIE_DOMAIN = "sendou.ink"; -export const authSessionStorage = createCookieSessionStorage({ - cookie: { - name: AUTH_SESSION_COOKIE_NAME, - sameSite: "lax", - // need to specify domain so that sub-domains can access it - domain: - ServerConfig.isProduction && !IS_E2E_TEST_RUN ? "sendou.ink" : undefined, - path: "/", - httpOnly: true, - secrets: [ServerConfig.sessionSecret], - secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN, - maxAge: ONE_YEAR_IN_SECONDS, - }, +const authSession = createAuthSession({ + isSecureContext: ServerConfig.isProduction && !IS_E2E_TEST_RUN, + secret: ServerConfig.sessionSecret, }); + +export const AUTH_SESSION_COOKIE_NAME = authSession.cookieName; +export const authSessionStorage = authSession.storage; +export const legacySessionMigrationCookies = + authSession.legacySessionMigrationCookies; + +/** + * Creates the auth session storage. In a secure context the cookie is host-only (`__Host-` prefix: Secure, `Path=/`, no `Domain`). + * + * Sessions issued before the switch live in a `Domain=sendou.ink` cookie. They are still read when no host-only cookie is present, + * and `legacySessionMigrationCookies` returns the `Set-Cookie` values that re-issue such a session host-only and expire the old cookie. + */ +export function createAuthSession({ + isSecureContext, + secret, +}: { + isSecureContext: boolean; + secret: string; +}) { + const cookieName = isSecureContext ? "__Host-session" : LEGACY_COOKIE_NAME; + + const hostOnlyStorage = createCookieSessionStorage({ + cookie: { + name: cookieName, + sameSite: "lax", + path: "/", + httpOnly: true, + secrets: [secret], + secure: isSecureContext, + maxAge: ONE_YEAR_IN_SECONDS, + }, + }); + + // TODO: remove legacy cookie migration after 2027-01 (anyone not migrated by then just logs in again) + const legacyStorage = isSecureContext + ? createCookieSessionStorage({ + cookie: { + name: LEGACY_COOKIE_NAME, + domain: LEGACY_COOKIE_DOMAIN, + sameSite: "lax", + path: "/", + httpOnly: true, + secrets: [secret], + secure: true, + }, + }) + : null; + + const readsLegacyCookie = (cookieHeader: string | null | undefined) => + legacyStorage !== null && + hasCookie(cookieHeader, LEGACY_COOKIE_NAME) && + !hasCookie(cookieHeader, cookieName); + + const storage: SessionStorage = { + getSession: async (cookieHeader, options) => { + if (!legacyStorage || !readsLegacyCookie(cookieHeader)) { + return hostOnlyStorage.getSession(cookieHeader, options); + } + + const legacySession = await legacyStorage.getSession( + cookieHeader, + options, + ); + const session = await hostOnlyStorage.getSession(); + for (const [key, value] of Object.entries(legacySession.data)) { + session.set(key, value); + } + + return session; + }, + commitSession: hostOnlyStorage.commitSession, + destroySession: hostOnlyStorage.destroySession, + }; + + const migrationCookies = async ( + cookieHeader: string | null, + ): Promise<{ hostOnly: string | null; expireLegacy: string } | null> => { + if (!legacyStorage || !hasCookie(cookieHeader, LEGACY_COOKIE_NAME)) { + return null; + } + + const expireLegacy = await legacyStorage.destroySession( + await legacyStorage.getSession(), + ); + + if (!readsLegacyCookie(cookieHeader)) { + return { hostOnly: null, expireLegacy }; + } + + const session = await storage.getSession(cookieHeader); + const hostOnly = + Object.keys(session.data).length > 0 + ? await hostOnlyStorage.commitSession(session) + : null; + + return { hostOnly, expireLegacy }; + }; + + return { + cookieName, + storage, + legacySessionMigrationCookies: migrationCookies, + }; +} + +function hasCookie(cookieHeader: string | null | undefined, name: string) { + if (!cookieHeader) return false; + + return cookieHeader + .split(";") + .some((cookie) => cookie.trim().startsWith(`${name}=`)); +} diff --git a/app/features/auth/core/user-middleware.server.ts b/app/features/auth/core/user-middleware.server.ts index cd9e41c34..76c61a8c1 100644 --- a/app/features/auth/core/user-middleware.server.ts +++ b/app/features/auth/core/user-middleware.server.ts @@ -1,4 +1,7 @@ -import { AUTH_SESSION_COOKIE_NAME } from "./session.server"; +import { + AUTH_SESSION_COOKIE_NAME, + legacySessionMigrationCookies, +} from "./session.server"; import { getUserFromRequest, userAsyncLocalStorage, @@ -20,8 +23,17 @@ export const userMiddleware: MiddlewareFn = async ({ request, url }, next) => { const response = await userAsyncLocalStorage.run({ user }, () => next()); - if (staleSessionCookie && !setsAuthSessionCookie(response)) { - response.headers.append("Set-Cookie", staleSessionCookie); + const migration = await legacySessionMigrationCookies( + request.headers.get("Cookie"), + ); + const sessionCookie = staleSessionCookie ?? migration?.hostOnly; + + if (sessionCookie && !setsAuthSessionCookie(response)) { + response.headers.append("Set-Cookie", sessionCookie); + } + + if (migration) { + response.headers.append("Set-Cookie", migration.expireLegacy); } return response;