Skip to content
File

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

typescript486 lines
1import { SELF, env } from "cloudflare:test";
2import { eq } from "drizzle-orm";
3import { generateSignedCookie } from "hono/cookie";
4import { parseSigned } from "hono/utils/cookie";
5import * as oidc from "openid-client";
6import { afterEach, beforeEach, describe, expect, it } from "vitest";
7 
8import {
9 __test,
10 OIDC_TX_COOKIE,
11 OIDC_TX_COOKIE_MAX_AGE_SECONDS,
12 OIDC_TX_COOKIE_NAME,
13 decodeTxCookiePayload,
14 deriveTxCookieSecret,
15 encodeTxCookiePayload,
16 getTxCookieSecret,
17 type TxCookiePayload,
18} from "@/worker/auth/oidc";
19import * as d1Schema from "@/worker/db/d1/schema";
20 
21import { getDb, seedUser, type SeededUser } from "../../helpers/runtime";
22import { registerWorkerRuntimeHooks } from "../../helpers/worker-hooks";
23 
24const ISSUER = "https://tessera.test";
25const CLIENT_ID = "anvil-test";
26const CLIENT_SECRET = "anvil-test-secret";
27const CALLBACK_ORIGIN = "https://example.com";
28const REDIRECT_URI = `${CALLBACK_ORIGIN}/api/public/oidc/callback`;
29const STATE = "test-state";
30const NONCE = "test-nonce";
31 
32const buildConfig = (): oidc.Configuration =>
33 new oidc.Configuration(
34 {
35 issuer: ISSUER,
36 authorization_endpoint: `${ISSUER}/authorize`,
37 token_endpoint: `${ISSUER}/token`,
38 jwks_uri: `${ISSUER}/jwks`,
39 response_types_supported: ["code"],
40 },
41 CLIENT_ID,
42 CLIENT_SECRET,
43 );
44 
45const buildClaims = (overrides: Partial<oidc.IDToken> = {}): oidc.IDToken =>
46 ({
47 iss: ISSUER,
48 aud: CLIENT_ID,
49 exp: Math.floor(Date.now() / 1000) + 300,
50 iat: Math.floor(Date.now() / 1000),
51 sub: "tessera-sub-1",
52 email: "operator@example.com",
53 email_verified: true,
54 name: "Test Operator",
55 ...overrides,
56 }) as oidc.IDToken;
57 
58const stubClaims = (claims: oidc.IDToken | undefined): void => {
59 __test.setAuthorizationCodeGrantImpl(async () => ({
60 claims: () => claims,
61 }));
62};
63 
64const stubTokenExchangeFailure = (): void => {
65 __test.setAuthorizationCodeGrantImpl(async () => {
66 throw new Error("provider failed");
67 });
68};
69 
70const txCookiePayload = (overrides: Partial<TxCookiePayload> = {}): TxCookiePayload => ({
71 state: STATE,
72 nonce: NONCE,
73 codeVerifier: "test-code-verifier",
74 redirectUri: REDIRECT_URI,
75 returnTo: "/app/projects",
76 createdAt: Date.now(),
77 ...overrides,
78});
79 
80const buildTxCookieHeader = async (
81 overrides: Partial<TxCookiePayload> = {},
82 secret?: BufferSource,
83): Promise<string> => {
84 const txCookieSecret = secret ?? (await getTxCookieSecret(env));
85 const setCookie = await generateSignedCookie(
86 OIDC_TX_COOKIE_NAME,
87 encodeTxCookiePayload(txCookiePayload(overrides)),
88 txCookieSecret,
89 {
90 path: "/",
91 httpOnly: true,
92 secure: true,
93 sameSite: "Lax",
94 maxAge: OIDC_TX_COOKIE_MAX_AGE_SECONDS,
95 prefix: "host",
96 },
97 );
98 return setCookie.split(";", 1)[0] ?? setCookie;
99};
100 
101const extractSetCookiePair = (setCookie: string | null, name: string): string => {
102 const cookie = setCookie
103 ?.split(/,(?=\s*[^;=]+=[^;]+)/u)
104 .map((part) => part.trim())
105 .find((part) => part.startsWith(`${name}=`));
106 
107 if (!cookie) {
108 throw new Error(`Missing ${name} cookie.`);
109 }
110 
111 return cookie.split(";", 1)[0] ?? cookie;
112};
113 
114const decodeStartTxCookie = async (response: Response): Promise<TxCookiePayload> => {
115 const cookie = extractSetCookiePair(response.headers.get("set-cookie"), OIDC_TX_COOKIE);
116 const parsed = await parseSigned(cookie, await getTxCookieSecret(env), OIDC_TX_COOKIE);
117 const payload = decodeTxCookiePayload(parsed[OIDC_TX_COOKIE]);
118 
119 if (!payload) {
120 throw new Error("OIDC transaction cookie did not decode.");
121 }
122 
123 return payload;
124};
125 
126const callbackWithClaims = async (
127 claims: oidc.IDToken,
128 options: {
129 state?: string;
130 txCookie?: string;
131 txOverrides?: Partial<TxCookiePayload>;
132 } = {},
133): Promise<Response> => {
134 stubClaims(claims);
135 return await SELF.fetch(`${CALLBACK_ORIGIN}/api/public/oidc/callback?code=code&state=${options.state ?? STATE}`, {
136 redirect: "manual",
137 headers: {
138 cookie: options.txCookie ?? (await buildTxCookieHeader(options.txOverrides)),
139 },
140 });
141};
142 
143const insertIdentity = async (sub: string, userId: string): Promise<void> => {
144 await getDb().insert(d1Schema.tesseraIdentities).values({
145 sub,
146 userId,
147 createdAt: Date.now(),
148 lastSeenAt: null,
149 });
150};
151 
152const disableUser = async (user: SeededUser): Promise<void> => {
153 await getDb().update(d1Schema.users).set({ disabledAt: Date.now() }).where(eq(d1Schema.users.id, user.id));
154};
155 
156const findUserByEmail = async (email: string) => {
157 const rows = await getDb().select().from(d1Schema.users).where(eq(d1Schema.users.email, email));
158 return rows[0];
159};
160 
161const findIdentitiesBySub = async (sub: string) =>
162 await getDb().select().from(d1Schema.tesseraIdentities).where(eq(d1Schema.tesseraIdentities.sub, sub));
163 
164const expectLoginError = (response: Response, code: string): void => {
165 expect(response.status).toBe(302);
166 expect(response.headers.get("location")).toBe(`/app/login?error=${code}`);
167};
168 
169describe("OIDC routes", () => {
170 registerWorkerRuntimeHooks();
171 
172 beforeEach(() => {
173 __test.setProviderForTesting(ISSUER, buildConfig());
174 });
175 
176 afterEach(() => {
177 __test.clear();
178 });
179 
180 it("starts authorization with PKCE and a sanitized transaction cookie", async () => {
181 const response = await SELF.fetch(`${CALLBACK_ORIGIN}/api/public/oidc/start?return_to=https%3A%2F%2Fevil.test`, {
182 redirect: "manual",
183 });
184 
185 expect(response.status).toBe(302);
186 const location = response.headers.get("location");
187 expect(location).toBeTruthy();
188 const authorizationUrl = new URL(location!);
189 expect(authorizationUrl.origin).toBe(ISSUER);
190 expect(authorizationUrl.pathname).toBe("/authorize");
191 expect(authorizationUrl.searchParams.get("code_challenge")).toBeTruthy();
192 expect(authorizationUrl.searchParams.get("code_challenge_method")).toBe("S256");
193 expect(authorizationUrl.searchParams.get("state")).toBeTruthy();
194 expect(authorizationUrl.searchParams.get("nonce")).toBeTruthy();
195 expect(response.headers.get("set-cookie")).toContain(`${OIDC_TX_COOKIE}=`);
196 
197 const payload = await decodeStartTxCookie(response);
198 expect(payload.returnTo).toBe("/app/projects");
199 expect(payload.redirectUri).toBe(REDIRECT_URI);
200 });
201 
202 it("signs in an existing bound tessera identity", async () => {
203 const user = await seedUser({
204 email: "operator@example.com",
205 slug: "oidc-existing",
206 });
207 await insertIdentity("tessera-sub-1", user.id);
208 
209 const response = await callbackWithClaims(buildClaims());
210 
211 expect(response.status).toBe(302);
212 expect(response.headers.get("location")).toBe("/app/projects?oidc=1");
213 const setCookie = response.headers.get("set-cookie") ?? "";
214 expect(setCookie).toContain("__Host-anvil_session=");
215 expect(setCookie).toContain(`${OIDC_TX_COOKIE}=; Max-Age=0`);
216 
217 const identity = await findIdentitiesBySub("tessera-sub-1");
218 expect(identity[0]?.lastSeenAt).toEqual(expect.any(Number));
219 });
220 
221 it("updates email for an existing bound identity when the new verified email is free", async () => {
222 const user = await seedUser({
223 email: "old-operator@example.com",
224 slug: "oidc-email-update",
225 });
226 await insertIdentity("tessera-email-update-sub", user.id);
227 
228 const response = await callbackWithClaims(
229 buildClaims({
230 sub: "tessera-email-update-sub",
231 email: "new-operator@example.com",
232 }),
233 );
234 
235 expect(response.status).toBe(302);
236 const updated = await findUserByEmail("new-operator@example.com");
237 expect(updated?.id).toBe(user.id);
238 expect(await findUserByEmail("old-operator@example.com")).toBeUndefined();
239 });
240 
241 it("rejects an existing bound identity when the new verified email belongs to another user", async () => {
242 const boundUser = await seedUser({
243 email: "bound-operator@example.com",
244 slug: "oidc-email-conflict-bound",
245 });
246 await seedUser({
247 email: "claimed-operator@example.com",
248 slug: "oidc-email-conflict-claimed",
249 });
250 await insertIdentity("tessera-email-conflict-sub", boundUser.id);
251 
252 const response = await callbackWithClaims(
253 buildClaims({
254 sub: "tessera-email-conflict-sub",
255 email: "claimed-operator@example.com",
256 }),
257 );
258 
259 expectLoginError(response, "tessera_email_conflict");
260 expect((await findUserByEmail("bound-operator@example.com"))?.id).toBe(boundUser.id);
261 });
262 
263 it("rejects an existing bound identity when the user is disabled", async () => {
264 const user = await seedUser({
265 email: "disabled-bound@example.com",
266 slug: "disabled-bound",
267 });
268 await disableUser(user);
269 await insertIdentity("disabled-bound-sub", user.id);
270 
271 const response = await callbackWithClaims(
272 buildClaims({
273 sub: "disabled-bound-sub",
274 email: "disabled-bound@example.com",
275 }),
276 );
277 
278 expectLoginError(response, "user_disabled");
279 });
280 
281 it("binds a new tessera sub to an unbound legacy user by verified email", async () => {
282 const user = await seedUser({
283 email: "legacy-operator@example.com",
284 slug: "legacy-operator",
285 });
286 
287 const response = await callbackWithClaims(
288 buildClaims({
289 sub: "legacy-bind-sub",
290 email: "legacy-operator@example.com",
291 }),
292 );
293 
294 expect(response.status).toBe(302);
295 const identities = await findIdentitiesBySub("legacy-bind-sub");
296 expect(identities).toHaveLength(1);
297 expect(identities[0]?.userId).toBe(user.id);
298 expect(identities[0]?.createdAt).toEqual(expect.any(Number));
299 expect(identities[0]?.lastSeenAt).toEqual(expect.any(Number));
300 });
301 
302 it("keeps concurrent legacy binding attempts idempotent for the same sub and user", async () => {
303 const user = await seedUser({
304 email: "legacy-race@example.com",
305 slug: "legacy-race",
306 });
307 const claims = buildClaims({
308 sub: "legacy-race-sub",
309 email: "legacy-race@example.com",
310 });
311 
312 const [first, second] = await Promise.all([callbackWithClaims(claims), callbackWithClaims(claims)]);
313 
314 expect(first.status).toBe(302);
315 expect(second.status).toBe(302);
316 const identities = await findIdentitiesBySub("legacy-race-sub");
317 expect(identities).toHaveLength(1);
318 expect(identities[0]?.userId).toBe(user.id);
319 });
320 
321 it("rejects a new tessera sub when the matching user is already bound to another sub", async () => {
322 const user = await seedUser({
323 email: "already-bound@example.com",
324 slug: "already-bound",
325 });
326 await insertIdentity("existing-bound-sub", user.id);
327 
328 const response = await callbackWithClaims(
329 buildClaims({
330 sub: "new-conflicting-sub",
331 email: "already-bound@example.com",
332 }),
333 );
334 
335 expectLoginError(response, "identity_conflict");
336 expect(await findIdentitiesBySub("new-conflicting-sub")).toHaveLength(0);
337 });
338 
339 it("rejects a new tessera sub when the matching legacy user is disabled", async () => {
340 const user = await seedUser({
341 email: "disabled-legacy@example.com",
342 slug: "disabled-legacy",
343 });
344 await disableUser(user);
345 
346 const response = await callbackWithClaims(
347 buildClaims({
348 sub: "disabled-legacy-sub",
349 email: "disabled-legacy@example.com",
350 }),
351 );
352 
353 expectLoginError(response, "user_disabled");
354 expect(await findIdentitiesBySub("disabled-legacy-sub")).toHaveLength(0);
355 });
356 
357 it("creates a user and identity for a new verified tessera identity", async () => {
358 const response = await callbackWithClaims(
359 buildClaims({
360 sub: "new-user-sub",
361 email: "new-user@example.com",
362 }),
363 );
364 
365 expect(response.status).toBe(302);
366 const user = await findUserByEmail("new-user@example.com");
367 expect(user?.id).toMatch(/^usr_[0-9A-Za-z]{22}$/u);
368 expect(user?.slug).toBe("new-user");
369 const identities = await findIdentitiesBySub("new-user-sub");
370 expect(identities).toHaveLength(1);
371 expect(identities[0]?.userId).toBe(user?.id);
372 expect(identities[0]?.createdAt).toEqual(expect.any(Number));
373 expect(identities[0]?.lastSeenAt).toEqual(expect.any(Number));
374 });
375 
376 it("retries auto-provisioning with the user-id suffix when the preferred slug is taken", async () => {
377 await seedUser({
378 email: "slug-owner@example.com",
379 slug: "first-last",
380 });
381 
382 const response = await callbackWithClaims(
383 buildClaims({
384 sub: "slug-fallback-sub",
385 email: "slug-fallback@example.com",
386 preferred_username: "First Last!",
387 }),
388 );
389 
390 expect(response.status).toBe(302);
391 const user = await findUserByEmail("slug-fallback@example.com");
392 expect(user?.slug).toMatch(/^first-last-[0-9A-Za-z]{6}$/u);
393 expect(user?.slug.endsWith(user.id.slice(-6))).toBe(true);
394 });
395 
396 it("falls back to usr suffix when slug sources clean to empty", async () => {
397 const response = await callbackWithClaims(
398 buildClaims({
399 sub: "empty-slug-sub",
400 email: "!!!@example.com",
401 name: undefined,
402 }),
403 );
404 
405 expect(response.status).toBe(302);
406 const user = await findUserByEmail("!!!@example.com");
407 expect(user?.slug).toMatch(/^usr-[0-9A-Za-z]{6}$/u);
408 expect(user?.slug).toBe(`usr-${user?.id.slice(-6)}`);
409 });
410 
411 it("keeps concurrent auto-provision attempts idempotent for the same sub", async () => {
412 const claims = buildClaims({
413 sub: "auto-race-sub",
414 email: "auto-race@example.com",
415 });
416 
417 const [first, second] = await Promise.all([callbackWithClaims(claims), callbackWithClaims(claims)]);
418 
419 expect(first.status).toBe(302);
420 expect(second.status).toBe(302);
421 const identities = await findIdentitiesBySub("auto-race-sub");
422 expect(identities).toHaveLength(1);
423 const users = await getDb().select().from(d1Schema.users).where(eq(d1Schema.users.email, "auto-race@example.com"));
424 expect(users).toHaveLength(1);
425 expect(identities[0]?.userId).toBe(users[0]?.id);
426 });
427 
428 it("redirects failed claim validation without writing users or identities", async () => {
429 const rejectedSub = "tessera-rejected-sub";
430 const rejectedEmail = "rejected-operator@example.com";
431 const response = await callbackWithClaims(
432 buildClaims({
433 sub: rejectedSub,
434 email: rejectedEmail,
435 email_verified: false,
436 }),
437 );
438 
439 expectLoginError(response, "oidc_unverified_email");
440 expect(await findUserByEmail(rejectedEmail)).toBeUndefined();
441 expect(await findIdentitiesBySub(rejectedSub)).toHaveLength(0);
442 });
443 
444 it("rejects missing, tampered, wrong-secret, and state-mismatched transaction cookies without DB writes", async () => {
445 const rejectedSub = "tx-rejected-sub";
446 const rejectedEmail = "tx-rejected@example.com";
447 const claims = buildClaims({ sub: rejectedSub, email: rejectedEmail });
448 stubClaims(claims);
449 
450 const missing = await SELF.fetch(`${CALLBACK_ORIGIN}/api/public/oidc/callback?code=code&state=${STATE}`, {
451 redirect: "manual",
452 });
453 expectLoginError(missing, "oidc_session_expired");
454 
455 const tampered = await callbackWithClaims(claims, {
456 txCookie: `${await buildTxCookieHeader()}x`,
457 });
458 expectLoginError(tampered, "oidc_session_expired");
459 
460 const wrongSecret = await callbackWithClaims(claims, {
461 txCookie: await buildTxCookieHeader({}, await deriveTxCookieSecret("wrong-secret")),
462 });
463 expectLoginError(wrongSecret, "oidc_session_expired");
464 
465 const wrongState = await callbackWithClaims(claims, { state: "wrong-state" });
466 expectLoginError(wrongState, "oidc_session_expired");
467 
468 expect(await findUserByEmail(rejectedEmail)).toBeUndefined();
469 expect(await findIdentitiesBySub(rejectedSub)).toHaveLength(0);
470 });
471 
472 it("redirects provider failures without DB writes", async () => {
473 stubTokenExchangeFailure();
474 
475 const response = await SELF.fetch(`${CALLBACK_ORIGIN}/api/public/oidc/callback?code=code&state=${STATE}`, {
476 redirect: "manual",
477 headers: {
478 cookie: await buildTxCookieHeader(),
479 },
480 });
481 
482 expectLoginError(response, "oidc_provider_error");
483 expect(await findUserByEmail("provider-failure@example.com")).toBeUndefined();
484 });
485});