Skip to content
File

Blob: tests/worker/control/auth/oidc.workers.test.ts

typescript120 lines
1import { env } from "cloudflare:workers";
2import { describe, expect, it } from "vitest";
3 
4import { createControlPlaneDb } from "@/worker/db/d1/client";
5import { getSubjectById } from "@/worker/db/d1/repository";
6import { completeOidcLogin, startOidcLogin } from "@tests/worker/helpers/auth";
7import { controlRequest, cookiePairFor, fetchWorker, setCookieHeaders } from "@tests/worker/helpers/http";
8 
9describe("tessera OIDC control-plane auth", () => {
10 it("starts login with discovery, state, nonce, PKCE, and a signed transaction cookie", async () => {
11 const { loginResponse, authorizationUrl } = await startOidcLogin("/after");
12 expect(loginResponse.status).toBe(302);
13 expect(authorizationUrl.pathname).toBe("/authorize");
14 expect(authorizationUrl.searchParams.get("response_type")).toBe("code");
15 expect(authorizationUrl.searchParams.get("scope")).toBe("openid email profile");
16 expect(authorizationUrl.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/);
17 expect(authorizationUrl.searchParams.get("nonce")).toMatch(/^[A-Za-z0-9_-]+$/);
18 expect(authorizationUrl.searchParams.get("code_challenge_method")).toBe("S256");
19 expect(authorizationUrl.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]+$/);
20 
21 const txCookie = setCookieHeaders(loginResponse).find((header) => header.startsWith("__Host-dab_oidc_tx="));
22 expect(txCookie).toContain("HttpOnly");
23 expect(txCookie).toContain("Secure");
24 expect(txCookie).toContain("SameSite=Lax");
25 expect(txCookie).toContain("Path=/");
26 expect(txCookie).not.toContain("Domain=");
27 });
28 
29 it("rejects foreign and protocol-relative return targets", async () => {
30 const foreign = await fetchWorker(
31 controlRequest(`/api/v1/auth/oidc/login?return_to=${encodeURIComponent("https://attacker.example/after")}`),
32 );
33 expect(foreign.status).toBe(400);
34 
35 const protocolRelative = await fetchWorker(
36 controlRequest(`/api/v1/auth/oidc/login?return_to=${encodeURIComponent("//attacker.example/after")}`),
37 );
38 expect(protocolRelative.status).toBe(400);
39 });
40 
41 it("rejects invalid callbacks and clears the transaction cookie", async () => {
42 const { transactionCookie } = await startOidcLogin();
43 const response = await fetchWorker(
44 controlRequest("/api/v1/auth/oidc/callback?code=bad&state=wrong", {
45 headers: { cookie: transactionCookie },
46 }),
47 );
48 
49 expect(response.status).toBe(400);
50 expect(setCookieHeaders(response).join("\n")).toContain("__Host-dab_oidc_tx=; Max-Age=0");
51 });
52 
53 it("bootstraps one stable subject row and creates API sessions", async () => {
54 const first = await completeOidcLogin();
55 expect(first.callbackResponse.status).toBe(200);
56 expect(setCookieHeaders(first.callbackResponse).join("\n")).toContain("__Host-dab_session=");
57 expect(setCookieHeaders(first.callbackResponse).join("\n")).toContain("__Host-dab_oidc_tx=; Max-Age=0");
58 
59 const db = createControlPlaneDb(env.DAV_CONTROL_PLANE);
60 const firstSubject = await getSubjectById(db, "tessera-sub-phase1");
61 expect(firstSubject).toBeDefined();
62 expect(firstSubject?.storageId).toMatch(/^stg_[0-9a-f]{32}$/);
63 expect(firstSubject?.hostLabel).toMatch(/^[a-z]+(-[a-z]+){4}$/);
64 expect(firstSubject?.hostLabel.length).toBeLessThanOrEqual(63);
65 
66 const meResponse = await fetchWorker(controlRequest("/api/v1/me", { headers: { cookie: first.sessionCookie } }));
67 expect(meResponse.status).toBe(200);
68 await expect(meResponse.json()).resolves.toMatchObject({
69 subject_id: "tessera-sub-phase1",
70 host_label: firstSubject?.hostLabel,
71 });
72 
73 const second = await completeOidcLogin();
74 expect(second.callbackResponse.status).toBe(200);
75 const secondSubject = await getSubjectById(db, "tessera-sub-phase1");
76 expect(secondSubject?.storageId).toBe(firstSubject?.storageId);
77 expect(secondSubject?.hostLabel).toBe(firstSubject?.hostLabel);
78 });
79 
80 it("rejects foreign-origin mutating requests before session lookup", async () => {
81 const missingOrigin = await fetchWorker(controlRequest("/api/v1/auth/logout", { method: "POST" }));
82 expect(missingOrigin.status).toBe(403);
83 
84 const foreignOrigin = await fetchWorker(
85 controlRequest("/api/v1/auth/logout", {
86 method: "POST",
87 headers: { origin: "https://attacker.example" },
88 }),
89 );
90 expect(foreignOrigin.status).toBe(403);
91 
92 const sameOriginNoSession = await fetchWorker(
93 controlRequest("/api/v1/auth/logout", {
94 method: "POST",
95 headers: { origin: "https://dav.example.com" },
96 }),
97 );
98 expect(sameOriginNoSession.status).toBe(401);
99 });
100 
101 it("logs out a same-origin API session and clears the session cookie", async () => {
102 const login = await completeOidcLogin();
103 const response = await fetchWorker(
104 controlRequest("/api/v1/auth/logout", {
105 method: "POST",
106 headers: {
107 cookie: login.sessionCookie,
108 origin: "https://dav.example.com",
109 },
110 }),
111 );
112 
113 expect(response.status).toBe(200);
114 expect(cookiePairFor(response, "__Host-dab_session")).toBe("__Host-dab_session=");
115 
116 const afterLogout = await fetchWorker(controlRequest("/api/v1/me", { headers: { cookie: login.sessionCookie } }));
117 expect(afterLogout.status).toBe(401);
118 });
119});