Skip to content
File

Blob: test/auth-session.worker.test.ts

typescript172 lines
1import { applyD1Migrations } from "cloudflare:test";
2import { env, exports as workerExports } from "cloudflare:workers";
3import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
4 
5import { __test as oidcTest } from "@/worker/auth/oidc";
6import { SESSION_COOKIE_HEADER_NAME } from "@/worker/auth/cookies";
7 
8import { fakeProvider } from "./util/oidcFake";
9import { readAppD1Migrations } from "./util/d1Migrations";
10import {
11 extractSessionToken,
12 oidcTransactionCookieHeader,
13 sessionCookieHeader,
14} from "./util/authCookies";
15 
16beforeAll(async () => {
17 await applyD1Migrations(env.DB, readAppD1Migrations());
18});
19 
20beforeEach(() => {
21 oidcTest.setProviderForTesting(
22 {
23 issuer: env.TESSERA_OIDC_ISSUER,
24 clientId: env.TESSERA_OIDC_CLIENT_ID,
25 clientSecret: env.TESSERA_OIDC_CLIENT_SECRET,
26 },
27 fakeProvider({
28 authorizationEndpoint: "https://auth.example.com/authorize",
29 tokenEndpoint: "https://auth.example.com/token",
30 jwksUri: "https://auth.example.com/.well-known/jwks.json",
31 })
32 );
33});
34 
35afterEach(() => {
36 oidcTest.clearProviderCache();
37 oidcTest.setAuthorizationCodeGrantImpl(null);
38});
39 
40async function signIn(sub: string, preferredUsername?: string): Promise<string> {
41 const state = `state-${sub}`;
42 const cookie = await oidcTransactionCookieHeader(env.TESSERA_OIDC_CLIENT_SECRET, {
43 state,
44 nonce: "n",
45 codeVerifier: "v",
46 redirectUri: "https://example.com/auth/callback",
47 createdAt: Date.now(),
48 });
49 oidcTest.setAuthorizationCodeGrantImpl(async () => {
50 const claims = preferredUsername ? { sub, preferred_username: preferredUsername } : { sub };
51 return {
52 access_token: "fake",
53 token_type: "Bearer",
54 claims: () => claims,
55 } as unknown as Awaited<ReturnType<typeof import("openid-client").authorizationCodeGrant>>;
56 });
57 const url = new URL("https://example.com/auth/callback");
58 url.searchParams.set("code", "x");
59 url.searchParams.set("state", state);
60 const res = await workerExports.default.fetch(url.toString(), {
61 redirect: "manual",
62 headers: { Cookie: cookie },
63 });
64 expect(res.status).toBe(302);
65 const token = extractSessionToken(res.headers.get("set-cookie"));
66 expect(token).toBeTruthy();
67 return token!;
68}
69 
70describe("session lifecycle", () => {
71 it("issues a session cookie that authorizes /auth/account", async () => {
72 const token = await signIn("sub-session-1", "session-rachel");
73 const res = await workerExports.default.fetch("https://example.com/auth/account", {
74 headers: { Cookie: sessionCookieHeader(token) },
75 });
76 expect(res.status).toBe(200);
77 const html = await res.text();
78 expect(html).toContain("@session-rachel");
79 });
80 
81 it("/auth/account redirects to /auth when no cookie is present", async () => {
82 const res = await workerExports.default.fetch("https://example.com/auth/account", {
83 redirect: "manual",
84 });
85 expect(res.status).toBe(302);
86 expect(res.headers.get("location")).toBe("/auth");
87 });
88 
89 it("rejects POST /auth/sign-out without same-origin", async () => {
90 const token = await signIn("sub-signout-cross", undefined);
91 const res = await workerExports.default.fetch("https://example.com/auth/sign-out", {
92 method: "POST",
93 headers: {
94 Cookie: sessionCookieHeader(token),
95 Origin: "https://attacker.example.com",
96 },
97 redirect: "manual",
98 });
99 expect(res.status).toBe(403);
100 // Cookie remains valid for /auth/account.
101 const followup = await workerExports.default.fetch("https://example.com/auth/account", {
102 headers: { Cookie: sessionCookieHeader(token) },
103 });
104 expect(followup.status).toBe(200);
105 });
106 
107 it("clears the browser cookie on same-origin sign-out", async () => {
108 const token = await signIn("sub-signout-2", undefined);
109 const res = await workerExports.default.fetch("https://example.com/auth/sign-out", {
110 method: "POST",
111 headers: {
112 Cookie: sessionCookieHeader(token),
113 Origin: "https://example.com",
114 },
115 redirect: "manual",
116 });
117 expect(res.status).toBe(303);
118 expect(res.headers.get("location")).toBe("/");
119 const setCookie = res.headers.get("set-cookie") ?? "";
120 expect(setCookie).toContain(`${SESSION_COOKIE_HEADER_NAME}=`);
121 expect(setCookie.toLowerCase()).toContain("max-age=0");
122 // Stateless sealed sessions have no server-side revocation row. A copied
123 // cookie remains valid until expiry or SESSION_SECRET rotation.
124 const followup = await workerExports.default.fetch("https://example.com/auth/account", {
125 headers: { Cookie: sessionCookieHeader(token) },
126 redirect: "manual",
127 });
128 expect(followup.status).toBe(200);
129 });
130 
131 it("treats a malformed session cookie as anonymous", async () => {
132 const res = await workerExports.default.fetch("https://example.com/auth/account", {
133 headers: { Cookie: `${SESSION_COOKIE_HEADER_NAME}=garbage` },
134 redirect: "manual",
135 });
136 expect(res.status).toBe(302);
137 expect(res.headers.get("location")).toBe("/auth");
138 });
139 
140 it("fails closed when SESSION_SECRET changes", async () => {
141 const token = await signIn("sub-secret-rotation", undefined);
142 const original = env.SESSION_SECRET;
143 env.SESSION_SECRET = "different-session-secret";
144 try {
145 const res = await workerExports.default.fetch("https://example.com/auth/account", {
146 headers: { Cookie: sessionCookieHeader(token) },
147 redirect: "manual",
148 });
149 expect(res.status).toBe(302);
150 expect(res.headers.get("location")).toBe("/auth");
151 } finally {
152 env.SESSION_SECRET = original;
153 }
154 });
155 
156 it("fails closed when SESSION_SECRET is missing", async () => {
157 const token = await signIn("sub-secret-missing", undefined);
158 const original = env.SESSION_SECRET;
159 env.SESSION_SECRET = "";
160 try {
161 const res = await workerExports.default.fetch("https://example.com/auth/account", {
162 headers: { Cookie: sessionCookieHeader(token) },
163 redirect: "manual",
164 });
165 expect(res.status).toBe(302);
166 expect(res.headers.get("location")).toBe("/auth");
167 } finally {
168 env.SESSION_SECRET = original;
169 }
170 });
171});