File
Blob: test/auth-tessera-callback.worker.test.ts
| 1 | import { applyD1Migrations } from "cloudflare:test"; |
| 2 | import { env, exports as workerExports } from "cloudflare:workers"; |
| 3 | import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; |
| 4 | |
| 5 | import { createDb } from "@/worker/db/d1/client"; |
| 6 | import { |
| 7 | findNamespaceBySlug, |
| 8 | findUserByTesseraSub, |
| 9 | insertMembershipIfMissing, |
| 10 | listNamespacesForUser, |
| 11 | } from "@/worker/db/d1/dal"; |
| 12 | import { __test as oidcTest } from "@/worker/auth/oidc"; |
| 13 | import { OIDC_TX_COOKIE_HEADER_NAME, SESSION_COOKIE_HEADER_NAME } from "@/worker/auth/cookies"; |
| 14 | |
| 15 | import { fakeProvider } from "./util/oidcFake"; |
| 16 | import { readAppD1Migrations } from "./util/d1Migrations"; |
| 17 | import { oidcTransactionCookieHeader } from "./util/authCookies"; |
| 18 | |
| 19 | beforeAll(async () => { |
| 20 | await applyD1Migrations(env.DB, readAppD1Migrations()); |
| 21 | }); |
| 22 | |
| 23 | const REDIRECT_URI = "https://example.com/auth/callback"; |
| 24 | |
| 25 | function preloadProvider() { |
| 26 | oidcTest.setProviderForTesting( |
| 27 | { |
| 28 | issuer: env.TESSERA_OIDC_ISSUER, |
| 29 | clientId: env.TESSERA_OIDC_CLIENT_ID, |
| 30 | clientSecret: env.TESSERA_OIDC_CLIENT_SECRET, |
| 31 | }, |
| 32 | fakeProvider({ |
| 33 | authorizationEndpoint: "https://auth.example.com/oauth2/authorize", |
| 34 | tokenEndpoint: "https://auth.example.com/oauth2/token", |
| 35 | jwksUri: "https://auth.example.com/.well-known/jwks.json", |
| 36 | }) |
| 37 | ); |
| 38 | } |
| 39 | |
| 40 | async function buildTransactionCookie(state: string, nonce: string, codeVerifier: string) { |
| 41 | return await oidcTransactionCookieHeader(env.TESSERA_OIDC_CLIENT_SECRET, { |
| 42 | state, |
| 43 | nonce, |
| 44 | codeVerifier, |
| 45 | redirectUri: REDIRECT_URI, |
| 46 | createdAt: Date.now(), |
| 47 | }); |
| 48 | } |
| 49 | |
| 50 | type FakeClaims = { sub: string; preferred_username?: string }; |
| 51 | |
| 52 | function stubGrantWithClaims(claims: FakeClaims, options?: { rejectAs?: "client" | "other" }) { |
| 53 | oidcTest.setAuthorizationCodeGrantImpl(async () => { |
| 54 | if (options?.rejectAs === "client") { |
| 55 | throw new (await import("openid-client")).ClientError("invalid_grant", { cause: claims }); |
| 56 | } |
| 57 | if (options?.rejectAs === "other") { |
| 58 | throw new Error("network down"); |
| 59 | } |
| 60 | const tokens = { |
| 61 | access_token: "fake-access", |
| 62 | token_type: "Bearer", |
| 63 | claims: () => ({ sub: claims.sub, preferred_username: claims.preferred_username }), |
| 64 | } as unknown as Awaited<ReturnType<typeof import("openid-client").authorizationCodeGrant>>; |
| 65 | return tokens; |
| 66 | }); |
| 67 | } |
| 68 | |
| 69 | async function callCallback(args: { state: string; cookie?: string; code?: string }) { |
| 70 | const url = new URL("https://example.com/auth/callback"); |
| 71 | url.searchParams.set("code", args.code ?? "fake-code"); |
| 72 | url.searchParams.set("state", args.state); |
| 73 | return await workerExports.default.fetch(url.toString(), { |
| 74 | redirect: "manual", |
| 75 | headers: args.cookie ? { Cookie: args.cookie } : undefined, |
| 76 | }); |
| 77 | } |
| 78 | |
| 79 | beforeEach(() => { |
| 80 | preloadProvider(); |
| 81 | }); |
| 82 | |
| 83 | afterEach(() => { |
| 84 | oidcTest.clearProviderCache(); |
| 85 | oidcTest.setAuthorizationCodeGrantImpl(null); |
| 86 | }); |
| 87 | |
| 88 | describe("/auth/callback", () => { |
| 89 | it("creates user, namespace, membership, and session for a fresh sub with valid pref-name", async () => { |
| 90 | const sub = "sub-fresh-1"; |
| 91 | const state = "state-fresh-1"; |
| 92 | stubGrantWithClaims({ sub, preferred_username: "fresh-rachel" }); |
| 93 | const cookie = await buildTransactionCookie(state, "nonce-1", "verifier-1"); |
| 94 | const res = await callCallback({ state, cookie }); |
| 95 | expect(res.status).toBe(302); |
| 96 | expect(res.headers.get("location")).toBe("/auth/account"); |
| 97 | const cookies = res.headers.get("set-cookie") ?? ""; |
| 98 | expect(cookies).toContain(`${SESSION_COOKIE_HEADER_NAME}=goc_sess_`); |
| 99 | const db = createDb(env.DB); |
| 100 | const user = await findUserByTesseraSub(db, sub); |
| 101 | expect(user).toBeDefined(); |
| 102 | const namespace = await findNamespaceBySlug(db, "fresh-rachel"); |
| 103 | expect(namespace).toBeDefined(); |
| 104 | expect(namespace!.createdBy).toBe(user!.id); |
| 105 | const namespaces = await listNamespacesForUser(db, user!.id); |
| 106 | expect(namespaces.map((row) => row.slug)).toEqual(["fresh-rachel"]); |
| 107 | }); |
| 108 | |
| 109 | it("creates only user+session when pref-name is taken by another user", async () => { |
| 110 | const occupant = "user-occupant"; |
| 111 | const db = createDb(env.DB); |
| 112 | // Seed an existing namespace owned by an unrelated user. |
| 113 | await db.batch([ |
| 114 | db.insert((await import("@/worker/db/d1/schema")).users).values({ |
| 115 | id: occupant, |
| 116 | tesseraSub: "sub-occupant", |
| 117 | createdAt: Date.now(), |
| 118 | }), |
| 119 | db.insert((await import("@/worker/db/d1/schema")).namespaces).values({ |
| 120 | id: "ns-taken", |
| 121 | slug: "taken", |
| 122 | createdBy: occupant, |
| 123 | createdAt: Date.now(), |
| 124 | }), |
| 125 | ]); |
| 126 | const sub = "sub-loser"; |
| 127 | const state = "state-taken"; |
| 128 | stubGrantWithClaims({ sub, preferred_username: "taken" }); |
| 129 | const cookie = await buildTransactionCookie(state, "nonce-2", "verifier-2"); |
| 130 | const res = await callCallback({ state, cookie }); |
| 131 | expect(res.status).toBe(302); |
| 132 | expect(res.headers.get("location")).toBe("/auth/account"); |
| 133 | const dbAfter = createDb(env.DB); |
| 134 | const user = await findUserByTesseraSub(dbAfter, sub); |
| 135 | expect(user).toBeDefined(); |
| 136 | expect(user!.id).not.toBe(occupant); |
| 137 | const namespace = await findNamespaceBySlug(dbAfter, "taken"); |
| 138 | expect(namespace?.createdBy).toBe(occupant); |
| 139 | expect((await listNamespacesForUser(dbAfter, user!.id)).length).toBe(0); |
| 140 | }); |
| 141 | |
| 142 | it("creates only user+session when pref-name is invalid", async () => { |
| 143 | const sub = "sub-invalid-1"; |
| 144 | const state = "state-invalid"; |
| 145 | stubGrantWithClaims({ sub, preferred_username: "Invalid_Slug!" }); |
| 146 | const cookie = await buildTransactionCookie(state, "nonce-3", "verifier-3"); |
| 147 | const res = await callCallback({ state, cookie }); |
| 148 | expect(res.status).toBe(302); |
| 149 | expect(res.headers.get("location")).toBe("/auth/account"); |
| 150 | const db = createDb(env.DB); |
| 151 | const user = await findUserByTesseraSub(db, sub); |
| 152 | expect(user).toBeDefined(); |
| 153 | expect((await listNamespacesForUser(db, user!.id)).length).toBe(0); |
| 154 | }); |
| 155 | |
| 156 | it("creates only session for a returning user even if pref-name is now valid+free", async () => { |
| 157 | const sub = "sub-returning"; |
| 158 | const state1 = "state-r1"; |
| 159 | stubGrantWithClaims({ sub }); |
| 160 | const cookie1 = await buildTransactionCookie(state1, "n1", "v1"); |
| 161 | expect((await callCallback({ state: state1, cookie: cookie1 })).status).toBe(302); |
| 162 | const db = createDb(env.DB); |
| 163 | const user = await findUserByTesseraSub(db, sub); |
| 164 | expect(user).toBeDefined(); |
| 165 | // Second sign-in tries to claim a free name; ensure it does NOT create |
| 166 | // a namespace because the user already exists. |
| 167 | stubGrantWithClaims({ sub, preferred_username: "newslug" }); |
| 168 | const state2 = "state-r2"; |
| 169 | const cookie2 = await buildTransactionCookie(state2, "n2", "v2"); |
| 170 | const res = await callCallback({ state: state2, cookie: cookie2 }); |
| 171 | expect(res.status).toBe(302); |
| 172 | const namespace = await findNamespaceBySlug(db, "newslug"); |
| 173 | expect(namespace).toBeUndefined(); |
| 174 | expect((await listNamespacesForUser(db, user!.id)).length).toBe(0); |
| 175 | }); |
| 176 | |
| 177 | it("clears the OIDC transaction cookie when runtime config is missing", async () => { |
| 178 | const cookie = await buildTransactionCookie("state-cfg", "n", "v"); |
| 179 | // Force loadOidcConfig() into the "missing_client_secret" branch by |
| 180 | // blanking the binding for the duration of this test. Restoring at the |
| 181 | // end so other tests still have a complete config. |
| 182 | const original = env.TESSERA_OIDC_CLIENT_SECRET; |
| 183 | env.TESSERA_OIDC_CLIENT_SECRET = ""; |
| 184 | try { |
| 185 | const url = new URL("https://example.com/auth/callback"); |
| 186 | url.searchParams.set("code", "anything"); |
| 187 | url.searchParams.set("state", "state-cfg"); |
| 188 | const res = await workerExports.default.fetch(url.toString(), { |
| 189 | redirect: "manual", |
| 190 | headers: { Cookie: cookie }, |
| 191 | }); |
| 192 | expect(res.status).toBe(302); |
| 193 | expect(res.headers.get("location")).toBe("/auth?error=oidc_unavailable"); |
| 194 | const setCookie = res.headers.get("set-cookie") ?? ""; |
| 195 | expect(setCookie).toContain(`${OIDC_TX_COOKIE_HEADER_NAME}=`); |
| 196 | expect(setCookie.toLowerCase()).toContain("max-age=0"); |
| 197 | } finally { |
| 198 | env.TESSERA_OIDC_CLIENT_SECRET = original; |
| 199 | } |
| 200 | }); |
| 201 | |
| 202 | it("fails before D1 writes when SESSION_SECRET is missing", async () => { |
| 203 | const sub = "sub-missing-session-secret"; |
| 204 | const state = "state-missing-session-secret"; |
| 205 | stubGrantWithClaims({ sub, preferred_username: "missing-session-secret" }); |
| 206 | const cookie = await buildTransactionCookie(state, "n", "v"); |
| 207 | const original = env.SESSION_SECRET; |
| 208 | env.SESSION_SECRET = ""; |
| 209 | try { |
| 210 | const res = await callCallback({ state, cookie }); |
| 211 | expect(res.status).toBe(302); |
| 212 | expect(res.headers.get("location")).toBe("/auth?error=session_create_failed"); |
| 213 | const setCookie = res.headers.get("set-cookie") ?? ""; |
| 214 | expect(setCookie).toContain(`${OIDC_TX_COOKIE_HEADER_NAME}=`); |
| 215 | expect(setCookie.toLowerCase()).toContain("max-age=0"); |
| 216 | const db = createDb(env.DB); |
| 217 | expect(await findUserByTesseraSub(db, sub)).toBeUndefined(); |
| 218 | } finally { |
| 219 | env.SESSION_SECRET = original; |
| 220 | } |
| 221 | }); |
| 222 | |
| 223 | it("redirects to /auth?error=missing_state when the OIDC cookie is absent", async () => { |
| 224 | const res = await callCallback({ state: "anything" }); |
| 225 | expect(res.status).toBe(302); |
| 226 | expect(res.headers.get("location")).toBe("/auth?error=missing_state"); |
| 227 | }); |
| 228 | |
| 229 | it("redirects to /auth?error=invalid_state when the OIDC cookie signature is invalid", async () => { |
| 230 | const res = await callCallback({ |
| 231 | state: "anything", |
| 232 | cookie: `${OIDC_TX_COOKIE_HEADER_NAME}=not-signed`, |
| 233 | }); |
| 234 | expect(res.status).toBe(302); |
| 235 | expect(res.headers.get("location")).toBe("/auth?error=invalid_state"); |
| 236 | const setCookie = res.headers.get("set-cookie") ?? ""; |
| 237 | expect(setCookie).toContain(`${OIDC_TX_COOKIE_HEADER_NAME}=`); |
| 238 | expect(setCookie.toLowerCase()).toContain("max-age=0"); |
| 239 | }); |
| 240 | |
| 241 | it("redirects to /auth?error=invalid_state when state does not match the cookie", async () => { |
| 242 | const cookie = await buildTransactionCookie("state-A", "n", "v"); |
| 243 | const res = await callCallback({ state: "state-B", cookie }); |
| 244 | expect(res.status).toBe(302); |
| 245 | expect(res.headers.get("location")).toBe("/auth?error=invalid_state"); |
| 246 | }); |
| 247 | |
| 248 | it("redirects to /auth?error=invalid_id_token when the grant raises ClientError", async () => { |
| 249 | const sub = "sub-grant-err"; |
| 250 | stubGrantWithClaims({ sub }, { rejectAs: "client" }); |
| 251 | const cookie = await buildTransactionCookie("state-grant", "n", "v"); |
| 252 | const res = await callCallback({ state: "state-grant", cookie }); |
| 253 | expect(res.status).toBe(302); |
| 254 | expect(res.headers.get("location")).toBe("/auth?error=invalid_id_token"); |
| 255 | }); |
| 256 | |
| 257 | it("seeds membership for the existing namespace owner on returning sign-in", async () => { |
| 258 | const sub = "sub-existing-member"; |
| 259 | const userId = "user-existing-member"; |
| 260 | const namespaceId = "ns-existing-member"; |
| 261 | const db = createDb(env.DB); |
| 262 | const schema = await import("@/worker/db/d1/schema"); |
| 263 | await db.batch([ |
| 264 | db.insert(schema.users).values({ id: userId, tesseraSub: sub, createdAt: Date.now() }), |
| 265 | db.insert(schema.namespaces).values({ |
| 266 | id: namespaceId, |
| 267 | slug: "preexisting", |
| 268 | createdBy: userId, |
| 269 | createdAt: Date.now(), |
| 270 | }), |
| 271 | ]); |
| 272 | await insertMembershipIfMissing(db, { |
| 273 | namespaceId, |
| 274 | userId, |
| 275 | createdAt: Date.now(), |
| 276 | }); |
| 277 | stubGrantWithClaims({ sub, preferred_username: "preexisting" }); |
| 278 | const state = "state-pre"; |
| 279 | const cookie = await buildTransactionCookie(state, "n", "v"); |
| 280 | const res = await callCallback({ state, cookie }); |
| 281 | expect(res.status).toBe(302); |
| 282 | expect((await listNamespacesForUser(db, userId)).map((row) => row.slug)).toEqual([ |
| 283 | "preexisting", |
| 284 | ]); |
| 285 | }); |
| 286 | |
| 287 | it("creates a fresh namespace claim without creating route-cache entries", async () => { |
| 288 | const slug = "callback-direct"; |
| 289 | const repo = "not-created-by-callback"; |
| 290 | const routeKey = `repo-route:v1:${slug}/${repo}`; |
| 291 | await env.ROUTES.delete(routeKey); |
| 292 | stubGrantWithClaims({ sub: "sub-callback-direct", preferred_username: slug }); |
| 293 | const cookie = await buildTransactionCookie("state-callback-direct", "n", "v"); |
| 294 | const res = await callCallback({ state: "state-callback-direct", cookie }); |
| 295 | expect(res.status).toBe(302); |
| 296 | await new Promise((resolve) => setTimeout(resolve, 250)); |
| 297 | expect(await env.ROUTES.get(routeKey)).toBeNull(); |
| 298 | }); |
| 299 | }); |