Skip to content
File

Blob: tests/worker/sign-in.test.ts

typescript200 lines
1import { beforeAll, describe, expect, it } from "vitest";
2 
3import {
4 authorizeWithPkce,
5 createOAuthClientAsAdmin,
6 DEFAULT_PASSWORD,
7 DEFAULT_REDIRECT_URI,
8 ISSUER,
9 SELF,
10 signInForCookie,
11 signUpAdmin,
12 testHeaders,
13 type TestCredential,
14} from "./helpers";
15 
16const credential = {
17 email: "sign-in-tester@example.com",
18 password: DEFAULT_PASSWORD,
19 name: "Sign-In Tester",
20} satisfies TestCredential;
21 
22describe("POST /api/sign-in", () => {
23 let adminCookie: string;
24 let clientId: string;
25 
26 beforeAll(async () => {
27 await signUpAdmin(credential);
28 
29 adminCookie = await signInForCookie(credential.email, credential.password, "10.0.0.10");
30 const created = await createOAuthClientAsAdmin(adminCookie, {
31 name: "sign-in flow client",
32 redirectUris: [DEFAULT_REDIRECT_URI],
33 skipConsent: true,
34 });
35 clientId = created.client_id;
36 });
37 
38 const authorizeToSignIn = async (state: string): Promise<URL> => {
39 const result = await authorizeWithPkce({ clientId, state });
40 expect(result.status).toBe(302);
41 expect(result.location).toBeTruthy();
42 const signInUrl = new URL(result.location!, ISSUER);
43 expect(signInUrl.pathname).toBe("/sign-in");
44 expect(signInUrl.searchParams.get("client_id")).toBe(clientId);
45 expect(signInUrl.searchParams.get("sig")).toBeTruthy();
46 return signInUrl;
47 };
48 
49 it("succeeds with a valid Turnstile token", async () => {
50 const res = await SELF.fetch(`${ISSUER}/api/sign-in`, {
51 method: "POST",
52 headers: testHeaders({ "CF-Connecting-IP": "10.0.0.1" }),
53 body: JSON.stringify({
54 email: credential.email,
55 password: credential.password,
56 turnstileToken: "any-token",
57 }),
58 });
59 expect(res.status).toBe(200);
60 expect(res.headers.get("set-cookie")).toMatch(/better-auth\./);
61 });
62 
63 it("rejects with 400 when turnstileToken is missing", async () => {
64 const res = await SELF.fetch(`${ISSUER}/api/sign-in`, {
65 method: "POST",
66 headers: testHeaders({ "CF-Connecting-IP": "10.0.0.2" }),
67 body: JSON.stringify({
68 email: credential.email,
69 password: credential.password,
70 }),
71 });
72 expect(res.status).toBe(400);
73 });
74 
75 it("does not expose Better Auth's raw email sign-in endpoint", async () => {
76 const res = await SELF.fetch(`${ISSUER}/api/auth/sign-in/email`, {
77 method: "POST",
78 headers: { "content-type": "application/json", "CF-Connecting-IP": "10.0.0.3" },
79 body: JSON.stringify({
80 email: credential.email,
81 password: credential.password,
82 }),
83 });
84 
85 expect(res.status).toBe(404);
86 expect(res.headers.get("set-cookie")).toBeNull();
87 });
88 
89 it("returns 429 with Retry-After once the per-IP threshold is exceeded (spray bypass guard)", async () => {
90 // Same IP, rotating emails — the per-IP bucket must trip even though
91 // each email has a fresh per-email allowance. Missing turnstileToken
92 // makes each request fast-fail at body validation (400) before any
93 // Better Auth or Turnstile work runs; the IP rate-limit decision
94 // happens before the body check, so the bucket still decrements.
95 const ip = "198.51.100.1";
96 let lastStatus = 0;
97 let last: Response | null = null;
98 for (let i = 0; i < 12; i += 1) {
99 last = await SELF.fetch(`${ISSUER}/api/sign-in`, {
100 method: "POST",
101 headers: testHeaders({ "CF-Connecting-IP": ip }),
102 body: JSON.stringify({
103 email: `spray-${i}-${crypto.randomUUID()}@example.com`,
104 password: "irrelevant",
105 }),
106 });
107 lastStatus = last.status;
108 if (lastStatus === 429) break;
109 }
110 expect(lastStatus).toBe(429);
111 expect(last).not.toBeNull();
112 expect(last!.headers.get("retry-after")).toBe("60");
113 
114 // A request from a different IP must still go through (only that one
115 // IP is throttled, not all of /api/sign-in).
116 const freshIp = await SELF.fetch(`${ISSUER}/api/sign-in`, {
117 method: "POST",
118 headers: testHeaders({ "CF-Connecting-IP": "198.51.100.99" }),
119 body: JSON.stringify({ email: `fresh-${crypto.randomUUID()}@example.com`, password: "irrelevant" }),
120 });
121 expect(freshIp.status).toBe(400);
122 }, 30_000);
123 
124 // The per-email bucket is exercised at the unit level by
125 // rate-limit.test.ts ("allows the first 10 calls and blocks the 11th
126 // with Retry-After 60s"). The sign-in handler's call site is
127 // `enforceRateLimit(c.env, "sign-in:email", email)` with the same
128 // return-early pattern as the IP bucket above; the IP bucket test
129 // here covers the integrated request path against the primary
130 // attack vector (single attacker spraying many emails).
131 
132 it("exposes public client metadata through Better Auth's session-gated endpoint", async () => {
133 const anonRes = await SELF.fetch(
134 `${ISSUER}/api/auth/oauth2/public-client?${new URLSearchParams({ client_id: clientId })}`,
135 );
136 expect(anonRes.status).toBe(401);
137 
138 const res = await SELF.fetch(
139 `${ISSUER}/api/auth/oauth2/public-client?${new URLSearchParams({ client_id: clientId })}`,
140 {
141 headers: { cookie: adminCookie },
142 },
143 );
144 expect(res.status).toBe(200);
145 const body = (await res.json()) as Record<string, unknown>;
146 expect(body.client_id).toBe(clientId);
147 expect(body.client_name).toBe("sign-in flow client");
148 expect(body.client_secret).toBeUndefined();
149 });
150 
151 it("exposes public client metadata before login only with a signed OAuth query", async () => {
152 const signInUrl = await authorizeToSignIn("prelogin-state");
153 const oauthQuery = signInUrl.search.slice(1);
154 
155 const res = await SELF.fetch(`${ISSUER}/api/auth/oauth2/public-client-prelogin`, {
156 method: "POST",
157 headers: { "content-type": "application/json" },
158 body: JSON.stringify({ client_id: clientId, oauth_query: oauthQuery }),
159 });
160 expect(res.status).toBe(200);
161 const body = (await res.json()) as Record<string, unknown>;
162 expect(body.client_id).toBe(clientId);
163 expect(body.client_name).toBe("sign-in flow client");
164 expect(body.client_secret).toBeUndefined();
165 
166 const tamperedQuery = oauthQuery.replace("state=prelogin-state", "state=tampered-state");
167 const tamperedRes = await SELF.fetch(`${ISSUER}/api/auth/oauth2/public-client-prelogin`, {
168 method: "POST",
169 headers: { "content-type": "application/json" },
170 body: JSON.stringify({ client_id: clientId, oauth_query: tamperedQuery }),
171 });
172 expect(tamperedRes.status).toBe(400);
173 });
174 
175 it("continues an OAuth authorize flow after the custom email sign-in wrapper", async () => {
176 const signInUrl = await authorizeToSignIn("wrapper-state");
177 const res = await SELF.fetch(`${ISSUER}/api/sign-in`, {
178 method: "POST",
179 headers: testHeaders({ "CF-Connecting-IP": "10.0.0.4" }),
180 body: JSON.stringify({
181 email: credential.email,
182 password: credential.password,
183 turnstileToken: "loopback",
184 oauth_query: signInUrl.search.slice(1),
185 }),
186 });
187 
188 expect(res.status).toBe(200);
189 expect(res.headers.get("set-cookie")).toMatch(/better-auth\./);
190 const body = (await res.json()) as { redirect?: boolean; url?: string };
191 expect(body.redirect).toBe(true);
192 expect(body.url).toBeTruthy();
193 const redirectUrl = new URL(body.url!);
194 expect(`${redirectUrl.origin}${redirectUrl.pathname}`).toBe(DEFAULT_REDIRECT_URI);
195 expect(redirectUrl.searchParams.get("code")).toBeTruthy();
196 expect(redirectUrl.searchParams.get("state")).toBe("wrapper-state");
197 expect(redirectUrl.searchParams.get("iss")).toBe(ISSUER);
198 }, 30_000);
199});