Skip to content
File

Blob: tests/worker/api/admin.test.ts

typescript529 lines
1import { env } from "cloudflare:test";
2import { OIDCMockProvider } from "@mongodb-js/oidc-mock-provider";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4import { ADMIN_COOKIE_HEADER_NAME, OIDC_TRANSACTION_COOKIE_HEADER_NAME } from "@/worker/services/cookies";
5import { clearOidcCachesForTesting } from "@/worker/services/oidc";
6import { apiRequest, resetWorkerState, seedAdminCookieSession, seedDomain } from "./helpers";
7 
8const fetchMock = vi.fn<typeof fetch>();
9 
10const ISSUER = "http://127.0.0.1:6174";
11const DISCOVERY_ENDPOINT = `${ISSUER}/.well-known/openid-configuration`;
12const AUTHORIZE_ENDPOINT = `${ISSUER}/api/auth/oauth2/authorize`;
13const TOKEN_ENDPOINT = `${ISSUER}/api/auth/oauth2/token`;
14const JWKS_ENDPOINT = `${ISSUER}/api/auth/jwks`;
15const CLIENT_ID = "local-flamemail";
16const ALLOWED_SUB = "00000000-0000-4000-8000-000000000001";
17const FLAMEMAIL_ORIGIN = "https://flamemail.devbin.tools";
18const SCOPE = "openid profile email";
19 
20let mockJwksKeys: Record<string, unknown>[] = [];
21 
22function jsonResponse(payload: unknown, init?: ResponseInit) {
23 return new Response(JSON.stringify(payload), {
24 ...init,
25 headers: {
26 "content-type": "application/json",
27 ...(init?.headers ?? {}),
28 },
29 });
30}
31 
32interface MockExchangeOptions {
33 idToken?: string;
34 status?: number;
35 body?: unknown;
36 discoveryBody?: unknown;
37 discoveryStatus?: number;
38 discoveryThrows?: boolean;
39}
40 
41function discoveryDocument(overrides: Record<string, unknown> = {}) {
42 return {
43 issuer: ISSUER,
44 authorization_endpoint: AUTHORIZE_ENDPOINT,
45 token_endpoint: TOKEN_ENDPOINT,
46 jwks_uri: JWKS_ENDPOINT,
47 response_types_supported: ["code"],
48 subject_types_supported: ["public"],
49 id_token_signing_alg_values_supported: ["RS256"],
50 scopes_supported: ["openid", "profile", "email"],
51 ...overrides,
52 };
53}
54 
55function mockOidcCalls(options: MockExchangeOptions = {}) {
56 fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
57 const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
58 if (url === DISCOVERY_ENDPOINT) {
59 if (options.discoveryThrows) {
60 throw new Error("discovery failed");
61 }
62 return jsonResponse(options.discoveryBody ?? discoveryDocument(), { status: options.discoveryStatus ?? 200 });
63 }
64 if (url === JWKS_ENDPOINT) {
65 return jsonResponse({ keys: mockJwksKeys });
66 }
67 if (url === TOKEN_ENDPOINT) {
68 if (options.body !== undefined || options.status !== undefined) {
69 return jsonResponse(options.body ?? {}, { status: options.status ?? 200 });
70 }
71 return jsonResponse({
72 token_type: "Bearer",
73 access_token: "at_test",
74 id_token: options.idToken,
75 expires_in: 300,
76 });
77 }
78 throw new Error(`unexpected fetch in mock: ${url}`);
79 });
80}
81 
82interface OidcMockProviderInternals {
83 close(): Promise<void>;
84 issuer: string;
85 issueToken(metadata: { client_id: string; nonce?: string; scope: string }): Promise<{ id_token?: string }>;
86 kid: string;
87 keys: {
88 publicKey: {
89 export(options: { format: "jwk" }): Record<string, unknown>;
90 };
91 };
92}
93 
94async function issueMockIdToken(options: {
95 customIdTokenPayload?: Record<string, unknown>;
96 nonce?: string;
97 sub: string;
98}) {
99 const provider = (await OIDCMockProvider.create({
100 hostname: "127.0.0.1",
101 getTokenPayload() {
102 return {
103 customIdTokenPayload: options.customIdTokenPayload,
104 expires_in: 300,
105 payload: {
106 sub: options.sub,
107 },
108 skipRefreshToken: true,
109 };
110 },
111 })) as unknown as OidcMockProviderInternals;
112 
113 try {
114 provider.issuer = ISSUER;
115 mockJwksKeys = [
116 {
117 alg: "RS256",
118 kid: provider.kid,
119 ...provider.keys.publicKey.export({ format: "jwk" }),
120 },
121 ];
122 const issued = await provider.issueToken({
123 client_id: CLIENT_ID,
124 nonce: options.nonce,
125 scope: SCOPE,
126 });
127 if (!issued.id_token) {
128 throw new Error("OIDC mock provider did not issue an id_token");
129 }
130 return issued.id_token;
131 } finally {
132 await provider.close();
133 }
134}
135 
136function parseSetCookie(response: Response): Map<string, { value: string; attributes: Map<string, string | true> }> {
137 const cookies = new Map<string, { value: string; attributes: Map<string, string | true> }>();
138 // Workers + Miniflare expose headers via getSetCookie() since hono uses it.
139 const list = response.headers.getSetCookie?.() ?? [];
140 for (const raw of list) {
141 const segments = raw.split(";").map((part) => part.trim());
142 const [first, ...rest] = segments;
143 if (!first) continue;
144 const eqIdx = first.indexOf("=");
145 if (eqIdx === -1) continue;
146 const name = first.slice(0, eqIdx);
147 const value = first.slice(eqIdx + 1);
148 const attributes = new Map<string, string | true>();
149 for (const attr of rest) {
150 if (!attr) continue;
151 const idx = attr.indexOf("=");
152 if (idx === -1) {
153 attributes.set(attr.toLowerCase(), true);
154 } else {
155 attributes.set(attr.slice(0, idx).toLowerCase(), attr.slice(idx + 1));
156 }
157 }
158 cookies.set(name, { value, attributes });
159 }
160 return cookies;
161}
162 
163describe("worker api /api/public/admin (OIDC)", () => {
164 beforeEach(async () => {
165 clearOidcCachesForTesting();
166 mockJwksKeys = [];
167 fetchMock.mockReset();
168 vi.stubGlobal("fetch", fetchMock);
169 mockOidcCalls();
170 await resetWorkerState();
171 });
172 
173 afterEach(() => {
174 vi.unstubAllGlobals();
175 });
176 
177 it("redirects /admin/start to the discovered authorize endpoint and sets the transaction cookie", async () => {
178 const response = await apiRequest("/api/public/admin/start", { method: "GET" });
179 
180 expect(response.status).toBe(302);
181 const location = response.headers.get("location");
182 expect(location).toBeTruthy();
183 const url = new URL(location ?? "");
184 expect(url.origin).toBe(ISSUER);
185 expect(url.pathname).toBe("/api/auth/oauth2/authorize");
186 expect(fetchMock).toHaveBeenCalledWith(DISCOVERY_ENDPOINT, expect.anything());
187 expect(url.searchParams.get("response_type")).toBe("code");
188 expect(url.searchParams.get("client_id")).toBe(CLIENT_ID);
189 expect(url.searchParams.get("scope")).toBe("openid profile email");
190 expect(url.searchParams.get("code_challenge_method")).toBe("S256");
191 expect(url.searchParams.get("redirect_uri")).toBe(`${FLAMEMAIL_ORIGIN}/api/public/admin/callback`);
192 expect(url.searchParams.get("state")).toBeTruthy();
193 expect(url.searchParams.get("nonce")).toBeTruthy();
194 
195 const cookies = parseSetCookie(response);
196 const txn = cookies.get(OIDC_TRANSACTION_COOKIE_HEADER_NAME);
197 expect(txn).toBeDefined();
198 expect(txn?.attributes.get("httponly")).toBe(true);
199 expect(txn?.attributes.get("secure")).toBe(true);
200 expect(txn?.attributes.get("samesite")?.toString().toLowerCase()).toBe("lax");
201 expect(txn?.attributes.get("path")).toBe("/");
202 });
203 
204 it("redirects /admin/start to /admin?error=ADMIN_ACCESS_DISABLED when OIDC env is missing", async () => {
205 const response = await apiRequest("/api/public/admin/start", {
206 method: "GET",
207 envOverrides: { TESSERA_OIDC_CLIENT_SECRET: "" },
208 });
209 
210 expect(response.status).toBe(302);
211 expect(response.headers.get("location")).toBe("/admin?error=ADMIN_ACCESS_DISABLED");
212 });
213 
214 it.each([
215 ["the provider is unavailable", { discoveryThrows: true }],
216 [
217 "a discovered endpoint violates policy",
218 {
219 discoveryBody: discoveryDocument({
220 authorization_endpoint: "http://auth.example/api/auth/oauth2/authorize",
221 }),
222 },
223 ],
224 ] satisfies Array<[string, MockExchangeOptions]>)(
225 "redirects /admin/start to /admin?error=ADMIN_ACCESS_DISABLED when discovery %s",
226 async (_name, options) => {
227 mockOidcCalls(options);
228 
229 const response = await apiRequest("/api/public/admin/start", { method: "GET" });
230 
231 expect(response.status).toBe(302);
232 expect(response.headers.get("location")).toBe("/admin?error=ADMIN_ACCESS_DISABLED");
233 },
234 );
235 
236 it("completes the callback, mints an admin session, and sets __Host-flamemail-admin", async () => {
237 const startResponse = await apiRequest("/api/public/admin/start", { method: "GET" });
238 const txnCookie = parseSetCookie(startResponse).get(OIDC_TRANSACTION_COOKIE_HEADER_NAME)?.value;
239 const startUrl = new URL(startResponse.headers.get("location") ?? "");
240 const state = startUrl.searchParams.get("state") ?? "";
241 const nonce = startUrl.searchParams.get("nonce") ?? "";
242 
243 const idToken = await issueMockIdToken({ sub: ALLOWED_SUB, nonce });
244 mockOidcCalls({ idToken });
245 
246 const callback = await apiRequest(`/api/public/admin/callback?code=test-code&state=${encodeURIComponent(state)}`, {
247 method: "GET",
248 cookie: `${OIDC_TRANSACTION_COOKIE_HEADER_NAME}=${txnCookie}`,
249 });
250 
251 expect(callback.status).toBe(302);
252 expect(callback.headers.get("location")).toBe("/admin");
253 expect(fetchMock).toHaveBeenCalledWith(TOKEN_ENDPOINT, expect.objectContaining({ method: "POST" }));
254 
255 const callbackCookies = parseSetCookie(callback);
256 const adminCookie = callbackCookies.get(ADMIN_COOKIE_HEADER_NAME);
257 expect(adminCookie).toBeDefined();
258 expect(adminCookie?.value).toMatch(/^tok_/);
259 expect(adminCookie?.attributes.get("httponly")).toBe(true);
260 expect(adminCookie?.attributes.get("secure")).toBe(true);
261 
262 const txnExpired = callbackCookies.get(OIDC_TRANSACTION_COOKIE_HEADER_NAME);
263 expect(txnExpired).toBeDefined();
264 expect(txnExpired?.attributes.get("max-age")).toBe("0");
265 
266 const stored = await env.SESSIONS.get(`token:${adminCookie?.value}`, "json");
267 expect(stored).toEqual({ type: "admin", sub: ALLOWED_SUB });
268 });
269 
270 it("redirects /admin/callback with not_operator when sub is not on the allowlist", async () => {
271 const startResponse = await apiRequest("/api/public/admin/start", { method: "GET" });
272 const txnCookie = parseSetCookie(startResponse).get(OIDC_TRANSACTION_COOKIE_HEADER_NAME)?.value;
273 const startUrl = new URL(startResponse.headers.get("location") ?? "");
274 const state = startUrl.searchParams.get("state") ?? "";
275 const nonce = startUrl.searchParams.get("nonce") ?? "";
276 
277 const idToken = await issueMockIdToken({ sub: "not-on-allowlist", nonce });
278 mockOidcCalls({ idToken });
279 
280 const callback = await apiRequest(`/api/public/admin/callback?code=test-code&state=${encodeURIComponent(state)}`, {
281 method: "GET",
282 cookie: `${OIDC_TRANSACTION_COOKIE_HEADER_NAME}=${txnCookie}`,
283 });
284 
285 expect(callback.status).toBe(302);
286 expect(callback.headers.get("location")).toBe("/admin?error=not_operator");
287 });
288 
289 it("redirects /admin/callback with invalid_id_token when nonce mismatches", async () => {
290 const startResponse = await apiRequest("/api/public/admin/start", { method: "GET" });
291 const txnCookie = parseSetCookie(startResponse).get(OIDC_TRANSACTION_COOKIE_HEADER_NAME)?.value;
292 const startUrl = new URL(startResponse.headers.get("location") ?? "");
293 const state = startUrl.searchParams.get("state") ?? "";
294 
295 const idToken = await issueMockIdToken({
296 customIdTokenPayload: { nonce: "different-nonce" },
297 sub: ALLOWED_SUB,
298 });
299 mockOidcCalls({ idToken });
300 
301 const callback = await apiRequest(`/api/public/admin/callback?code=test-code&state=${encodeURIComponent(state)}`, {
302 method: "GET",
303 cookie: `${OIDC_TRANSACTION_COOKIE_HEADER_NAME}=${txnCookie}`,
304 });
305 
306 expect(callback.status).toBe(302);
307 expect(callback.headers.get("location")).toBe("/admin?error=invalid_id_token");
308 });
309 
310 it("redirects /admin/callback with invalid_state when state mismatches", async () => {
311 const startResponse = await apiRequest("/api/public/admin/start", { method: "GET" });
312 const txnCookie = parseSetCookie(startResponse).get(OIDC_TRANSACTION_COOKIE_HEADER_NAME)?.value;
313 
314 const callback = await apiRequest(`/api/public/admin/callback?code=test-code&state=tampered`, {
315 method: "GET",
316 cookie: `${OIDC_TRANSACTION_COOKIE_HEADER_NAME}=${txnCookie}`,
317 });
318 
319 expect(callback.status).toBe(302);
320 expect(callback.headers.get("location")).toBe("/admin?error=invalid_state");
321 });
322 
323 it("redirects /admin/callback with missing_state when transaction cookie is absent", async () => {
324 const callback = await apiRequest(`/api/public/admin/callback?code=test-code&state=anything`, {
325 method: "GET",
326 });
327 
328 expect(callback.status).toBe(302);
329 expect(callback.headers.get("location")).toBe("/admin?error=missing_state");
330 });
331 
332 it("redirects /admin/callback with invalid_state when transaction cookie signature is invalid", async () => {
333 const callback = await apiRequest(`/api/public/admin/callback?code=test-code&state=anything`, {
334 method: "GET",
335 cookie: `${OIDC_TRANSACTION_COOKIE_HEADER_NAME}=not-signed`,
336 });
337 
338 expect(callback.status).toBe(302);
339 expect(callback.headers.get("location")).toBe("/admin?error=invalid_state");
340 const callbackCookies = parseSetCookie(callback);
341 expect(callbackCookies.get(OIDC_TRANSACTION_COOKIE_HEADER_NAME)?.attributes.get("max-age")).toBe("0");
342 });
343 
344 it("clears the transaction cookie when OIDC config is missing during callback", async () => {
345 const callback = await apiRequest(`/api/public/admin/callback?code=test-code&state=anything`, {
346 method: "GET",
347 cookie: `${OIDC_TRANSACTION_COOKIE_HEADER_NAME}=stale`,
348 envOverrides: { TESSERA_OIDC_CLIENT_SECRET: "" },
349 });
350 
351 expect(callback.status).toBe(302);
352 expect(callback.headers.get("location")).toBe("/admin?error=ADMIN_ACCESS_DISABLED");
353 const callbackCookies = parseSetCookie(callback);
354 expect(callbackCookies.get(OIDC_TRANSACTION_COOKIE_HEADER_NAME)?.attributes.get("max-age")).toBe("0");
355 });
356 
357 it("redirects /admin/callback with token_exchange_failed when tessera token endpoint returns non-OK", async () => {
358 const startResponse = await apiRequest("/api/public/admin/start", { method: "GET" });
359 const txnCookie = parseSetCookie(startResponse).get(OIDC_TRANSACTION_COOKIE_HEADER_NAME)?.value;
360 const startUrl = new URL(startResponse.headers.get("location") ?? "");
361 const state = startUrl.searchParams.get("state") ?? "";
362 
363 mockOidcCalls({ status: 400, body: { error: "invalid_grant" } });
364 
365 const callback = await apiRequest(`/api/public/admin/callback?code=test-code&state=${encodeURIComponent(state)}`, {
366 method: "GET",
367 cookie: `${OIDC_TRANSACTION_COOKIE_HEADER_NAME}=${txnCookie}`,
368 });
369 
370 expect(callback.status).toBe(302);
371 expect(callback.headers.get("location")).toBe("/admin?error=token_exchange_failed");
372 });
373 
374 it("redirects /admin/callback with ADMIN_ACCESS_DISABLED when discovery fails before token exchange", async () => {
375 const startResponse = await apiRequest("/api/public/admin/start", { method: "GET" });
376 const txnCookie = parseSetCookie(startResponse).get(OIDC_TRANSACTION_COOKIE_HEADER_NAME)?.value;
377 const startUrl = new URL(startResponse.headers.get("location") ?? "");
378 const state = startUrl.searchParams.get("state") ?? "";
379 
380 clearOidcCachesForTesting();
381 mockOidcCalls({ discoveryStatus: 503 });
382 
383 const callback = await apiRequest(`/api/public/admin/callback?code=test-code&state=${encodeURIComponent(state)}`, {
384 method: "GET",
385 cookie: `${OIDC_TRANSACTION_COOKIE_HEADER_NAME}=${txnCookie}`,
386 });
387 
388 expect(callback.status).toBe(302);
389 expect(callback.headers.get("location")).toBe("/admin?error=ADMIN_ACCESS_DISABLED");
390 expect(fetchMock).not.toHaveBeenCalledWith(TOKEN_ENDPOINT, expect.anything());
391 
392 const callbackCookies = parseSetCookie(callback);
393 expect(callbackCookies.get(OIDC_TRANSACTION_COOKIE_HEADER_NAME)?.attributes.get("max-age")).toBe("0");
394 });
395 
396 it("logout clears the admin cookie and KV record", async () => {
397 const { token, cookie } = await seedAdminCookieSession();
398 expect(await env.SESSIONS.get(`token:${token}`)).not.toBeNull();
399 
400 const response = await apiRequest("/api/public/admin/logout", {
401 method: "POST",
402 cookie,
403 });
404 
405 expect(response.status).toBe(200);
406 await expect(response.json()).resolves.toEqual({ ok: true });
407 
408 const cookies = parseSetCookie(response);
409 expect(cookies.get(ADMIN_COOKIE_HEADER_NAME)?.attributes.get("max-age")).toBe("0");
410 
411 expect(await env.SESSIONS.get(`token:${token}`)).toBeNull();
412 });
413 
414 it("logout rejects cross-origin requests", async () => {
415 const { cookie } = await seedAdminCookieSession();
416 
417 const response = await apiRequest("/api/public/admin/logout", {
418 method: "POST",
419 cookie,
420 origin: "https://attacker.example",
421 });
422 
423 expect(response.status).toBe(403);
424 });
425});
426 
427describe("worker api /api/protected/admin", () => {
428 beforeEach(async () => {
429 fetchMock.mockReset();
430 vi.stubGlobal("fetch", fetchMock);
431 await resetWorkerState();
432 });
433 
434 afterEach(() => {
435 vi.unstubAllGlobals();
436 });
437 
438 it("requires the admin cookie for /admin/domains", async () => {
439 const response = await apiRequest("/api/protected/admin/domains");
440 expect(response.status).toBe(401);
441 });
442 
443 it("rejects bearer admin tokens on /admin/domains", async () => {
444 const { token } = await seedAdminCookieSession();
445 const response = await apiRequest("/api/protected/admin/domains", { token });
446 expect(response.status).toBe(401);
447 });
448 
449 it("returns admin domains with a valid cookie", async () => {
450 await seedDomain("mail.test");
451 const { cookie } = await seedAdminCookieSession();
452 
453 const response = await apiRequest("/api/protected/admin/domains", { cookie });
454 expect(response.status).toBe(200);
455 const payload = (await response.json()) as { domains: Array<{ domain: string }> };
456 expect(payload.domains.map((d) => d.domain)).toContain("mail.test");
457 });
458 
459 it("fails closed when OIDC config is missing even with a valid cookie", async () => {
460 const { cookie } = await seedAdminCookieSession();
461 
462 const response = await apiRequest("/api/protected/admin/domains", {
463 cookie,
464 envOverrides: { TESSERA_OIDC_CLIENT_SECRET: "" },
465 });
466 expect(response.status).toBe(503);
467 await expect(response.json()).resolves.toEqual({
468 code: "ADMIN_ACCESS_DISABLED",
469 error: "Admin access is unavailable because tessera OIDC is not configured or cannot be discovered.",
470 });
471 });
472 
473 it("fails closed when the issuer is plaintext non-loopback", async () => {
474 const { cookie } = await seedAdminCookieSession();
475 
476 const response = await apiRequest("/api/protected/admin/domains", {
477 cookie,
478 envOverrides: { TESSERA_OIDC_ISSUER: "http://auth.example" },
479 });
480 expect(response.status).toBe(503);
481 await expect(response.json()).resolves.toMatchObject({
482 code: "ADMIN_ACCESS_DISABLED",
483 });
484 });
485 
486 it("rejects an admin session whose sub is no longer on the allowlist", async () => {
487 const { cookie } = await seedAdminCookieSession({ sub: "11111111-1111-4111-8111-111111111111" });
488 
489 const response = await apiRequest("/api/protected/admin/domains", { cookie });
490 expect(response.status).toBe(403);
491 await expect(response.json()).resolves.toEqual({ error: "Forbidden" });
492 });
493 
494 it("rejects an admin session when TESSERA_OPERATOR_SUBS no longer lists the session sub", async () => {
495 const { cookie } = await seedAdminCookieSession();
496 
497 const response = await apiRequest("/api/protected/admin/domains", {
498 cookie,
499 envOverrides: { TESSERA_OPERATOR_SUBS: "22222222-2222-4222-8222-222222222222" },
500 });
501 expect(response.status).toBe(403);
502 });
503 
504 it("rejects cross-origin POST /admin/domains with the cookie", async () => {
505 const { cookie } = await seedAdminCookieSession();
506 
507 const response = await apiRequest("/api/protected/admin/domains", {
508 method: "POST",
509 cookie,
510 origin: "https://attacker.example",
511 body: { domain: "evil.test", isActive: true },
512 });
513 
514 expect(response.status).toBe(403);
515 });
516 
517 it("accepts same-origin POST /admin/domains with the cookie", async () => {
518 const { cookie } = await seedAdminCookieSession();
519 
520 const response = await apiRequest("/api/protected/admin/domains", {
521 method: "POST",
522 cookie,
523 body: { domain: "fresh.test", isActive: true },
524 });
525 
526 expect(response.status).toBe(201);
527 });
528});