import { beforeAll, describe, expect, it } from "vitest"; import { authorizeWithPkce, createOAuthClientAsAdmin, DEFAULT_PASSWORD, DEFAULT_REDIRECT_URI, ISSUER, SELF, signInForCookie, signUpAdmin, testHeaders, type TestCredential, } from "./helpers"; const credential = { email: "sign-in-tester@example.com", password: DEFAULT_PASSWORD, name: "Sign-In Tester", } satisfies TestCredential; describe("POST /api/sign-in", () => { let adminCookie: string; let clientId: string; beforeAll(async () => { await signUpAdmin(credential); adminCookie = await signInForCookie(credential.email, credential.password, "10.0.0.10"); const created = await createOAuthClientAsAdmin(adminCookie, { name: "sign-in flow client", redirectUris: [DEFAULT_REDIRECT_URI], skipConsent: true, }); clientId = created.client_id; }); const authorizeToSignIn = async (state: string): Promise => { const result = await authorizeWithPkce({ clientId, state }); expect(result.status).toBe(302); expect(result.location).toBeTruthy(); const signInUrl = new URL(result.location!, ISSUER); expect(signInUrl.pathname).toBe("/sign-in"); expect(signInUrl.searchParams.get("client_id")).toBe(clientId); expect(signInUrl.searchParams.get("sig")).toBeTruthy(); return signInUrl; }; it("succeeds with a valid Turnstile token", async () => { const res = await SELF.fetch(`${ISSUER}/api/sign-in`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "10.0.0.1" }), body: JSON.stringify({ email: credential.email, password: credential.password, turnstileToken: "any-token", }), }); expect(res.status).toBe(200); expect(res.headers.get("set-cookie")).toMatch(/better-auth\./); }); it("rejects with 400 when turnstileToken is missing", async () => { const res = await SELF.fetch(`${ISSUER}/api/sign-in`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "10.0.0.2" }), body: JSON.stringify({ email: credential.email, password: credential.password, }), }); expect(res.status).toBe(400); }); it("does not expose Better Auth's raw email sign-in endpoint", async () => { const res = await SELF.fetch(`${ISSUER}/api/auth/sign-in/email`, { method: "POST", headers: { "content-type": "application/json", "CF-Connecting-IP": "10.0.0.3" }, body: JSON.stringify({ email: credential.email, password: credential.password, }), }); expect(res.status).toBe(404); expect(res.headers.get("set-cookie")).toBeNull(); }); it("returns 429 with Retry-After once the per-IP threshold is exceeded (spray bypass guard)", async () => { // Same IP, rotating emails — the per-IP bucket must trip even though // each email has a fresh per-email allowance. Missing turnstileToken // makes each request fast-fail at body validation (400) before any // Better Auth or Turnstile work runs; the IP rate-limit decision // happens before the body check, so the bucket still decrements. const ip = "198.51.100.1"; let lastStatus = 0; let last: Response | null = null; for (let i = 0; i < 12; i += 1) { last = await SELF.fetch(`${ISSUER}/api/sign-in`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": ip }), body: JSON.stringify({ email: `spray-${i}-${crypto.randomUUID()}@example.com`, password: "irrelevant", }), }); lastStatus = last.status; if (lastStatus === 429) break; } expect(lastStatus).toBe(429); expect(last).not.toBeNull(); expect(last!.headers.get("retry-after")).toBe("60"); // A request from a different IP must still go through (only that one // IP is throttled, not all of /api/sign-in). const freshIp = await SELF.fetch(`${ISSUER}/api/sign-in`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "198.51.100.99" }), body: JSON.stringify({ email: `fresh-${crypto.randomUUID()}@example.com`, password: "irrelevant" }), }); expect(freshIp.status).toBe(400); }, 30_000); // The per-email bucket is exercised at the unit level by // rate-limit.test.ts ("allows the first 10 calls and blocks the 11th // with Retry-After 60s"). The sign-in handler's call site is // `enforceRateLimit(c.env, "sign-in:email", email)` with the same // return-early pattern as the IP bucket above; the IP bucket test // here covers the integrated request path against the primary // attack vector (single attacker spraying many emails). it("exposes public client metadata through Better Auth's session-gated endpoint", async () => { const anonRes = await SELF.fetch( `${ISSUER}/api/auth/oauth2/public-client?${new URLSearchParams({ client_id: clientId })}`, ); expect(anonRes.status).toBe(401); const res = await SELF.fetch( `${ISSUER}/api/auth/oauth2/public-client?${new URLSearchParams({ client_id: clientId })}`, { headers: { cookie: adminCookie }, }, ); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body.client_id).toBe(clientId); expect(body.client_name).toBe("sign-in flow client"); expect(body.client_secret).toBeUndefined(); }); it("exposes public client metadata before login only with a signed OAuth query", async () => { const signInUrl = await authorizeToSignIn("prelogin-state"); const oauthQuery = signInUrl.search.slice(1); const res = await SELF.fetch(`${ISSUER}/api/auth/oauth2/public-client-prelogin`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ client_id: clientId, oauth_query: oauthQuery }), }); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body.client_id).toBe(clientId); expect(body.client_name).toBe("sign-in flow client"); expect(body.client_secret).toBeUndefined(); const tamperedQuery = oauthQuery.replace("state=prelogin-state", "state=tampered-state"); const tamperedRes = await SELF.fetch(`${ISSUER}/api/auth/oauth2/public-client-prelogin`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ client_id: clientId, oauth_query: tamperedQuery }), }); expect(tamperedRes.status).toBe(400); }); it("continues an OAuth authorize flow after the custom email sign-in wrapper", async () => { const signInUrl = await authorizeToSignIn("wrapper-state"); const res = await SELF.fetch(`${ISSUER}/api/sign-in`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "10.0.0.4" }), body: JSON.stringify({ email: credential.email, password: credential.password, turnstileToken: "loopback", oauth_query: signInUrl.search.slice(1), }), }); expect(res.status).toBe(200); expect(res.headers.get("set-cookie")).toMatch(/better-auth\./); const body = (await res.json()) as { redirect?: boolean; url?: string }; expect(body.redirect).toBe(true); expect(body.url).toBeTruthy(); const redirectUrl = new URL(body.url!); expect(`${redirectUrl.origin}${redirectUrl.pathname}`).toBe(DEFAULT_REDIRECT_URI); expect(redirectUrl.searchParams.get("code")).toBeTruthy(); expect(redirectUrl.searchParams.get("state")).toBe("wrapper-state"); expect(redirectUrl.searchParams.get("iss")).toBe(ISSUER); }, 30_000); });