Make session cookie host-only

This commit is contained in:
Kalle
2026-10-22 19:45:23 +03:00
parent 32b8689d8d
commit 693fe21da4
3 changed files with 270 additions and 18 deletions

View File

@@ -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<string, unknown>) => {
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<string, unknown>) => {
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];
}

View File

@@ -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}=`));
}

View File

@@ -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;