From 8f489a8078389664a181edd846527defc4b48e79 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 2 Oct 2026 11:00:03 +0000 Subject: [PATCH] fix(ai): use the shared requireUser auth check The ai Edge Function parsed the Bearer token and looked up the user with its own copy of the logic in _shared/auth.ts. The copy had drifted: it did not trim the token, sent an empty token to Auth, and returned different error strings from account, announcements and workspace-invites. ai now calls requireUser and returns its HttpError through jsonError. Missing or empty tokens get 401 "Authentication required" without an Auth round trip; rejected tokens get 401 "Invalid authentication token". The client maps 401 by status, so its behavior is unchanged. A new handler test covers the missing, empty and rejected cases. Tests that import ai/index.ts now set SUPABASE_ANON_KEY, which _shared/auth.ts requires at load. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HyWuBiDEMvVnjmguH9CwCZ --- supabase/functions/ai/debate-context.test.ts | 1 + .../functions/ai/generate-honesty_test.ts | 1 + .../functions/ai/handler-research.test.ts | 46 +++++++++++++++++++ supabase/functions/ai/index.ts | 27 ++++------- supabase/functions/ai/voice.test.ts | 1 + 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/supabase/functions/ai/debate-context.test.ts b/supabase/functions/ai/debate-context.test.ts index 081b4568d..db2804cf2 100644 --- a/supabase/functions/ai/debate-context.test.ts +++ b/supabase/functions/ai/debate-context.test.ts @@ -6,6 +6,7 @@ import { UNTRUSTED_CONTENT_RULES } from "../_shared/prompts.ts"; const environment = { SUPABASE_URL: "https://supabase.test", + SUPABASE_ANON_KEY: "test-anon-key", SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", SPARRING_INTEGRITY_SECRET: "test-sparring-secret", EXA_API_KEY: "test-exa-key", diff --git a/supabase/functions/ai/generate-honesty_test.ts b/supabase/functions/ai/generate-honesty_test.ts index 78e9bbf23..759aa374e 100644 --- a/supabase/functions/ai/generate-honesty_test.ts +++ b/supabase/functions/ai/generate-honesty_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", EXA_API_KEY: "test-exa-key", OPENROUTER_API_KEY: "test-openrouter-key", diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index 44e9821a7..97110a756 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -7,6 +7,7 @@ function jsonResponse(body: unknown, status = 200): Response { const environment = { SUPABASE_URL: "https://supabase.test", + SUPABASE_ANON_KEY: "test-anon-key", SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", SPARRING_INTEGRITY_SECRET: "test-sparring-secret", EXA_API_KEY: "test-exa-key", @@ -123,6 +124,51 @@ Deno.test("AI handlers reject malformed controls before quota or provider work", } }); +Deno.test("AI handler rejects missing, empty, and invalid bearer tokens with the shared auth errors", async () => { + const originalFetch = globalThis.fetch; + const calls: string[] = []; + + globalThis.fetch = (async (input: Request | URL | string) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + calls.push(url.href); + if (url.origin === "https://supabase.test" && url.pathname === "/auth/v1/user") { + return jsonResponse({ code: 401, msg: "invalid JWT" }, 401); + } + throw new Error(`Rejected auth reached unexpected work: ${url}`); + }) as typeof fetch; + + const send = (authorization?: string) => + handleRequest(new Request("http://127.0.0.1/functions/v1/ai", { + 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" }, + }), + })); + + try { + for (const [label, authorization, expectedError, expectedCalls] of [ + ["missing header", undefined, "Authentication required", 0], + ["empty bearer token", "Bearer ", "Authentication required", 0], + ["rejected token", "Bearer expired-token", "Invalid authentication token", 1], + ] as const) { + calls.length = 0; + const response = await send(authorization); + if (response.status !== 401) throw new Error(`${label} returned ${response.status}`); + const body = await response.json() as { error?: unknown }; + if (body.error !== expectedError) throw new Error(`${label} returned error ${JSON.stringify(body.error)}`); + if (calls.length !== expectedCalls) throw new Error(`${label} made remote calls: ${calls.join(", ")}`); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + Deno.test("factoids handler carries Exa filters and consulted-source metadata across the request boundary", async () => { const originalFetch = globalThis.fetch; const exaSearchBodies: Array> = []; diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index baa07c476..af3ec5264 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -46,6 +46,7 @@ import { type ResearchContext, type ResearchDeps, } from "../_shared/research.ts"; +import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; import { checkRateLimit, @@ -274,26 +275,14 @@ export async function handleRequest(req: Request): Promise { // Require a valid user session for all AI requests. // The frontend already enforces auth; this protects the endpoint even when // gateway JWT verification is disabled. - const authHeader = req.headers.get("authorization"); - if (!authHeader?.startsWith("Bearer ")) { - log(requestId, "WARN", "Missing authorization header"); - 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) { - log(requestId, "WARN", "Auth token validation failed", { error: authError?.message }); - return new Response(JSON.stringify({ error: "Invalid token" }), { - status: 401, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); + let userId: string; + try { + userId = (await requireUser(req)).user.id; + } catch (error) { + if (!(error instanceof HttpError)) throw error; + log(requestId, "WARN", "Authentication rejected", { error: error.message }); + return jsonError(error, corsHeaders); } - - const userId: string = user.id; log(requestId, "INFO", "User authenticated", { userId }); if (debateContextRequest) { diff --git a/supabase/functions/ai/voice.test.ts b/supabase/functions/ai/voice.test.ts index e6c5ef6b3..af631f2db 100644 --- a/supabase/functions/ai/voice.test.ts +++ b/supabase/functions/ai/voice.test.ts @@ -4,6 +4,7 @@ const environment = { SUPABASE_URL: "https://supabase.test", + SUPABASE_ANON_KEY: "test-anon-key", SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", SPARRING_INTEGRITY_SECRET: "test-sparring-secret", OPENROUTER_API_KEY: "test-openrouter-key",