Repository navigation
fix(edge): use the shared requireUser check in nine more functions - #90
Conversation
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyWuBiDEMvVnjmguH9CwCZ
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (25)
Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughNine Supabase Edge Functions now use shared ChangesEdge Function authentication
Estimated code review effort: 3 (Moderate) | ~30 minutes Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The inspected changes preserve administrator authorization, share ownership, and caller-scoped promo redemption. No introduced security weakness was established. Production startup behavior for the newly conditional entrypoints remains unverified. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing. Comment |
|
CI notes:
|
PR #88 moved
aionto the shared auth helpers insupabase/functions/_shared/auth.ts. This PR does the same for the remaining nine Edge Functions that still had their own Bearer parsing andauth.getUsercall. It uses the same pattern:requireUser(req)inside try/catch, anHttpErroris logged as a warning and returned as the 401, and anything else is rethrown.What the old copies got wrong
ai-streamnever checked theBearerprefix. It didauthHeader.replace("Bearer ", ""), soAuthorization: Basic ...was sent to Auth as a token.Authorization: Bearercost an Auth round trip before failing.After this PR, each function answers a missing, empty or non-Bearer token with 401 and makes no Auth call. A token Auth rejects gets 401 after one call. A valid token reaches the handler as the same user it did before.
Per function
admin: shared{ error }body. The token-length and user-idconsole.loglines are replaced by one warning when auth is rejected. The admin page maps 401 by status, so nothing visible changes.ai-stream: shared{ error }body. The strings were already the shared ones. What changes is that the prefix is checked and the token trimmed. The client maps 401 by status throughmapAIError, so nothing visible changes.create-checkout,create-portal: shared{ error }body.getEdgeFunctionErrorMessageshowsbody.error, so a bad session token now shows "Authentication required" or "Invalid authentication token" where it used to show "Unauthorized" or "Invalid token". The client only calls these with a session.import-source: same as checkout. The Library shows the new string only when the session token is bad.voice: same strings through its ownerrorResponse. The body shape{ error, code? }doesn't change, and the client uses status andcode, so nothing visible changes.redeem-promo: keeps its{ success: false, message }shape, withmessagenow holding the shared string. Its RPC now runs on the user-scoped client thatrequireUserreturns, which carries the same callerAuthorizationheader and anon key as the local client it replaces. The client never readmessageon a 401 (it falls back to "Request failed (HTTP 401)"), so nothing visible changes.export-gdoc: keeps{ error: "Unauthorized", details }. The browser showserrorand treats any 401 as "sign in again", so nothing visible changes.detailsnow holds the shared string rather than the raw Auth message. Its unused service-role client is removed.share: shared{ error }body. The comment said anonymous shares were allowed, but the code returned 401 without a user anyway, so the comment was wrong and is now corrected. No browser code calls this function.No function was left out. All nine require a signed-in user on every path, so none needed optional auth.
One behavior change outside the 401 cases:
export-gdocandshareused to turn an exception thrown inside the Auth lookup into a 401. It now propagates as a 500, the same asaisince #88. A missing or rejected token is not an exception, so it still gets the 401.Structure
admin,create-checkout,create-portal,export-gdoc,redeem-promoandsharecalledDeno.serveat module level. They now exporthandleRequestand serve it underif (import.meta.main), the wayai-stream,import-sourceandvoicealready work in production, so tests can call them.supabase/config.tomlandverify_jwtare unchanged.Tests
<function>/auth_test.tsfor all nine, with a shared helper insupabase/functions/_shared/testing/bearer-auth.ts. They check a missing header,Bearer,Basic ...and a rejected token: status, exact body and the number of Auth calls. Foradmin,create-checkout,create-portal,export-gdoc,redeem-promoandsharethere is also a valid-token case that shows the token's user reaching the handler: the admin role lookup is for that user id, the share row'screated_byis that user, and the promo RPC carries the caller's token and the anon key.Bearerwith one Auth call where zero were expected. The others failed first on their old strings.test:edgenow allowsSTRIPE_SECRET_KEY,STRIPE_PRICE_IDandSTRIPE_PRICE_LOOKUP_KEY, which create-checkout and create-portal read when loaded. Tests that import a migrated function setSUPABASE_ANON_KEY, which_shared/auth.tsrequires when loaded.src/test/security-hardening.test.tschecked share's source for the removedif (!userId). It now checks foruserId = (await requireUser(req)).user.idandcreated_by: userId.share/auth_test.tscovers the behavior.Verification
All of these ran locally at 9e8e7c8 on Node 22.23.2 and Deno 2.9.6. They are local results; the CI results are in the checks below.
npm run test:edge: 341 passed, 0 failed. The baseline onorigin/mainwas 326. Before the fix, 9 failed.npm test: 118 files, 1218 tests passed.npm run lint,npm run typecheck,npm run docs:check,npm run build: all pass.deno checkon every changed function and test file passes.deno lintfinds nothing new compared withmain; the only new item is thejsr:import prefix in the test helper, which the existing tests also use.doctorall ok:drive all: 60 passed (every recipe on desktop-chromium and mobile-chromium, including ai-tools, library, voice-rounds, billing and account).e2e/full-stack/admin,voiceandbilling: 4 passed each.curlrequests to each of the nine local functions with no header,Bearer,Basic ...and a bogus token, using the local anon key. All 36 returned 401 with the bodies listed above. The local stack serves functions with--no-verify-jwt, so these are the functions' own answers.gpt-6.1-sol, high effort) found nothing actionable.Not verified: Stripe Checkout and the portal UI, Google Docs export with a real Google token, ElevenLabs audio, and anything in staging or production before deploy.
Deploy
Merging deploys only the frontend, and this PR doesn't change the frontend. After merge, these nine functions need deploying, one at a time: admin, ai-stream, create-checkout, create-portal, export-gdoc, import-source, redeem-promo, share, voice. No migrations.
🤖 Generated with Claude Code
https://claude.ai/code/session_01HyWuBiDEMvVnjmguH9CwCZ
Summary by CodeRabbit