From 9e8e7c8a0668a511726fa14bf2d0fb5c83902742 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 2 Oct 2026 12:31:39 +0000 Subject: [PATCH] fix(edge): use the shared requireUser check in nine more functions admin, ai-stream, create-checkout, create-portal, export-gdoc, import-source, redeem-promo, share and voice each parsed the Bearer token and called auth.getUser with their own copy of the logic in _shared/auth.ts. The copies had drifted. ai-stream did not check the Bearer prefix, so "Basic ..." and "Bearer " both reached Auth. None of them trimmed the token, so an all-whitespace token cost an Auth round trip. Error strings varied between "Unauthorized", "Invalid token" and the shared ones. Each function now calls requireUser, as ai does since #88. A missing, empty or non-Bearer token gets 401 without an Auth call; a rejected token gets 401 after one. HttpError is logged as a warning and returned; anything else is rethrown. Two functions keep their own body shape because the browser reads it: redeem-promo answers { success: false, message } and export-gdoc answers { error: "Unauthorized", details }. redeem-promo now uses the user-scoped client from requireUser for its RPC. The others return the shared { error: "Authentication required" | "Invalid authentication token" }. admin, ai-stream and voice clients map 401 by status, so they are unchanged; checkout, portal and import-source show the new strings only when the session token is bad. share has no browser caller. The six functions that called Deno.serve at module level now export handleRequest and serve it under import.meta.main, like ai-stream, import-source and voice already did, so tests can call them. Each function gets auth_test.ts covering a missing header, "Bearer ", a non-Bearer scheme and a rejected token, plus a valid-token case for admin, checkout, portal, export-gdoc, redeem-promo and share that shows the same user reaches the handler. test:edge now allows the Stripe env names that create-checkout and create-portal read at load, and tests importing a migrated function set SUPABASE_ANON_KEY. The share source-text check in security-hardening.test.ts now pins requireUser instead of the removed `if (!userId)`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyWuBiDEMvVnjmguH9CwCZ --- package.json | 2 +- src/test/security-hardening.test.ts | 5 +- .../functions/_shared/testing/bearer-auth.ts | 91 +++++++++++++++++++ supabase/functions/admin/auth_test.ts | 42 +++++++++ supabase/functions/admin/index.ts | 38 +++----- supabase/functions/ai-stream/auth_test.ts | 29 ++++++ supabase/functions/ai-stream/handler_test.ts | 1 + supabase/functions/ai-stream/index.ts | 19 ++-- supabase/functions/ai-stream/index_test.ts | 1 + .../functions/create-checkout/auth_test.ts | 40 ++++++++ supabase/functions/create-checkout/index.ts | 33 +++---- supabase/functions/create-portal/auth_test.ts | 40 ++++++++ supabase/functions/create-portal/index.ts | 33 +++---- supabase/functions/export-gdoc/auth_test.ts | 44 +++++++++ supabase/functions/export-gdoc/index.ts | 56 ++++-------- supabase/functions/import-source/auth_test.ts | 28 ++++++ supabase/functions/import-source/index.ts | 19 ++-- .../functions/import-source/index_test.ts | 1 + supabase/functions/redeem-promo/auth_test.ts | 54 +++++++++++ supabase/functions/redeem-promo/index.ts | 44 ++++----- supabase/functions/share/auth_test.ts | 46 ++++++++++ supabase/functions/share/index.ts | 35 +++---- supabase/functions/voice/auth_test.ts | 28 ++++++ supabase/functions/voice/index.ts | 14 ++- supabase/functions/voice/index_test.ts | 1 + 25 files changed, 574 insertions(+), 170 deletions(-) create mode 100644 supabase/functions/_shared/testing/bearer-auth.ts create mode 100644 supabase/functions/admin/auth_test.ts create mode 100644 supabase/functions/ai-stream/auth_test.ts create mode 100644 supabase/functions/create-checkout/auth_test.ts create mode 100644 supabase/functions/create-portal/auth_test.ts create mode 100644 supabase/functions/export-gdoc/auth_test.ts create mode 100644 supabase/functions/import-source/auth_test.ts create mode 100644 supabase/functions/redeem-promo/auth_test.ts create mode 100644 supabase/functions/share/auth_test.ts create mode 100644 supabase/functions/voice/auth_test.ts 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);