File
Blob: tests/worker/routes/oidc.test.ts
| 1 | import { SELF, env } from "cloudflare:test"; |
| 2 | import { eq } from "drizzle-orm"; |
| 3 | import { generateSignedCookie } from "hono/cookie"; |
| 4 | import { parseSigned } from "hono/utils/cookie"; |
| 5 | import * as oidc from "openid-client"; |
| 6 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 7 | |
| 8 | import { |
| 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"; |
| 19 | import * as d1Schema from "@/worker/db/d1/schema"; |
| 20 | |
| 21 | import { getDb, seedUser, type SeededUser } from "../../helpers/runtime"; |
| 22 | import { registerWorkerRuntimeHooks } from "../../helpers/worker-hooks"; |
| 23 | |
| 24 | const ISSUER = "https://tessera.test"; |
| 25 | const CLIENT_ID = "anvil-test"; |
| 26 | const CLIENT_SECRET = "anvil-test-secret"; |
| 27 | const CALLBACK_ORIGIN = "https://example.com"; |
| 28 | const REDIRECT_URI = `${CALLBACK_ORIGIN}/api/public/oidc/callback`; |
| 29 | const STATE = "test-state"; |
| 30 | const NONCE = "test-nonce"; |
| 31 | |
| 32 | const 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 | |
| 45 | const 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 | |
| 58 | const stubClaims = (claims: oidc.IDToken | undefined): void => { |
| 59 | __test.setAuthorizationCodeGrantImpl(async () => ({ |
| 60 | claims: () => claims, |
| 61 | })); |
| 62 | }; |
| 63 | |
| 64 | const stubTokenExchangeFailure = (): void => { |
| 65 | __test.setAuthorizationCodeGrantImpl(async () => { |
| 66 | throw new Error("provider failed"); |
| 67 | }); |
| 68 | }; |
| 69 | |
| 70 | const 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 | |
| 80 | const 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 | |
| 101 | const 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 | |
| 114 | const 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 | |
| 126 | const 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 | |
| 143 | const 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 | |
| 152 | const 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 | |
| 156 | const 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 | |
| 161 | const findIdentitiesBySub = async (sub: string) => |
| 162 | await getDb().select().from(d1Schema.tesseraIdentities).where(eq(d1Schema.tesseraIdentities.sub, sub)); |
| 163 | |
| 164 | const 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 | |
| 169 | describe("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 | }); |