Skip to content
File

Blob: test/auth-tessera-callback.worker.test.ts

typescript300 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 { createDb } from "@/worker/db/d1/client";
6import {
7 findNamespaceBySlug,
8 findUserByTesseraSub,
9 insertMembershipIfMissing,
10 listNamespacesForUser,
11} from "@/worker/db/d1/dal";
12import { __test as oidcTest } from "@/worker/auth/oidc";
13import { OIDC_TX_COOKIE_HEADER_NAME, SESSION_COOKIE_HEADER_NAME } from "@/worker/auth/cookies";
14 
15import { fakeProvider } from "./util/oidcFake";
16import { readAppD1Migrations } from "./util/d1Migrations";
17import { oidcTransactionCookieHeader } from "./util/authCookies";
18 
19beforeAll(async () => {
20 await applyD1Migrations(env.DB, readAppD1Migrations());
21});
22 
23const REDIRECT_URI = "https://example.com/auth/callback";
24 
25function preloadProvider() {
26 oidcTest.setProviderForTesting(
27 {
28 issuer: env.TESSERA_OIDC_ISSUER,
29 clientId: env.TESSERA_OIDC_CLIENT_ID,
30 clientSecret: env.TESSERA_OIDC_CLIENT_SECRET,
31 },
32 fakeProvider({
33 authorizationEndpoint: "https://auth.example.com/oauth2/authorize",
34 tokenEndpoint: "https://auth.example.com/oauth2/token",
35 jwksUri: "https://auth.example.com/.well-known/jwks.json",
36 })
37 );
38}
39 
40async function buildTransactionCookie(state: string, nonce: string, codeVerifier: string) {
41 return await oidcTransactionCookieHeader(env.TESSERA_OIDC_CLIENT_SECRET, {
42 state,
43 nonce,
44 codeVerifier,
45 redirectUri: REDIRECT_URI,
46 createdAt: Date.now(),
47 });
48}
49 
50type FakeClaims = { sub: string; preferred_username?: string };
51 
52function stubGrantWithClaims(claims: FakeClaims, options?: { rejectAs?: "client" | "other" }) {
53 oidcTest.setAuthorizationCodeGrantImpl(async () => {
54 if (options?.rejectAs === "client") {
55 throw new (await import("openid-client")).ClientError("invalid_grant", { cause: claims });
56 }
57 if (options?.rejectAs === "other") {
58 throw new Error("network down");
59 }
60 const tokens = {
61 access_token: "fake-access",
62 token_type: "Bearer",
63 claims: () => ({ sub: claims.sub, preferred_username: claims.preferred_username }),
64 } as unknown as Awaited<ReturnType<typeof import("openid-client").authorizationCodeGrant>>;
65 return tokens;
66 });
67}
68 
69async function callCallback(args: { state: string; cookie?: string; code?: string }) {
70 const url = new URL("https://example.com/auth/callback");
71 url.searchParams.set("code", args.code ?? "fake-code");
72 url.searchParams.set("state", args.state);
73 return await workerExports.default.fetch(url.toString(), {
74 redirect: "manual",
75 headers: args.cookie ? { Cookie: args.cookie } : undefined,
76 });
77}
78 
79beforeEach(() => {
80 preloadProvider();
81});
82 
83afterEach(() => {
84 oidcTest.clearProviderCache();
85 oidcTest.setAuthorizationCodeGrantImpl(null);
86});
87 
88describe("/auth/callback", () => {
89 it("creates user, namespace, membership, and session for a fresh sub with valid pref-name", async () => {
90 const sub = "sub-fresh-1";
91 const state = "state-fresh-1";
92 stubGrantWithClaims({ sub, preferred_username: "fresh-rachel" });
93 const cookie = await buildTransactionCookie(state, "nonce-1", "verifier-1");
94 const res = await callCallback({ state, cookie });
95 expect(res.status).toBe(302);
96 expect(res.headers.get("location")).toBe("/auth/account");
97 const cookies = res.headers.get("set-cookie") ?? "";
98 expect(cookies).toContain(`${SESSION_COOKIE_HEADER_NAME}=goc_sess_`);
99 const db = createDb(env.DB);
100 const user = await findUserByTesseraSub(db, sub);
101 expect(user).toBeDefined();
102 const namespace = await findNamespaceBySlug(db, "fresh-rachel");
103 expect(namespace).toBeDefined();
104 expect(namespace!.createdBy).toBe(user!.id);
105 const namespaces = await listNamespacesForUser(db, user!.id);
106 expect(namespaces.map((row) => row.slug)).toEqual(["fresh-rachel"]);
107 });
108 
109 it("creates only user+session when pref-name is taken by another user", async () => {
110 const occupant = "user-occupant";
111 const db = createDb(env.DB);
112 // Seed an existing namespace owned by an unrelated user.
113 await db.batch([
114 db.insert((await import("@/worker/db/d1/schema")).users).values({
115 id: occupant,
116 tesseraSub: "sub-occupant",
117 createdAt: Date.now(),
118 }),
119 db.insert((await import("@/worker/db/d1/schema")).namespaces).values({
120 id: "ns-taken",
121 slug: "taken",
122 createdBy: occupant,
123 createdAt: Date.now(),
124 }),
125 ]);
126 const sub = "sub-loser";
127 const state = "state-taken";
128 stubGrantWithClaims({ sub, preferred_username: "taken" });
129 const cookie = await buildTransactionCookie(state, "nonce-2", "verifier-2");
130 const res = await callCallback({ state, cookie });
131 expect(res.status).toBe(302);
132 expect(res.headers.get("location")).toBe("/auth/account");
133 const dbAfter = createDb(env.DB);
134 const user = await findUserByTesseraSub(dbAfter, sub);
135 expect(user).toBeDefined();
136 expect(user!.id).not.toBe(occupant);
137 const namespace = await findNamespaceBySlug(dbAfter, "taken");
138 expect(namespace?.createdBy).toBe(occupant);
139 expect((await listNamespacesForUser(dbAfter, user!.id)).length).toBe(0);
140 });
141 
142 it("creates only user+session when pref-name is invalid", async () => {
143 const sub = "sub-invalid-1";
144 const state = "state-invalid";
145 stubGrantWithClaims({ sub, preferred_username: "Invalid_Slug!" });
146 const cookie = await buildTransactionCookie(state, "nonce-3", "verifier-3");
147 const res = await callCallback({ state, cookie });
148 expect(res.status).toBe(302);
149 expect(res.headers.get("location")).toBe("/auth/account");
150 const db = createDb(env.DB);
151 const user = await findUserByTesseraSub(db, sub);
152 expect(user).toBeDefined();
153 expect((await listNamespacesForUser(db, user!.id)).length).toBe(0);
154 });
155 
156 it("creates only session for a returning user even if pref-name is now valid+free", async () => {
157 const sub = "sub-returning";
158 const state1 = "state-r1";
159 stubGrantWithClaims({ sub });
160 const cookie1 = await buildTransactionCookie(state1, "n1", "v1");
161 expect((await callCallback({ state: state1, cookie: cookie1 })).status).toBe(302);
162 const db = createDb(env.DB);
163 const user = await findUserByTesseraSub(db, sub);
164 expect(user).toBeDefined();
165 // Second sign-in tries to claim a free name; ensure it does NOT create
166 // a namespace because the user already exists.
167 stubGrantWithClaims({ sub, preferred_username: "newslug" });
168 const state2 = "state-r2";
169 const cookie2 = await buildTransactionCookie(state2, "n2", "v2");
170 const res = await callCallback({ state: state2, cookie: cookie2 });
171 expect(res.status).toBe(302);
172 const namespace = await findNamespaceBySlug(db, "newslug");
173 expect(namespace).toBeUndefined();
174 expect((await listNamespacesForUser(db, user!.id)).length).toBe(0);
175 });
176 
177 it("clears the OIDC transaction cookie when runtime config is missing", async () => {
178 const cookie = await buildTransactionCookie("state-cfg", "n", "v");
179 // Force loadOidcConfig() into the "missing_client_secret" branch by
180 // blanking the binding for the duration of this test. Restoring at the
181 // end so other tests still have a complete config.
182 const original = env.TESSERA_OIDC_CLIENT_SECRET;
183 env.TESSERA_OIDC_CLIENT_SECRET = "";
184 try {
185 const url = new URL("https://example.com/auth/callback");
186 url.searchParams.set("code", "anything");
187 url.searchParams.set("state", "state-cfg");
188 const res = await workerExports.default.fetch(url.toString(), {
189 redirect: "manual",
190 headers: { Cookie: cookie },
191 });
192 expect(res.status).toBe(302);
193 expect(res.headers.get("location")).toBe("/auth?error=oidc_unavailable");
194 const setCookie = res.headers.get("set-cookie") ?? "";
195 expect(setCookie).toContain(`${OIDC_TX_COOKIE_HEADER_NAME}=`);
196 expect(setCookie.toLowerCase()).toContain("max-age=0");
197 } finally {
198 env.TESSERA_OIDC_CLIENT_SECRET = original;
199 }
200 });
201 
202 it("fails before D1 writes when SESSION_SECRET is missing", async () => {
203 const sub = "sub-missing-session-secret";
204 const state = "state-missing-session-secret";
205 stubGrantWithClaims({ sub, preferred_username: "missing-session-secret" });
206 const cookie = await buildTransactionCookie(state, "n", "v");
207 const original = env.SESSION_SECRET;
208 env.SESSION_SECRET = "";
209 try {
210 const res = await callCallback({ state, cookie });
211 expect(res.status).toBe(302);
212 expect(res.headers.get("location")).toBe("/auth?error=session_create_failed");
213 const setCookie = res.headers.get("set-cookie") ?? "";
214 expect(setCookie).toContain(`${OIDC_TX_COOKIE_HEADER_NAME}=`);
215 expect(setCookie.toLowerCase()).toContain("max-age=0");
216 const db = createDb(env.DB);
217 expect(await findUserByTesseraSub(db, sub)).toBeUndefined();
218 } finally {
219 env.SESSION_SECRET = original;
220 }
221 });
222 
223 it("redirects to /auth?error=missing_state when the OIDC cookie is absent", async () => {
224 const res = await callCallback({ state: "anything" });
225 expect(res.status).toBe(302);
226 expect(res.headers.get("location")).toBe("/auth?error=missing_state");
227 });
228 
229 it("redirects to /auth?error=invalid_state when the OIDC cookie signature is invalid", async () => {
230 const res = await callCallback({
231 state: "anything",
232 cookie: `${OIDC_TX_COOKIE_HEADER_NAME}=not-signed`,
233 });
234 expect(res.status).toBe(302);
235 expect(res.headers.get("location")).toBe("/auth?error=invalid_state");
236 const setCookie = res.headers.get("set-cookie") ?? "";
237 expect(setCookie).toContain(`${OIDC_TX_COOKIE_HEADER_NAME}=`);
238 expect(setCookie.toLowerCase()).toContain("max-age=0");
239 });
240 
241 it("redirects to /auth?error=invalid_state when state does not match the cookie", async () => {
242 const cookie = await buildTransactionCookie("state-A", "n", "v");
243 const res = await callCallback({ state: "state-B", cookie });
244 expect(res.status).toBe(302);
245 expect(res.headers.get("location")).toBe("/auth?error=invalid_state");
246 });
247 
248 it("redirects to /auth?error=invalid_id_token when the grant raises ClientError", async () => {
249 const sub = "sub-grant-err";
250 stubGrantWithClaims({ sub }, { rejectAs: "client" });
251 const cookie = await buildTransactionCookie("state-grant", "n", "v");
252 const res = await callCallback({ state: "state-grant", cookie });
253 expect(res.status).toBe(302);
254 expect(res.headers.get("location")).toBe("/auth?error=invalid_id_token");
255 });
256 
257 it("seeds membership for the existing namespace owner on returning sign-in", async () => {
258 const sub = "sub-existing-member";
259 const userId = "user-existing-member";
260 const namespaceId = "ns-existing-member";
261 const db = createDb(env.DB);
262 const schema = await import("@/worker/db/d1/schema");
263 await db.batch([
264 db.insert(schema.users).values({ id: userId, tesseraSub: sub, createdAt: Date.now() }),
265 db.insert(schema.namespaces).values({
266 id: namespaceId,
267 slug: "preexisting",
268 createdBy: userId,
269 createdAt: Date.now(),
270 }),
271 ]);
272 await insertMembershipIfMissing(db, {
273 namespaceId,
274 userId,
275 createdAt: Date.now(),
276 });
277 stubGrantWithClaims({ sub, preferred_username: "preexisting" });
278 const state = "state-pre";
279 const cookie = await buildTransactionCookie(state, "n", "v");
280 const res = await callCallback({ state, cookie });
281 expect(res.status).toBe(302);
282 expect((await listNamespacesForUser(db, userId)).map((row) => row.slug)).toEqual([
283 "preexisting",
284 ]);
285 });
286 
287 it("creates a fresh namespace claim without creating route-cache entries", async () => {
288 const slug = "callback-direct";
289 const repo = "not-created-by-callback";
290 const routeKey = `repo-route:v1:${slug}/${repo}`;
291 await env.ROUTES.delete(routeKey);
292 stubGrantWithClaims({ sub: "sub-callback-direct", preferred_username: slug });
293 const cookie = await buildTransactionCookie("state-callback-direct", "n", "v");
294 const res = await callCallback({ state: "state-callback-direct", cookie });
295 expect(res.status).toBe(302);
296 await new Promise((resolve) => setTimeout(resolve, 250));
297 expect(await env.ROUTES.get(routeKey)).toBeNull();
298 });
299});