Skip to content
File

Blob: tests/worker/routes/oidc.workers.test.ts

typescript344 lines
1import { env } from "cloudflare:workers";
2import { beforeEach, describe, expect, it, afterEach } from "vitest";
3import { eq } from "drizzle-orm";
4import * as oidc from "openid-client";
5 
6import { __test, OIDC_TX_COOKIE, encodeTxCookie, type ResolvedClaims, type TxCookiePayload } from "@/worker/lib/oidc";
7import { memberships, tesseraIdentities, users, workspaces } from "@/worker/db/d1/schema";
8import { getDb, resetD1Tables } from "@tests/worker/helpers/db";
9import { apiRequest, LOOPBACK_ORIGIN } from "@tests/worker/helpers/request";
10import { seedTesseraIdentity, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds";
11 
12const ISSUER = "https://tessera.test";
13const CALLBACK_ORIGIN = LOOPBACK_ORIGIN;
14const REDIRECT_URI = `${CALLBACK_ORIGIN}/api/v1/oidc/callback`;
15 
16// Loopback bypasses RL_AUTH (rate-limit middleware short-circuits for
17// localhost/127.0.0.1 hosts), so every test here shares one ungated bucket.
18// For tests that need a non-loopback origin (to exercise the origin gate),
19// the per-test IP keeps the production-mode bucket isolated.
20let ipCounter = 0;
21function uniqueIpHeaders(): Record<string, string> {
22 ipCounter += 1;
23 return { "cf-connecting-ip": `10.0.${Math.floor(ipCounter / 256)}.${ipCounter % 256}` };
24}
25const STATE = "test-state-deterministic";
26const NONCE = "test-nonce-deterministic";
27 
28function buildConfig(): oidc.Configuration {
29 return new oidc.Configuration(
30 {
31 issuer: ISSUER,
32 authorization_endpoint: `${ISSUER}/authorize`,
33 token_endpoint: `${ISSUER}/token`,
34 jwks_uri: `${ISSUER}/jwks`,
35 response_types_supported: ["code"],
36 },
37 "bland-test",
38 "test-tessera-client-secret-deterministic",
39 );
40}
41 
42async function buildTxCookieHeader(overrides: Partial<TxCookiePayload> = {}): Promise<string> {
43 const payload: TxCookiePayload = {
44 state: STATE,
45 nonce: NONCE,
46 codeVerifier: "test-code-verifier-deterministic-1234567890",
47 redirectUri: REDIRECT_URI,
48 returnTo: "/",
49 createdAt: Date.now(),
50 ...overrides,
51 };
52 const value = await encodeTxCookie(env, payload);
53 return `${OIDC_TX_COOKIE}=${value}`;
54}
55 
56function buildClaims(overrides: Partial<ResolvedClaims & { email_verified: boolean }> = {}): oidc.IDToken {
57 return {
58 iss: ISSUER,
59 aud: "bland-test",
60 exp: Math.floor(Date.now() / 1000) + 300,
61 iat: Math.floor(Date.now() / 1000),
62 sub: overrides.sub ?? "tessera-sub-1",
63 email: overrides.email ?? "user@example.com",
64 email_verified: overrides.email_verified ?? true,
65 name: overrides.name ?? "Test User",
66 } as oidc.IDToken;
67}
68 
69function stubClaims(claims: oidc.IDToken | undefined): void {
70 __test.setAuthorizationCodeGrantImpl(async () => ({
71 claims: () => claims,
72 }));
73}
74 
75function stubClaimsThrows(message: string): void {
76 __test.setAuthorizationCodeGrantImpl(async () => {
77 throw new Error(message);
78 });
79}
80 
81async function callOidcCallback(
82 opts: {
83 cookie?: string;
84 state?: string;
85 } = {},
86): Promise<Response> {
87 const search: Record<string, string> = { code: "auth-code", state: opts.state ?? STATE };
88 return apiRequest("/api/v1/oidc/callback", {
89 method: "GET",
90 origin: CALLBACK_ORIGIN,
91 cookie: opts.cookie,
92 search,
93 redirect: "manual",
94 });
95}
96 
97describe("OIDC callback identity binding", () => {
98 beforeEach(async () => {
99 await resetD1Tables();
100 __test.setProviderForTesting(ISSUER, buildConfig());
101 });
102 
103 afterEach(() => {
104 __test.clear();
105 });
106 
107 it("returning sub with matching email signs in and updates last_seen_at", async () => {
108 const user = await seedUser({ email: "user@example.com", name: "Existing User" });
109 await seedTesseraIdentity({ sub: "tessera-sub-1", user_id: user.id });
110 stubClaims(buildClaims({ sub: "tessera-sub-1", email: "user@example.com" }));
111 
112 const cookie = await buildTxCookieHeader();
113 const res = await callOidcCallback({ cookie });
114 
115 expect(res.status).toBe(302);
116 expect(res.headers.get("location")).toContain("oidc=1");
117 expect(res.headers.get("set-cookie")).toContain("bland_refresh=");
118 
119 const identity = await getDb()
120 .select()
121 .from(tesseraIdentities)
122 .where(eq(tesseraIdentities.sub, "tessera-sub-1"))
123 .get();
124 expect(identity?.last_seen_at).not.toBeNull();
125 
126 const updated = await getDb().select().from(users).where(eq(users.id, user.id)).get();
127 expect(updated?.email).toBe("user@example.com");
128 });
129 
130 it("returning sub with changed-but-free email updates users.email", async () => {
131 const user = await seedUser({ email: "old@example.com", name: "Existing User" });
132 await seedTesseraIdentity({ sub: "tessera-sub-1", user_id: user.id });
133 stubClaims(buildClaims({ sub: "tessera-sub-1", email: "new@example.com" }));
134 
135 const cookie = await buildTxCookieHeader();
136 const res = await callOidcCallback({ cookie });
137 
138 expect(res.status).toBe(302);
139 
140 const updated = await getDb().select().from(users).where(eq(users.id, user.id)).get();
141 expect(updated?.email).toBe("new@example.com");
142 });
143 
144 it("returning sub colliding with another user's email fails closed", async () => {
145 const userA = await seedUser({ email: "old@example.com" });
146 await seedUser({ email: "taken@example.com" });
147 await seedTesseraIdentity({ sub: "tessera-sub-1", user_id: userA.id });
148 stubClaims(buildClaims({ sub: "tessera-sub-1", email: "taken@example.com" }));
149 
150 const cookie = await buildTxCookieHeader();
151 const res = await callOidcCallback({ cookie });
152 
153 expect(res.status).toBe(302);
154 expect(res.headers.get("location")).toBe("/login?error=tessera_email_conflict");
155 expect(res.headers.get("set-cookie")).not.toContain("bland_refresh=");
156 
157 const userAStill = await getDb().select().from(users).where(eq(users.id, userA.id)).get();
158 expect(userAStill?.email).toBe("old@example.com");
159 });
160 
161 it("new sub matching unbound legacy user binds without creating workspace", async () => {
162 const legacy = await seedUser({ email: "legacy@example.com" });
163 await seedWorkspace({ owner_id: legacy.id, slug: "legacy-ws" });
164 stubClaims(buildClaims({ sub: "tessera-sub-new", email: "legacy@example.com" }));
165 
166 const cookie = await buildTxCookieHeader();
167 const res = await callOidcCallback({ cookie });
168 
169 expect(res.status).toBe(302);
170 
171 const identity = await getDb()
172 .select()
173 .from(tesseraIdentities)
174 .where(eq(tesseraIdentities.sub, "tessera-sub-new"))
175 .get();
176 expect(identity?.user_id).toBe(legacy.id);
177 
178 const allWorkspaces = await getDb().select().from(workspaces).all();
179 expect(allWorkspaces).toHaveLength(1);
180 });
181 
182 it("new sub matching already-bound user fails closed", async () => {
183 const bound = await seedUser({ email: "bound@example.com" });
184 await seedTesseraIdentity({ sub: "tessera-sub-old", user_id: bound.id });
185 stubClaims(buildClaims({ sub: "tessera-sub-new", email: "bound@example.com" }));
186 
187 const cookie = await buildTxCookieHeader();
188 const res = await callOidcCallback({ cookie });
189 
190 expect(res.status).toBe(302);
191 expect(res.headers.get("location")).toBe("/login?error=identity_conflict");
192 
193 const conflicting = await getDb()
194 .select()
195 .from(tesseraIdentities)
196 .where(eq(tesseraIdentities.sub, "tessera-sub-new"))
197 .get();
198 expect(conflicting).toBeUndefined();
199 });
200 
201 it("new sub creates user + identity + workspace + owner membership", async () => {
202 stubClaims(buildClaims({ sub: "tessera-sub-fresh", email: "fresh@example.com", name: "Fresh Person" }));
203 
204 const cookie = await buildTxCookieHeader();
205 const res = await callOidcCallback({ cookie });
206 
207 expect(res.status).toBe(302);
208 
209 const newUser = await getDb().select().from(users).where(eq(users.email, "fresh@example.com")).get();
210 expect(newUser).toBeTruthy();
211 expect(newUser?.name).toBe("Fresh Person");
212 
213 const identity = await getDb()
214 .select()
215 .from(tesseraIdentities)
216 .where(eq(tesseraIdentities.sub, "tessera-sub-fresh"))
217 .get();
218 expect(identity?.user_id).toBe(newUser?.id);
219 
220 const ws = await getDb().select().from(workspaces).where(eq(workspaces.owner_id, newUser!.id)).get();
221 expect(ws).toBeTruthy();
222 
223 const member = await getDb().select().from(memberships).where(eq(memberships.user_id, newUser!.id)).get();
224 expect(member?.role).toBe("owner");
225 });
226 
227 it("email_verified=false redirects to oidc_unverified_email", async () => {
228 stubClaims(buildClaims({ sub: "tessera-sub-1", email: "user@example.com", email_verified: false }));
229 
230 const cookie = await buildTxCookieHeader();
231 const res = await callOidcCallback({ cookie });
232 
233 expect(res.status).toBe(302);
234 expect(res.headers.get("location")).toBe("/login?error=oidc_unverified_email");
235 
236 const userRows = await getDb().select().from(users).all();
237 expect(userRows).toHaveLength(0);
238 });
239 
240 it("missing tx cookie redirects to oidc_session_expired", async () => {
241 stubClaims(buildClaims());
242 const res = await callOidcCallback();
243 
244 expect(res.status).toBe(302);
245 expect(res.headers.get("location")).toBe("/login?error=oidc_session_expired");
246 });
247 
248 it("tampered tx cookie redirects to oidc_session_expired", async () => {
249 stubClaims(buildClaims());
250 const cookie = await buildTxCookieHeader();
251 const tampered = cookie.slice(0, -4) + "XXXX";
252 const res = await callOidcCallback({ cookie: tampered });
253 
254 expect(res.status).toBe(302);
255 expect(res.headers.get("location")).toBe("/login?error=oidc_session_expired");
256 });
257 
258 it("state mismatch redirects to oidc_session_expired", async () => {
259 stubClaims(buildClaims());
260 const cookie = await buildTxCookieHeader();
261 const res = await callOidcCallback({ cookie, state: "wrong-state" });
262 
263 expect(res.status).toBe(302);
264 expect(res.headers.get("location")).toBe("/login?error=oidc_session_expired");
265 });
266 
267 it("token exchange failure redirects to oidc_provider_error", async () => {
268 stubClaimsThrows("token endpoint unreachable");
269 const cookie = await buildTxCookieHeader();
270 const res = await callOidcCallback({ cookie });
271 
272 expect(res.status).toBe(302);
273 expect(res.headers.get("location")).toBe("/login?error=oidc_provider_error");
274 });
275 
276 it("callback for the same sub with an existing session re-issues tokens", async () => {
277 const user = await seedUser({ email: "user@example.com" });
278 await seedTesseraIdentity({ sub: "tessera-sub-1", user_id: user.id });
279 stubClaims(buildClaims({ sub: "tessera-sub-1", email: "user@example.com" }));
280 
281 const cookie = await buildTxCookieHeader({ returnTo: "/workspaces" });
282 const res = await callOidcCallback({ cookie });
283 
284 expect(res.status).toBe(302);
285 expect(res.headers.get("location")).toContain("/workspaces");
286 expect(res.headers.get("location")).toContain("oidc=1");
287 expect(res.headers.get("set-cookie")).toContain("bland_refresh=");
288 });
289 
290 it("callback for a different sub swaps the session server-side", async () => {
291 const oldUser = await seedUser({ email: "old@example.com" });
292 await seedTesseraIdentity({ sub: "tessera-sub-old", user_id: oldUser.id });
293 const newUser = await seedUser({ email: "new@example.com" });
294 await seedTesseraIdentity({ sub: "tessera-sub-new", user_id: newUser.id });
295 stubClaims(buildClaims({ sub: "tessera-sub-new", email: "new@example.com" }));
296 
297 const cookie = await buildTxCookieHeader();
298 const res = await callOidcCallback({ cookie });
299 
300 expect(res.status).toBe(302);
301 expect(res.headers.get("location")).toContain("oidc=1");
302 expect(res.headers.get("set-cookie")).toContain("bland_refresh=");
303 });
304});
305 
306describe("OIDC start", () => {
307 beforeEach(async () => {
308 await resetD1Tables();
309 __test.setProviderForTesting(ISSUER, buildConfig());
310 });
311 
312 afterEach(() => {
313 __test.clear();
314 });
315 
316 it("redirects to the authorization endpoint and sets the tx cookie", async () => {
317 const res = await apiRequest("/api/v1/oidc/start", {
318 method: "GET",
319 origin: CALLBACK_ORIGIN,
320 search: { return_to: "/workspaces" },
321 redirect: "manual",
322 });
323 
324 expect(res.status).toBe(302);
325 const location = res.headers.get("location");
326 expect(location).toBeTruthy();
327 expect(location!.startsWith(`${ISSUER}/authorize`)).toBe(true);
328 
329 const setCookie = res.headers.get("set-cookie") ?? "";
330 expect(setCookie).toContain(OIDC_TX_COOKIE);
331 });
332 
333 it("rejects requests from disallowed origins", async () => {
334 const res = await apiRequest("/api/v1/oidc/start", {
335 method: "GET",
336 origin: "https://evil.example",
337 headers: uniqueIpHeaders(),
338 redirect: "manual",
339 });
340 
341 expect(res.status).toBe(403);
342 });
343});