import { exports } from "cloudflare:workers"; import { describe, expect, it, vi } from "vitest"; import { buildUpstreamSocialBody } from "@/worker/api/sign-in-social"; import { ISSUER, testHeaders } from "./helpers"; const SELF = exports.default; // tessera's social wrapper forwards to Better Auth via auth.handler. The // social provider config is gated on env.GITHUB_OAUTH_CLIENT_ID and the // Google equivalent in src/worker/auth/index.ts; the test pool's env has // neither set, so Better Auth's social handler returns a 4xx for unknown // provider — but only AFTER tessera's Turnstile + rate-limit boundary has // run. The cells we care about are the ones tessera owns. describe("POST /api/sign-in/social", () => { it("400s on unsupported provider", async () => { const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "10.72.0.1" }), body: JSON.stringify({ provider: "linkedin", turnstileToken: "loopback" }), }); expect(res.status).toBe(400); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("invalid_body"); }); it("400s when turnstileToken is missing", async () => { const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "10.72.0.2" }), body: JSON.stringify({ provider: "github" }), }); expect(res.status).toBe(400); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("invalid_body"); }); it("403s on foreign Origin (origin guard runs before any handler work)", async () => { const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, { method: "POST", headers: testHeaders({ origin: "https://evil.example.com", "CF-Connecting-IP": "10.72.0.3" }), body: JSON.stringify({ provider: "github", turnstileToken: "loopback" }), }); expect(res.status).toBe(403); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("forbidden_origin"); }); it("403s on missing Origin", async () => { const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, { method: "POST", headers: { "content-type": "application/json", "CF-Connecting-IP": "10.72.0.4", }, body: JSON.stringify({ provider: "github", turnstileToken: "loopback" }), }); expect(res.status).toBe(403); }); it("403s on turnstile failure (siteverify rejection)", async () => { // Empty token triggers tessera's mocked verifier failure path; the // boundary order is rate-limit -> turnstile, so a fresh IP with empty // token surfaces a 400 missing_token from request body validation, not // a turnstile error. Use a non-empty token + the dedicated turnstile // mock to force the rejection path here. const turnstile = await import("@/worker/services/turnstile"); const verifySpy = vi.spyOn(turnstile, "verifyTurnstileToken").mockResolvedValueOnce({ ok: false, status: 403, reason: "verification_failed", message: turnstile.TURNSTILE_REQUIRED_MESSAGE, errorCodes: [], }); const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "10.72.0.5" }), body: JSON.stringify({ provider: "github", turnstileToken: "any-token" }), }); expect(res.status).toBe(403); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("turnstile_failed"); verifySpy.mockRestore(); }); it("404s the raw /api/auth/sign-in/social endpoint", async () => { const res = await SELF.fetch(`${ISSUER}/api/auth/sign-in/social`, { method: "POST", headers: testHeaders(), body: JSON.stringify({ provider: "github" }), }); expect(res.status).toBe(404); expect(await res.text()).toBe("Not Found"); }); // Worker-boundary callback validation. Better Auth uses callbackURL / // errorCallbackURL as the post-IdP redirect target, so an absolute or // protocol-relative value would let any caller turn the social flow // into an open redirector — even though the React client normalizes // these to local paths, the worker must enforce the contract. describe("callback URL validation", () => { it("rejects an absolute callbackURL with 400 invalid_callback_url", async () => { const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "10.73.0.1" }), body: JSON.stringify({ provider: "github", turnstileToken: "loopback", callbackURL: "https://evil.example/path", }), }); expect(res.status).toBe(400); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("invalid_callback_url"); }); it("rejects a protocol-relative callbackURL", async () => { const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "10.73.0.2" }), body: JSON.stringify({ provider: "github", turnstileToken: "loopback", callbackURL: "//evil.example/path", }), }); expect(res.status).toBe(400); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("invalid_callback_url"); }); it("rejects a callbackURL containing backslashes", async () => { const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "10.73.0.3" }), body: JSON.stringify({ provider: "github", turnstileToken: "loopback", callbackURL: "/path\\backslash", }), }); expect(res.status).toBe(400); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("invalid_callback_url"); }); it("rejects an absolute errorCallbackURL", async () => { const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "10.73.0.4" }), body: JSON.stringify({ provider: "github", turnstileToken: "loopback", callbackURL: "/account", errorCallbackURL: "https://evil.example/oops", }), }); expect(res.status).toBe(400); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("invalid_callback_url"); }); it("accepts a valid local callbackURL (validation runs only when present)", async () => { // GitHub creds aren't set in the test pool, so Better Auth's social // handler returns a 4xx/5xx of its own — but only AFTER tessera's // own validation passes. Asserting "not 400 invalid_callback_url" // proves the boundary doesn't reject the valid local path. const res = await SELF.fetch(`${ISSUER}/api/sign-in/social`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": "10.73.0.5" }), body: JSON.stringify({ provider: "github", turnstileToken: "loopback", callbackURL: "/account", errorCallbackURL: "/sign-in?error=social_unavailable", }), }); if (res.status === 400) { const body = (await res.json()) as { error?: string }; expect(body.error).not.toBe("invalid_callback_url"); } }); }); // Lock in the contract with oauth-provider's before-hook. The hook // matches `ctx.body.oauth_query` at the top level — putting the // signed query under `additionalData` would skip the hook and drop // the RP-initiated /authorize context after a social round-trip. describe("buildUpstreamSocialBody", () => { it("emits provider only when no callbacks or oauth_query are supplied", () => { expect(buildUpstreamSocialBody({ provider: "github" })).toEqual({ provider: "github" }); }); it("forwards oauth_query at the top level (not under additionalData)", () => { const body = buildUpstreamSocialBody({ provider: "google", callbackURL: "/account", errorCallbackURL: "/sign-in?error=social_unavailable", oauth_query: "client_id=abc&state=xyz&sig=signed", }); expect(body.oauth_query).toBe("client_id=abc&state=xyz&sig=signed"); expect(body.additionalData).toBeUndefined(); expect(body.provider).toBe("google"); expect(body.callbackURL).toBe("/account"); expect(body.errorCallbackURL).toBe("/sign-in?error=social_unavailable"); }); it("omits oauth_query when caller passes empty string", () => { const body = buildUpstreamSocialBody({ provider: "github", oauth_query: "" }); expect("oauth_query" in body).toBe(false); }); }); it("returns 429 with Retry-After once the per-IP threshold is exceeded (shared with email path)", async () => { const ip = "198.51.100.55"; let lastStatus = 0; let last: Response | null = null; for (let i = 0; i < 12; i += 1) { last = await SELF.fetch(`${ISSUER}/api/sign-in/social`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": ip }), body: JSON.stringify({ provider: "github" }), }); lastStatus = last.status; if (lastStatus === 429) break; } expect(lastStatus).toBe(429); expect(last).not.toBeNull(); expect(last!.headers.get("retry-after")).toBe("60"); }, 30_000); });