Skip to content
File

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

typescript235 lines
1import { exports } from "cloudflare:workers";
2import { describe, expect, it, vi } from "vitest";
3 
4import { buildUpstreamSocialBody } from "@/worker/api/sign-in-social";
5 
6import { ISSUER, testHeaders } from "./helpers";
7 
8const SELF = exports.default;
9 
10// tessera's social wrapper forwards to Better Auth via auth.handler. The
11// social provider config is gated on env.GITHUB_OAUTH_CLIENT_ID and the
12// Google equivalent in src/worker/auth/index.ts; the test pool's env has
13// neither set, so Better Auth's social handler returns a 4xx for unknown
14// provider — but only AFTER tessera's Turnstile + rate-limit boundary has
15// run. The cells we care about are the ones tessera owns.
16 
17describe("POST /api/sign-in/social", () => {
18 it("400s on unsupported provider", async () => {
19 const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, {
20 method: "POST",
21 headers: testHeaders({ "CF-Connecting-IP": "10.72.0.1" }),
22 body: JSON.stringify({ provider: "linkedin", turnstileToken: "loopback" }),
23 });
24 expect(res.status).toBe(400);
25 const body = (await res.json()) as { error?: string };
26 expect(body.error).toBe("invalid_body");
27 });
28 
29 it("400s when turnstileToken is missing", async () => {
30 const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, {
31 method: "POST",
32 headers: testHeaders({ "CF-Connecting-IP": "10.72.0.2" }),
33 body: JSON.stringify({ provider: "github" }),
34 });
35 expect(res.status).toBe(400);
36 const body = (await res.json()) as { error?: string };
37 expect(body.error).toBe("invalid_body");
38 });
39 
40 it("403s on foreign Origin (origin guard runs before any handler work)", async () => {
41 const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, {
42 method: "POST",
43 headers: testHeaders({ origin: "https://evil.example.com", "CF-Connecting-IP": "10.72.0.3" }),
44 body: JSON.stringify({ provider: "github", turnstileToken: "loopback" }),
45 });
46 expect(res.status).toBe(403);
47 const body = (await res.json()) as { error?: string };
48 expect(body.error).toBe("forbidden_origin");
49 });
50 
51 it("403s on missing Origin", async () => {
52 const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, {
53 method: "POST",
54 headers: {
55 "content-type": "application/json",
56 "CF-Connecting-IP": "10.72.0.4",
57 },
58 body: JSON.stringify({ provider: "github", turnstileToken: "loopback" }),
59 });
60 expect(res.status).toBe(403);
61 });
62 
63 it("403s on turnstile failure (siteverify rejection)", async () => {
64 // Empty token triggers tessera's mocked verifier failure path; the
65 // boundary order is rate-limit -> turnstile, so a fresh IP with empty
66 // token surfaces a 400 missing_token from request body validation, not
67 // a turnstile error. Use a non-empty token + the dedicated turnstile
68 // mock to force the rejection path here.
69 const turnstile = await import("@/worker/services/turnstile");
70 const verifySpy = vi.spyOn(turnstile, "verifyTurnstileToken").mockResolvedValueOnce({
71 ok: false,
72 status: 403,
73 reason: "verification_failed",
74 message: turnstile.TURNSTILE_REQUIRED_MESSAGE,
75 errorCodes: [],
76 });
77 
78 const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, {
79 method: "POST",
80 headers: testHeaders({ "CF-Connecting-IP": "10.72.0.5" }),
81 body: JSON.stringify({ provider: "github", turnstileToken: "any-token" }),
82 });
83 expect(res.status).toBe(403);
84 const body = (await res.json()) as { error?: string };
85 expect(body.error).toBe("turnstile_failed");
86 verifySpy.mockRestore();
87 });
88 
89 it("404s the raw /api/auth/sign-in/social endpoint", async () => {
90 const res = await SELF.fetch(`${ISSUER}/api/auth/sign-in/social`, {
91 method: "POST",
92 headers: testHeaders(),
93 body: JSON.stringify({ provider: "github" }),
94 });
95 expect(res.status).toBe(404);
96 expect(await res.text()).toBe("Not Found");
97 });
98 
99 // Worker-boundary callback validation. Better Auth uses callbackURL /
100 // errorCallbackURL as the post-IdP redirect target, so an absolute or
101 // protocol-relative value would let any caller turn the social flow
102 // into an open redirector — even though the React client normalizes
103 // these to local paths, the worker must enforce the contract.
104 describe("callback URL validation", () => {
105 it("rejects an absolute callbackURL with 400 invalid_callback_url", async () => {
106 const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, {
107 method: "POST",
108 headers: testHeaders({ "CF-Connecting-IP": "10.73.0.1" }),
109 body: JSON.stringify({
110 provider: "github",
111 turnstileToken: "loopback",
112 callbackURL: "https://evil.example/path",
113 }),
114 });
115 expect(res.status).toBe(400);
116 const body = (await res.json()) as { error?: string };
117 expect(body.error).toBe("invalid_callback_url");
118 });
119 
120 it("rejects a protocol-relative callbackURL", async () => {
121 const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, {
122 method: "POST",
123 headers: testHeaders({ "CF-Connecting-IP": "10.73.0.2" }),
124 body: JSON.stringify({
125 provider: "github",
126 turnstileToken: "loopback",
127 callbackURL: "//evil.example/path",
128 }),
129 });
130 expect(res.status).toBe(400);
131 const body = (await res.json()) as { error?: string };
132 expect(body.error).toBe("invalid_callback_url");
133 });
134 
135 it("rejects a callbackURL containing backslashes", async () => {
136 const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, {
137 method: "POST",
138 headers: testHeaders({ "CF-Connecting-IP": "10.73.0.3" }),
139 body: JSON.stringify({
140 provider: "github",
141 turnstileToken: "loopback",
142 callbackURL: "/path\\backslash",
143 }),
144 });
145 expect(res.status).toBe(400);
146 const body = (await res.json()) as { error?: string };
147 expect(body.error).toBe("invalid_callback_url");
148 });
149 
150 it("rejects an absolute errorCallbackURL", async () => {
151 const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, {
152 method: "POST",
153 headers: testHeaders({ "CF-Connecting-IP": "10.73.0.4" }),
154 body: JSON.stringify({
155 provider: "github",
156 turnstileToken: "loopback",
157 callbackURL: "/account",
158 errorCallbackURL: "https://evil.example/oops",
159 }),
160 });
161 expect(res.status).toBe(400);
162 const body = (await res.json()) as { error?: string };
163 expect(body.error).toBe("invalid_callback_url");
164 });
165 
166 it("accepts a valid local callbackURL (validation runs only when present)", async () => {
167 // GitHub creds aren't set in the test pool, so Better Auth's social
168 // handler returns a 4xx/5xx of its own — but only AFTER tessera's
169 // own validation passes. Asserting "not 400 invalid_callback_url"
170 // proves the boundary doesn't reject the valid local path.
171 const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, {
172 method: "POST",
173 headers: testHeaders({ "CF-Connecting-IP": "10.73.0.5" }),
174 body: JSON.stringify({
175 provider: "github",
176 turnstileToken: "loopback",
177 callbackURL: "/account",
178 errorCallbackURL: "/sign-in?error=social_unavailable",
179 }),
180 });
181 if (res.status === 400) {
182 const body = (await res.json()) as { error?: string };
183 expect(body.error).not.toBe("invalid_callback_url");
184 }
185 });
186 });
187 
188 // Lock in the contract with oauth-provider's before-hook. The hook
189 // matches `ctx.body.oauth_query` at the top level — putting the
190 // signed query under `additionalData` would skip the hook and drop
191 // the RP-initiated /authorize context after a social round-trip.
192 describe("buildUpstreamSocialBody", () => {
193 it("emits provider only when no callbacks or oauth_query are supplied", () => {
194 expect(buildUpstreamSocialBody({ provider: "github" })).toEqual({ provider: "github" });
195 });
196 
197 it("forwards oauth_query at the top level (not under additionalData)", () => {
198 const body = buildUpstreamSocialBody({
199 provider: "google",
200 callbackURL: "/account",
201 errorCallbackURL: "/sign-in?error=social_unavailable",
202 oauth_query: "client_id=abc&state=xyz&sig=signed",
203 });
204 expect(body.oauth_query).toBe("client_id=abc&state=xyz&sig=signed");
205 expect(body.additionalData).toBeUndefined();
206 expect(body.provider).toBe("google");
207 expect(body.callbackURL).toBe("/account");
208 expect(body.errorCallbackURL).toBe("/sign-in?error=social_unavailable");
209 });
210 
211 it("omits oauth_query when caller passes empty string", () => {
212 const body = buildUpstreamSocialBody({ provider: "github", oauth_query: "" });
213 expect("oauth_query" in body).toBe(false);
214 });
215 });
216 
217 it("returns 429 with Retry-After once the per-IP threshold is exceeded (shared with email path)", async () => {
218 const ip = "198.51.100.55";
219 let lastStatus = 0;
220 let last: Response | null = null;
221 for (let i = 0; i < 12; i += 1) {
222 last = await SELF.fetch(`${ISSUER}/api/sign-in/social`, {
223 method: "POST",
224 headers: testHeaders({ "CF-Connecting-IP": ip }),
225 body: JSON.stringify({ provider: "github" }),
226 });
227 lastStatus = last.status;
228 if (lastStatus === 429) break;
229 }
230 expect(lastStatus).toBe(429);
231 expect(last).not.toBeNull();
232 expect(last!.headers.get("retry-after")).toBe("60");
233 }, 30_000);
234});