mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-27 13:06:13 -05:00
Make session cookie host-only
This commit is contained in:
137
app/features/auth/core/session.server.test.ts
Normal file
137
app/features/auth/core/session.server.test.ts
Normal 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];
|
||||
}
|
||||
@@ -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}=`));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user