diff --git a/package.json b/package.json index 10cc5cb9d..27ba5ba2d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test:collaboration": "vitest run --config services/collaboration/vitest.config.ts", "start:collaboration": "tsx services/collaboration/src/index.ts", "test:watch": "vitest", - "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", + "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:webkit": "playwright test --project=webkit", diff --git a/src/test/security-hardening.test.ts b/src/test/security-hardening.test.ts index 3b7c707dc..67085015a 100644 --- a/src/test/security-hardening.test.ts +++ b/src/test/security-hardening.test.ts @@ -45,7 +45,10 @@ describe('security hardening regressions', () => { it('uses code-scoped sharing and disallows anonymous share creation', () => { expect(migration).toContain('DROP POLICY IF EXISTS "shared_items_select_public"'); expect(migration).toContain('FUNCTION public.get_shared_item(p_share_code text)'); - expect(share).toContain('if (!userId)'); + // requireUser refuses a missing, empty, or rejected token with 401, so the + // creator is always a verified user. share/auth_test.ts covers the behavior. + expect(share).toContain('userId = (await requireUser(req)).user.id'); + expect(share).toContain('created_by: userId'); expect(share).toContain('crypto.getRandomValues'); const sharedPage = read('src/pages/SharedPage.tsx'); expect(sharedPage).toContain("rpc('get_shared_item'"); diff --git a/supabase/functions/_shared/testing/bearer-auth.ts b/supabase/functions/_shared/testing/bearer-auth.ts new file mode 100644 index 000000000..1869b5b63 --- /dev/null +++ b/supabase/functions/_shared/testing/bearer-auth.ts @@ -0,0 +1,91 @@ +// Test support for Edge Functions that authenticate with requireUser. +// Deno only collects *_test.ts and *.test.ts files, so this module is not a +// test itself, and no deployed function imports it. +import { assertEquals } from "jsr:@std/assert@1.0.19"; + +export const TEST_SUPABASE_URL = "https://supabase.test"; +export const TEST_ANON_KEY = "test-anon-key"; +export const VALID_TOKEN = "valid-token"; +export const TEST_USER = { + id: "11111111-1111-4111-8111-111111111111", + aud: "authenticated", + role: "authenticated", + email: "user@example.test", +}; + +export type BearerCase = { + label: string; + authorization?: string; + status: number; + body: unknown; + /** Calls to the Auth user endpoint this request may make. */ + authCalls: number; +}; + +/** Missing, empty, non-Bearer and rejected credentials, all refused with 401. */ +export function rejectedBearerCases(bodies: { required: unknown; invalid: unknown }): BearerCase[] { + return [ + { label: "missing header", status: 401, body: bodies.required, authCalls: 0 }, + { label: "empty bearer token", authorization: "Bearer ", status: 401, body: bodies.required, authCalls: 0 }, + { label: "non-Bearer scheme", authorization: "Basic dXNlcjpwYXNz", status: 401, body: bodies.required, authCalls: 0 }, + { label: "rejected token", authorization: "Bearer expired-token", status: 401, body: bodies.invalid, authCalls: 1 }, + ]; +} + +type OtherFetch = (url: URL, headers: Headers, init?: RequestInit) => Response | Promise; + +function headersOf(input: Request | URL | string, init?: RequestInit): Headers { + const headers = new Headers(input instanceof Request ? input.headers : undefined); + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); + return headers; +} + +/** + * Sends one request per case through `handle`. Supabase Auth accepts only + * VALID_TOKEN; any other outbound request goes to `otherFetch`, or fails the + * test when none is given. + */ +export async function runBearerCases( + handle: (req: Request) => Promise, + makeRequest: (authorization?: string) => Request, + cases: BearerCase[], + otherFetch?: OtherFetch, +): Promise { + const originalFetch = globalThis.fetch; + const authCalls: string[] = []; + + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const headers = headersOf(input, init); + if (url.origin === TEST_SUPABASE_URL && url.pathname === "/auth/v1/user") { + const authorization = headers.get("authorization") ?? ""; + authCalls.push(authorization); + return authorization === `Bearer ${VALID_TOKEN}` + ? Response.json(TEST_USER) + : Response.json({ code: 401, error_code: "bad_jwt", msg: "invalid JWT" }, { status: 401 }); + } + if (otherFetch) return await otherFetch(url, headers, init); + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + + try { + for (const testCase of cases) { + authCalls.length = 0; + const response = await handle(makeRequest(testCase.authorization)); + const text = await response.text(); + let body: unknown = text; + try { + body = JSON.parse(text); + } catch { + // Keep the raw text so the assertion shows it. + } + assertEquals( + { status: response.status, body, authCalls: authCalls.length }, + { status: testCase.status, body: testCase.body, authCalls: testCase.authCalls }, + testCase.label, + ); + } + } finally { + globalThis.fetch = originalFetch; + } +} diff --git a/supabase/functions/admin/auth_test.ts b/supabase/functions/admin/auth_test.ts new file mode 100644 index 000000000..585f5575e --- /dev/null +++ b/supabase/functions/admin/auth_test.ts @@ -0,0 +1,42 @@ +import { + rejectedBearerCases, + runBearerCases, + TEST_ANON_KEY, + TEST_SUPABASE_URL, + TEST_USER, + VALID_TOKEN, +} from "../_shared/testing/bearer-auth.ts"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: TEST_SUPABASE_URL, + SUPABASE_ANON_KEY: TEST_ANON_KEY, + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +const send = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/admin?action=announcements", { + method: "GET", + headers: authorization === undefined ? {} : { Authorization: authorization }, + }); + +Deno.test("admin refuses missing, empty, non-Bearer and rejected tokens with the shared auth errors", async () => { + await runBearerCases(handleRequest, send, rejectedBearerCases({ required: { error: "Authentication required" }, invalid: { error: "Invalid authentication token" } })); +}); + +Deno.test("admin checks the role of the user the token belongs to", async () => { + const roleQueries: string[] = []; + await runBearerCases(handleRequest, send, [{ + label: "valid token, ordinary user", + authorization: `Bearer ${VALID_TOKEN}`, + status: 403, + body: { error: "Admin access required" }, + authCalls: 1, + }], (url) => { + if (url.pathname !== "/rest/v1/user_roles") throw new Error(`Unexpected request: ${url}`); + roleQueries.push(url.searchParams.get("user_id") ?? ""); + return Response.json({ role: "user" }); + }); + if (roleQueries.join() !== `eq.${TEST_USER.id}`) throw new Error(`role lookups: ${roleQueries.join()}`); +}); diff --git a/supabase/functions/admin/index.ts b/supabase/functions/admin/index.ts index 52a3716f4..5b2783d1f 100644 --- a/supabase/functions/admin/index.ts +++ b/supabase/functions/admin/index.ts @@ -1,13 +1,14 @@ // Supabase Edge Function: Admin API for DebateLab // Provides usage statistics, rate limit management, and user management -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2"; import { validateAnnouncementId, validateCreateAnnouncement, } from "../_shared/announcements.ts"; import { generateDemoCode, hashDemoCode, isCreatableDemoRole } from "../_shared/demo-code.ts"; import { collectAdminUsagePages, getKnownCost, hasUnknownCost, isNonAiActivity } from "../_shared/admin-cost.ts"; +import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; // SECURITY: Allow specific origins for CORS @@ -86,7 +87,7 @@ async function assertCurrentAdmin(userId: string) { } } -Deno.serve(async (req) => { +export async function handleRequest(req: Request): Promise { const origin = req.headers.get("origin"); const corsHeaders = getCorsHeaders(origin, { methods: "POST, GET, OPTIONS" }); @@ -95,26 +96,13 @@ Deno.serve(async (req) => { } // Authenticate request - const authHeader = req.headers.get("authorization"); - console.log("[ADMIN] Auth header present:", !!authHeader, "starts with Bearer:", authHeader?.startsWith("Bearer ")); - if (!authHeader?.startsWith("Bearer ")) { - console.log("[ADMIN] No auth header, returning 401"); - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } - - const token = authHeader.slice(7); - console.log("[ADMIN] Token length:", token.length); - const { data: { user }, error: authError } = await supabase.auth.getUser(token); - console.log("[ADMIN] getUser result - user:", user?.id, "error:", authError?.message); - if (authError || !user) { - console.log("[ADMIN] Auth failed, returning 401"); - return new Response(JSON.stringify({ error: "Invalid token" }), { - status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + let user: User; + try { + user = (await requireUser(req)).user; + } catch (error) { + if (!(error instanceof HttpError)) throw error; + console.warn("[ADMIN] Authentication rejected:", error.message); + return jsonError(error, corsHeaders); } // Check if user is admin @@ -1032,4 +1020,8 @@ Deno.serve(async (req) => { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } -}); +} + +if (import.meta.main) { + Deno.serve(handleRequest); +} diff --git a/supabase/functions/ai-stream/auth_test.ts b/supabase/functions/ai-stream/auth_test.ts new file mode 100644 index 000000000..f287cf7eb --- /dev/null +++ b/supabase/functions/ai-stream/auth_test.ts @@ -0,0 +1,29 @@ +import { + rejectedBearerCases, + runBearerCases, + TEST_ANON_KEY, + TEST_SUPABASE_URL, +} from "../_shared/testing/bearer-auth.ts"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: TEST_SUPABASE_URL, + SUPABASE_ANON_KEY: TEST_ANON_KEY, + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + OPENROUTER_API_KEY: "test-openrouter-key", +})) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +const send = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/ai-stream", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(authorization === undefined ? {} : { Authorization: authorization }), + }, + body: JSON.stringify({ feature: "ask", model: "openai/gpt-5.6-luna", input: { message: "Question", mode: "general" } }), + }); + +Deno.test("ai-stream refuses missing, empty, non-Bearer and rejected tokens with the shared auth errors", async () => { + await runBearerCases(handleRequest, send, rejectedBearerCases({ required: { error: "Authentication required" }, invalid: { error: "Invalid authentication token" } })); +}); diff --git a/supabase/functions/ai-stream/handler_test.ts b/supabase/functions/ai-stream/handler_test.ts index bca690c68..853556c43 100644 --- a/supabase/functions/ai-stream/handler_test.ts +++ b/supabase/functions/ai-stream/handler_test.ts @@ -1,5 +1,6 @@ const testEnvironment = { SUPABASE_URL: "https://supabase.test", + SUPABASE_ANON_KEY: "test-anon-key", SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", OPENROUTER_API_KEY: "test-openrouter-key", }; diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index f88b69000..195baebfb 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -1,7 +1,7 @@ // Supabase Edge Function: streaming Ask proxy for DebateLab. // Keeps provider keys server-side and uses SSE for progressive UI rendering. -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2"; import { getAskModeGuidance } from "../ai/prompt-builder.ts"; import { isAskStreamingModeAllowed, validateControlInput } from "../_shared/control-enforcement.ts"; import { boundPromptSources, buildStreamingAskSystemPrompt, type StreamingAskMaterial } from "../_shared/prompts.ts"; @@ -18,6 +18,7 @@ import { normalizeOpenRouterUsage, type NormalizedUsage, } from "../_shared/cost.ts"; +import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; import { checkRateLimit, logUsage } from "../_shared/entitlements.ts"; import { OPENROUTER_API_URL } from "../_shared/research.ts"; @@ -101,15 +102,13 @@ export async function handleRequest(req: Request): Promise { return jsonResponse({ error: "Method not allowed" }, 405, corsHeaders); } - const authHeader = req.headers.get("Authorization"); - if (!authHeader) { - return jsonResponse({ error: "Authentication required" }, 401, corsHeaders); - } - - const token = authHeader.replace("Bearer ", ""); - const { data: { user }, error: authError } = await supabase.auth.getUser(token); - if (authError || !user) { - return jsonResponse({ error: "Invalid authentication token" }, 401, corsHeaders); + let user: User; + try { + user = (await requireUser(req)).user; + } catch (error) { + if (!(error instanceof HttpError)) throw error; + console.warn(JSON.stringify({ function: "ai-stream", event: "auth_rejected", error: error.message })); + return jsonError(error, corsHeaders); } let body: Record; diff --git a/supabase/functions/ai-stream/index_test.ts b/supabase/functions/ai-stream/index_test.ts index 3bc5731ba..5c4067417 100644 --- a/supabase/functions/ai-stream/index_test.ts +++ b/supabase/functions/ai-stream/index_test.ts @@ -7,6 +7,7 @@ function jsonResponse(body: unknown, status = 200): Response { for (const [key, value] of Object.entries({ SUPABASE_URL: "https://supabase.test", + SUPABASE_ANON_KEY: "test-anon-key", SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", OPENROUTER_API_KEY: "test-openrouter-key", })) Deno.env.set(key, value); diff --git a/supabase/functions/create-checkout/auth_test.ts b/supabase/functions/create-checkout/auth_test.ts new file mode 100644 index 000000000..4dd1ca354 --- /dev/null +++ b/supabase/functions/create-checkout/auth_test.ts @@ -0,0 +1,40 @@ +import { + rejectedBearerCases, + runBearerCases, + TEST_ANON_KEY, + TEST_SUPABASE_URL, + VALID_TOKEN, +} from "../_shared/testing/bearer-auth.ts"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: TEST_SUPABASE_URL, + SUPABASE_ANON_KEY: TEST_ANON_KEY, + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + STRIPE_SECRET_KEY: "sk_test_not_used", +})) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +const send = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/create-checkout", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(authorization === undefined ? {} : { Authorization: authorization }), + }, + body: JSON.stringify({}), + }); + +Deno.test("create-checkout refuses missing, empty, non-Bearer and rejected tokens with the shared auth errors", async () => { + await runBearerCases(handleRequest, send, rejectedBearerCases({ required: { error: "Authentication required" }, invalid: { error: "Invalid authentication token" } })); +}); + +Deno.test("create-checkout lets a valid token through to request validation", async () => { + await runBearerCases(handleRequest, send, [{ + label: "valid token", + authorization: `Bearer ${VALID_TOKEN}`, + status: 400, + body: { error: "Missing required fields: successUrl, cancelUrl" }, + authCalls: 1, + }]); +}); diff --git a/supabase/functions/create-checkout/index.ts b/supabase/functions/create-checkout/index.ts index f700f62c6..760330730 100644 --- a/supabase/functions/create-checkout/index.ts +++ b/supabase/functions/create-checkout/index.ts @@ -1,8 +1,9 @@ // Supabase Edge Function: Create Stripe Checkout Session // Creates a Checkout Session for subscription purchase. -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2"; import Stripe from "https://esm.sh/stripe@14.10.0?target=deno"; +import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; import { findCustomerOwnedByUser, @@ -191,7 +192,7 @@ function classifyStripeError(err: unknown): { status: number; message: string; d return { status: 500, message, details }; } -Deno.serve(async (req) => { +export async function handleRequest(req: Request): Promise { const origin = req.headers.get("origin"); const corsHeaders = getCorsHeaders(origin); @@ -204,21 +205,13 @@ Deno.serve(async (req) => { } // Authenticate user - const authHeader = req.headers.get("authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } - - const token = authHeader.slice(7); - const { data: { user }, error: authError } = await supabase.auth.getUser(token); - if (authError || !user) { - return new Response(JSON.stringify({ error: "Invalid token" }), { - status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + let user: User; + try { + user = (await requireUser(req)).user; + } catch (error) { + if (!(error instanceof HttpError)) throw error; + console.warn("[CHECKOUT] Authentication rejected:", error.message); + return jsonError(error, corsHeaders); } try { @@ -353,4 +346,8 @@ Deno.serve(async (req) => { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } -}); +} + +if (import.meta.main) { + Deno.serve(handleRequest); +} diff --git a/supabase/functions/create-portal/auth_test.ts b/supabase/functions/create-portal/auth_test.ts new file mode 100644 index 000000000..0df0adfad --- /dev/null +++ b/supabase/functions/create-portal/auth_test.ts @@ -0,0 +1,40 @@ +import { + rejectedBearerCases, + runBearerCases, + TEST_ANON_KEY, + TEST_SUPABASE_URL, + VALID_TOKEN, +} from "../_shared/testing/bearer-auth.ts"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: TEST_SUPABASE_URL, + SUPABASE_ANON_KEY: TEST_ANON_KEY, + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + STRIPE_SECRET_KEY: "sk_test_not_used", +})) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +const send = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/create-portal", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(authorization === undefined ? {} : { Authorization: authorization }), + }, + body: JSON.stringify({}), + }); + +Deno.test("create-portal refuses missing, empty, non-Bearer and rejected tokens with the shared auth errors", async () => { + await runBearerCases(handleRequest, send, rejectedBearerCases({ required: { error: "Authentication required" }, invalid: { error: "Invalid authentication token" } })); +}); + +Deno.test("create-portal lets a valid token through to request validation", async () => { + await runBearerCases(handleRequest, send, [{ + label: "valid token", + authorization: `Bearer ${VALID_TOKEN}`, + status: 400, + body: { error: "Missing returnUrl" }, + authCalls: 1, + }]); +}); diff --git a/supabase/functions/create-portal/index.ts b/supabase/functions/create-portal/index.ts index 2945474b9..fcb3612a7 100644 --- a/supabase/functions/create-portal/index.ts +++ b/supabase/functions/create-portal/index.ts @@ -1,8 +1,9 @@ // Supabase Edge Function: Create Stripe Customer Portal Session // Generates a portal URL so users can manage their subscription. -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2"; import Stripe from "https://esm.sh/stripe@14.10.0?target=deno"; +import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; import { findCustomerOwnedByUser, @@ -83,7 +84,7 @@ function classifyStripeError(err: unknown): { status: number; message: string } return { status: 500, message }; } -Deno.serve(async (req) => { +export async function handleRequest(req: Request): Promise { const origin = req.headers.get("origin"); const corsHeaders = getCorsHeaders(origin); @@ -96,21 +97,13 @@ Deno.serve(async (req) => { } // Authenticate user - const authHeader = req.headers.get("authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } - - const token = authHeader.slice(7); - const { data: { user }, error: authError } = await supabase.auth.getUser(token); - if (authError || !user) { - return new Response(JSON.stringify({ error: "Invalid token" }), { - status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + let user: User; + try { + user = (await requireUser(req)).user; + } catch (error) { + if (!(error instanceof HttpError)) throw error; + console.warn("[PORTAL] Authentication rejected:", error.message); + return jsonError(error, corsHeaders); } try { @@ -168,4 +161,8 @@ Deno.serve(async (req) => { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } -}); +} + +if (import.meta.main) { + Deno.serve(handleRequest); +} diff --git a/supabase/functions/export-gdoc/auth_test.ts b/supabase/functions/export-gdoc/auth_test.ts new file mode 100644 index 000000000..0fd9387d6 --- /dev/null +++ b/supabase/functions/export-gdoc/auth_test.ts @@ -0,0 +1,44 @@ +import { + rejectedBearerCases, + runBearerCases, + TEST_ANON_KEY, + TEST_SUPABASE_URL, + VALID_TOKEN, +} from "../_shared/testing/bearer-auth.ts"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: TEST_SUPABASE_URL, + SUPABASE_ANON_KEY: TEST_ANON_KEY, + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +const send = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/export-gdoc", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(authorization === undefined ? {} : { Authorization: authorization }), + }, + body: JSON.stringify({}), + }); + +// The browser reads `error` and treats any 401 as "sign in again", so the +// function keeps its { error: "Unauthorized", details } shape. +Deno.test("export-gdoc refuses missing, empty, non-Bearer and rejected tokens in its own error shape", async () => { + await runBearerCases(handleRequest, send, rejectedBearerCases({ + required: { error: "Unauthorized", details: "Authentication required" }, + invalid: { error: "Unauthorized", details: "Invalid authentication token" }, + })); +}); + +Deno.test("export-gdoc lets a valid token through to request validation", async () => { + await runBearerCases(handleRequest, send, [{ + label: "valid token", + authorization: `Bearer ${VALID_TOKEN}`, + status: 400, + body: { error: "Missing data" }, + authCalls: 1, + }]); +}); diff --git a/supabase/functions/export-gdoc/index.ts b/supabase/functions/export-gdoc/index.ts index 7b0c99f31..6bfa0e340 100644 --- a/supabase/functions/export-gdoc/index.ts +++ b/supabase/functions/export-gdoc/index.ts @@ -2,36 +2,9 @@ // Creates a Google Doc from debate data using the user's OAuth token. // Supports multiple modes: generate, attack, brainstorm, factoids -// Import Supabase client for JWT verification -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { HttpError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; -// Initialize Supabase client with service role key for user verification -const supabaseUrl = Deno.env.get("SUPABASE_URL")!; -const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; -const supabase = createClient(supabaseUrl, supabaseServiceKey); - -// Validate JWT from Authorization header -async function validateJWT(req: Request): Promise<{ valid: boolean; userId?: string; error?: string }> { - const authHeader = req.headers.get("authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return { valid: false, error: "Missing or invalid Authorization header" }; - } - - const token = authHeader.slice(7); - try { - const { data: { user }, error } = await supabase.auth.getUser(token); - if (error || !user) { - console.error("[GDOC] JWT validation failed:", error?.message || "No user found"); - return { valid: false, error: error?.message || "Invalid token" }; - } - return { valid: true, userId: user.id }; - } catch (err) { - console.error("[GDOC] JWT validation error:", err); - return { valid: false, error: "Token validation failed" }; - } -} - @@ -752,7 +725,7 @@ function buildFactoidsContent(data: FactoidsData) { // ============================================================ // MAIN HANDLER // ============================================================ -Deno.serve(async (req: Request) => { +export async function handleRequest(req: Request): Promise { // IMMEDIATE log to verify function is being called console.log("[GDOC] Function invoked, method:", req.method); @@ -767,19 +740,24 @@ Deno.serve(async (req: Request) => { return new Response("Method Not Allowed", { status: 405, headers: corsHeaders }); } - // Validate Supabase JWT (manual validation since we disabled gateway verification) - const jwtResult = await validateJWT(req); - if (!jwtResult.valid) { - console.error("[GDOC] JWT validation failed:", jwtResult.error); + // Gateway JWT verification is disabled for this function, so the handler + // checks the Supabase session itself. The browser reads `error` and treats + // any 401 as "sign in again", so the response keeps this shape. + let userId: string; + try { + userId = (await requireUser(req)).user.id; + } catch (error) { + if (!(error instanceof HttpError)) throw error; + console.warn("[GDOC] Authentication rejected:", error.message); return new Response(JSON.stringify({ error: "Unauthorized", - details: jwtResult.error + details: error.message }), { - status: 401, + status: error.status, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } - console.log("[GDOC] JWT validated successfully, userId:", jwtResult.userId); + console.log("[GDOC] JWT validated successfully, userId:", userId); try { console.log("[GDOC] Parsing request body..."); @@ -958,4 +936,8 @@ Deno.serve(async (req: Request) => { headers: { ...corsHeaders, "Content-Type": "application/json" } }); } -}); +} + +if (import.meta.main) { + Deno.serve(handleRequest); +} diff --git a/supabase/functions/import-source/auth_test.ts b/supabase/functions/import-source/auth_test.ts new file mode 100644 index 000000000..437e530c6 --- /dev/null +++ b/supabase/functions/import-source/auth_test.ts @@ -0,0 +1,28 @@ +import { + rejectedBearerCases, + runBearerCases, + TEST_ANON_KEY, + TEST_SUPABASE_URL, +} from "../_shared/testing/bearer-auth.ts"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: TEST_SUPABASE_URL, + SUPABASE_ANON_KEY: TEST_ANON_KEY, + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +const send = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/import-source", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(authorization === undefined ? {} : { Authorization: authorization }), + }, + body: JSON.stringify({ url: "https://example.com/article" }), + }); + +Deno.test("import-source refuses missing, empty, non-Bearer and rejected tokens with the shared auth errors", async () => { + await runBearerCases(handleRequest, send, rejectedBearerCases({ required: { error: "Authentication required" }, invalid: { error: "Invalid authentication token" } })); +}); diff --git a/supabase/functions/import-source/index.ts b/supabase/functions/import-source/index.ts index 10784a3bc..d2d64090c 100644 --- a/supabase/functions/import-source/index.ts +++ b/supabase/functions/import-source/index.ts @@ -1,6 +1,7 @@ -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2"; import { isPrivateIpv4, isPrivateIpv6, validatePublicHttpUrl, validateResolvedPublicHost } from "./url-validation.ts"; import { readBoundedText } from "./resource-limits.ts"; +import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; // Re-export helpers so existing callers (if any) keep working. @@ -235,15 +236,13 @@ export async function handleRequest(req: Request): Promise { return jsonResponse({ error: "Method not allowed" }, 405, corsHeaders); } - const authHeader = req.headers.get("authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return jsonResponse({ error: "Unauthorized" }, 401, corsHeaders); - } - - const token = authHeader.slice(7); - const { data: { user }, error: authError } = await supabase.auth.getUser(token); - if (authError || !user) { - return jsonResponse({ error: "Invalid token" }, 401, corsHeaders); + let user: User; + try { + user = (await requireUser(req)).user; + } catch (error) { + if (!(error instanceof HttpError)) throw error; + console.warn("[IMPORT_SOURCE] Authentication rejected:", error.message); + return jsonError(error, corsHeaders); } if (await isUserBanned(user.id)) { diff --git a/supabase/functions/import-source/index_test.ts b/supabase/functions/import-source/index_test.ts index 099fe1a56..5c0214201 100644 --- a/supabase/functions/import-source/index_test.ts +++ b/supabase/functions/import-source/index_test.ts @@ -1,5 +1,6 @@ const environment = { SUPABASE_URL: "https://supabase.test", + SUPABASE_ANON_KEY: "test-anon-key", SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", }; diff --git a/supabase/functions/redeem-promo/auth_test.ts b/supabase/functions/redeem-promo/auth_test.ts new file mode 100644 index 000000000..419a2ef70 --- /dev/null +++ b/supabase/functions/redeem-promo/auth_test.ts @@ -0,0 +1,54 @@ +import { + rejectedBearerCases, + runBearerCases, + TEST_ANON_KEY, + TEST_SUPABASE_URL, + VALID_TOKEN, +} from "../_shared/testing/bearer-auth.ts"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: TEST_SUPABASE_URL, + SUPABASE_ANON_KEY: TEST_ANON_KEY, + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +const send = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/redeem-promo", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(authorization === undefined ? {} : { Authorization: authorization }), + }, + body: JSON.stringify({ code: " spring " }), + }); + +// The browser expects { success, message } from this function. +Deno.test("redeem-promo refuses missing, empty, non-Bearer and rejected tokens in its own error shape", async () => { + await runBearerCases(handleRequest, send, rejectedBearerCases({ + required: { success: false, message: "Authentication required" }, + invalid: { success: false, message: "Invalid authentication token" }, + })); +}); + +Deno.test("redeem-promo redeems through a client scoped to the caller's token", async () => { + const rpcCalls: Array<{ authorization: string | null; apikey: string | null; body: unknown }> = []; + await runBearerCases(handleRequest, send, [{ + label: "valid token", + authorization: `Bearer ${VALID_TOKEN}`, + status: 200, + body: { success: true, message: "Promo code applied!" }, + authCalls: 1, + }], (url, headers, init) => { + if (url.pathname !== "/rest/v1/rpc/redeem_promo_code_atomic") throw new Error(`Unexpected request: ${url}`); + rpcCalls.push({ + authorization: headers.get("authorization"), + apikey: headers.get("apikey"), + body: JSON.parse(String(init?.body)), + }); + return Response.json({ success: true, message: "Promo code applied!", promoType: "trial" }); + }); + const expected = JSON.stringify([{ authorization: `Bearer ${VALID_TOKEN}`, apikey: TEST_ANON_KEY, body: { p_code: "SPRING" } }]); + if (JSON.stringify(rpcCalls) !== expected) throw new Error(`RPC calls: ${JSON.stringify(rpcCalls)}`); +}); diff --git a/supabase/functions/redeem-promo/index.ts b/supabase/functions/redeem-promo/index.ts index e2e0752da..1c986a497 100644 --- a/supabase/functions/redeem-promo/index.ts +++ b/supabase/functions/redeem-promo/index.ts @@ -1,17 +1,10 @@ // Supabase Edge Function: Redeem Promo Code // Validates and applies promo codes for users. -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { type AuthContext, HttpError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; -const supabaseUrl = Deno.env.get("SUPABASE_URL")!; -const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; -const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!; -const supabase = createClient(supabaseUrl, supabaseServiceKey); - - - -Deno.serve(async (req) => { +export async function handleRequest(req: Request): Promise { const origin = req.headers.get("origin"); const corsHeaders = getCorsHeaders(origin); @@ -23,23 +16,19 @@ Deno.serve(async (req) => { return new Response("Method Not Allowed", { status: 405, headers: corsHeaders }); } - // Authenticate - const authHeader = req.headers.get("authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return new Response(JSON.stringify({ success: false, message: "Unauthorized" }), { - status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } - - const token = authHeader.slice(7); - const { data: { user }, error: authError } = await supabase.auth.getUser(token); - if (authError || !user) { - return new Response(JSON.stringify({ success: false, message: "Invalid token" }), { - status: 401, + // Authenticate. The browser expects { success, message } from this function. + let auth: AuthContext; + try { + auth = await requireUser(req); + } catch (error) { + if (!(error instanceof HttpError)) throw error; + console.warn("[PROMO] Authentication rejected:", error.message); + return new Response(JSON.stringify({ success: false, message: error.message }), { + status: error.status, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } + const { user, userClient } = auth; try { const body = await req.json(); @@ -52,9 +41,6 @@ Deno.serve(async (req) => { }); } - const userClient = createClient(supabaseUrl, supabaseAnonKey, { - global: { headers: { Authorization: authHeader } }, - }); const { data: result, error: redeemError } = await userClient.rpc("redeem_promo_code_atomic", { p_code: code }); if (redeemError) { @@ -78,4 +64,8 @@ Deno.serve(async (req) => { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } -}); +} + +if (import.meta.main) { + Deno.serve(handleRequest); +} diff --git a/supabase/functions/share/auth_test.ts b/supabase/functions/share/auth_test.ts new file mode 100644 index 000000000..8bfd15ca2 --- /dev/null +++ b/supabase/functions/share/auth_test.ts @@ -0,0 +1,46 @@ +import { + rejectedBearerCases, + runBearerCases, + TEST_ANON_KEY, + TEST_SUPABASE_URL, + TEST_USER, + VALID_TOKEN, +} from "../_shared/testing/bearer-auth.ts"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: TEST_SUPABASE_URL, + SUPABASE_ANON_KEY: TEST_ANON_KEY, + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +const send = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/share", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(authorization === undefined ? {} : { Authorization: authorization }), + }, + body: JSON.stringify({ itemType: "case", itemData: { motion: "This house would test" } }), + }); + +Deno.test("share refuses missing, empty, non-Bearer and rejected tokens with the shared auth errors", async () => { + await runBearerCases(handleRequest, send, rejectedBearerCases({ required: { error: "Authentication required" }, invalid: { error: "Invalid authentication token" } })); +}); + +Deno.test("share records the token's user as the creator", async () => { + const creators: unknown[] = []; + await runBearerCases(handleRequest, send, [{ + label: "valid token", + authorization: `Bearer ${VALID_TOKEN}`, + status: 200, + body: { shareCode: "abc123", shareUrl: "https://debate.pi-stack.org/share/abc123", expiresAt: null }, + authCalls: 1, + }], (url, _headers, init) => { + if (url.pathname !== "/rest/v1/shared_items") throw new Error(`Unexpected request: ${url}`); + creators.push((JSON.parse(String(init?.body)) as { created_by?: unknown }).created_by); + return Response.json({ share_code: "abc123", expires_at: null }, { status: 201 }); + }); + if (JSON.stringify(creators) !== JSON.stringify([TEST_USER.id])) throw new Error(`creators: ${JSON.stringify(creators)}`); +}); diff --git a/supabase/functions/share/index.ts b/supabase/functions/share/index.ts index d210b639f..15039b951 100644 --- a/supabase/functions/share/index.ts +++ b/supabase/functions/share/index.ts @@ -2,6 +2,7 @@ // Creates and manages shareable links for debate content import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { PUBLIC_WEB_APP_ORIGIN, getCorsHeaders } from "../_shared/cors.ts"; @@ -26,7 +27,7 @@ function isUniqueViolation(error: { code?: string; message?: string } | null): b return error?.code === "23505" || Boolean(error?.message?.toLowerCase().includes("duplicate")); } -Deno.serve(async (req: Request) => { +export async function handleRequest(req: Request): Promise { const corsHeaders = getCorsHeaders(req.headers.get("origin")); if (req.method === "OPTIONS") { @@ -40,24 +41,14 @@ Deno.serve(async (req: Request) => { }); } - // Get user from token (optional - anonymous shares allowed) - let userId: string | null = null; - const authHeader = req.headers.get("authorization"); - if (authHeader?.startsWith("Bearer ")) { - try { - const token = authHeader.slice(7); - const { data: { user } } = await supabase.auth.getUser(token); - userId = user?.id || null; - } catch { - // Continue as anonymous - } - } - - if (!userId) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + // Creating a share link requires a signed-in user, who becomes its owner. + let userId: string; + try { + userId = (await requireUser(req)).user.id; + } catch (error) { + if (!(error instanceof HttpError)) throw error; + console.warn("[SHARE] Authentication rejected:", error.message); + return jsonError(error, corsHeaders); } try { @@ -141,4 +132,8 @@ Deno.serve(async (req: Request) => { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } -}); +} + +if (import.meta.main) { + Deno.serve(handleRequest); +} diff --git a/supabase/functions/voice/auth_test.ts b/supabase/functions/voice/auth_test.ts new file mode 100644 index 000000000..4b65150a3 --- /dev/null +++ b/supabase/functions/voice/auth_test.ts @@ -0,0 +1,28 @@ +import { + rejectedBearerCases, + runBearerCases, + TEST_ANON_KEY, + TEST_SUPABASE_URL, +} from "../_shared/testing/bearer-auth.ts"; + +for (const [key, value] of Object.entries({ + SUPABASE_URL: TEST_SUPABASE_URL, + SUPABASE_ANON_KEY: TEST_ANON_KEY, + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +const send = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/voice", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(authorization === undefined ? {} : { Authorization: authorization }), + }, + body: JSON.stringify({ feature: "sparring", text: "A sentence.", token: "voice-token" }), + }); + +Deno.test("voice refuses missing, empty, non-Bearer and rejected tokens with the shared auth errors", async () => { + await runBearerCases(handleRequest, send, rejectedBearerCases({ required: { error: "Authentication required" }, invalid: { error: "Invalid authentication token" } })); +}); diff --git a/supabase/functions/voice/index.ts b/supabase/functions/voice/index.ts index 6ec51c4e3..04a67f794 100644 --- a/supabase/functions/voice/index.ts +++ b/supabase/functions/voice/index.ts @@ -7,6 +7,7 @@ // the browser can fall back to its own voice and keep the round going. import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { HttpError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; import { ELEVENLABS_PROVIDER, normalizeElevenLabsUsage } from "../_shared/cost.ts"; import { @@ -110,11 +111,14 @@ export async function handleRequest(req: Request): Promise { if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: corsHeaders }); - const authHeader = req.headers.get("authorization"); - if (!authHeader?.startsWith("Bearer ")) return errorResponse(401, "Unauthorized", corsHeaders); - const { data: { user }, error: authError } = await supabase.auth.getUser(authHeader.slice(7)); - if (authError || !user) return errorResponse(401, "Invalid token", corsHeaders); - const userId = user.id; + let userId: string; + try { + userId = (await requireUser(req)).user.id; + } catch (error) { + if (!(error instanceof HttpError)) throw error; + console.warn("[VOICE] Authentication rejected:", error.message); + return errorResponse(error.status, error.message, corsHeaders); + } const request = await readRequest(req); if (!request) return errorResponse(400, "Invalid voice request", corsHeaders); diff --git a/supabase/functions/voice/index_test.ts b/supabase/functions/voice/index_test.ts index a3cdbf025..2d208b05a 100644 --- a/supabase/functions/voice/index_test.ts +++ b/supabase/functions/voice/index_test.ts @@ -1,5 +1,6 @@ for (const [key, value] of Object.entries({ SUPABASE_URL: "https://supabase.test", + SUPABASE_ANON_KEY: "test-anon-key", SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", })) Deno.env.set(key, value);