seahaven-ap/packages/api/src/auth/cookies.test.ts
Adam Moussa bbfa6e4a3c
feat(api): switch live auth to host cookie BFF
Replace Bearer as the documented session path with Cognito hosted UI plus __Host-ap_* cookies so the SPA can call /api with credentials include.
2026-09-22 12:39:00 -04:00

138 lines
4.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
ACCESS_MAX_AGE_SEC,
COOKIE_ACCESS,
COOKIE_ID,
COOKIE_OAUTH,
COOKIE_REFRESH,
COOKIE_SESSION_HINT,
REFRESH_MAX_AGE_SEC,
clearCookie,
clearedSessionCookies,
cookieNames,
parseCookies,
serializeCookie,
serializeOauthCookie,
sessionCookieValue,
tokenCookies,
} from "./cookies.js";
const host = cookieNames("dev");
const local = cookieNames("local");
describe("auth cookies", () => {
it("prefixes deployed cookies with __Host- and keeps local names unprefixed", () => {
expect(host).toEqual({
access: `__Host-${COOKIE_ACCESS}`,
id: `__Host-${COOKIE_ID}`,
refresh: `__Host-${COOKIE_REFRESH}`,
oauth: `__Host-${COOKIE_OAUTH}`,
hint: `__Host-${COOKIE_SESSION_HINT}`,
});
expect(local).toEqual({
access: COOKIE_ACCESS,
id: COOKIE_ID,
refresh: COOKIE_REFRESH,
oauth: COOKIE_OAUTH,
hint: COOKIE_SESSION_HINT,
});
});
it("sets HttpOnly, SameSite=Lax, Path=/, no Domain, and Secure outside local", () => {
const cookie = serializeCookie(host.access, "tok", {
maxAge: ACCESS_MAX_AGE_SEC,
stage: "dev",
});
expect(cookie.startsWith(`${host.access}=`)).toBe(true);
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");
expect(cookie).toContain("Path=/");
expect(cookie).toMatch(/(?:^|; )Secure(?:;|$)/);
expect(cookie).not.toMatch(/Domain=/i);
expect(cookie).toContain(`Max-Age=${ACCESS_MAX_AGE_SEC}`);
});
it("omits Secure on local and scopes refresh to /api/auth", () => {
const cookie = serializeCookie(local.access, "tok", {
maxAge: ACCESS_MAX_AGE_SEC,
stage: "local",
});
expect(cookie).toContain("HttpOnly");
expect(cookie).not.toMatch(/(?:^|; )Secure(?:;|$)/);
expect(cookie).not.toMatch(/Domain=/i);
const refresh = tokenCookies(
{ accessToken: "a", idToken: "i", refreshToken: "r" },
"local",
).find((item) => item.startsWith(`${local.refresh}=`));
expect(refresh).toContain("Path=/api/auth");
expect(refresh).not.toMatch(/(?:^|; )Secure(?:;|$)/);
});
it("sets a non-HttpOnly session hint for 8h", () => {
const cookies = tokenCookies({ accessToken: "a", idToken: "i", refreshToken: "r" }, "dev");
const hint = cookies.find((item) => item.startsWith(`${host.hint}=`));
expect(hint).toContain(`${host.hint}=1`);
expect(hint).toContain(`Max-Age=${REFRESH_MAX_AGE_SEC}`);
expect(hint).not.toContain("HttpOnly");
expect(hint).toContain("SameSite=Lax");
expect(hint).toMatch(/(?:^|; )Secure(?:;|$)/);
});
it("ignores unprefixed session cookies on deployed stages", () => {
expect(
sessionCookieValue({ headers: { cookie: `${COOKIE_ACCESS}=legacy` } }, "access", "dev"),
).toBeUndefined();
expect(
sessionCookieValue(
{ headers: { cookie: `${host.access}=host; ${COOKIE_ACCESS}=legacy` } },
"access",
"dev",
),
).toBe("host");
});
it("reads unprefixed session cookies only on local", () => {
expect(
sessionCookieValue({ headers: { cookie: `${COOKIE_ACCESS}=local` } }, "access", "local"),
).toBe("local");
});
it("parses Cookie headers and keeps the first duplicate", () => {
expect(
parseCookies({
headers: { cookie: `${host.access}=first; ${host.access}=second` },
}),
).toEqual({ [host.access]: "first" });
});
it("clears cookies with Max-Age=0 and the same host-only attributes", () => {
const cookie = clearCookie(host.access, "dev");
expect(cookie).toContain("Max-Age=0");
expect(cookie).toContain("HttpOnly");
expect(cookie).not.toMatch(/Domain=/i);
expect(cookie).toMatch(/(?:^|; )Secure(?:;|$)/);
});
it("clears both __Host- and legacy ap_* cookies on deployed stages", () => {
const cookies = clearedSessionCookies("dev");
expect(
cookies.some((item) => item.startsWith(`${host.refresh}=`) && item.includes("Max-Age=0")),
).toBe(true);
expect(
cookies.some(
(item) =>
item.startsWith(`${COOKIE_REFRESH}=`) &&
item.includes("Max-Age=0") &&
item.includes("Path=/"),
),
).toBe(true);
});
it("encodes oauth state without a Domain attribute", () => {
const cookie = serializeOauthCookie({ state: "st", verifier: "ver", returnTo: "/" }, "dev");
expect(cookie.startsWith(`${host.oauth}=`)).toBe(true);
expect(cookie).toContain("HttpOnly");
expect(cookie).not.toMatch(/Domain=/i);
});
});