Skip to content

fix(edge): use the shared requireUser check in nine more functions - #90

Merged
Programcode merged 1 commit into
mainfrom
fix/edge-shared-auth
Oct 2, 2026
Merged

Programcode merged 1 commit into
mainfrom
fix/edge-shared-auth

Conversation

@Programcode

@Programcode Programcode commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

PR #88 moved ai onto the shared auth helpers in supabase/functions/_shared/auth.ts. This PR does the same for the remaining nine Edge Functions that still had their own Bearer parsing and auth.getUser call. It uses the same pattern: requireUser(req) inside try/catch, an HttpError is logged as a warning and returned as the 401, and anything else is rethrown.

What the old copies got wrong

  • ai-stream never checked the Bearer prefix. It did authHeader.replace("Bearer ", ""), so Authorization: Basic ... was sent to Auth as a token.
  • None of the nine trimmed the token. Authorization: Bearer cost an Auth round trip before failing.
  • The error strings didn't match: "Unauthorized", "Invalid token", "Missing or invalid Authorization header", raw Auth messages, or the shared strings.

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-id console.log lines 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 through mapAIError, so nothing visible changes.
  • create-checkout, create-portal: shared { error } body. getEdgeFunctionErrorMessage shows body.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 own errorResponse. The body shape { error, code? } doesn't change, and the client uses status and code, so nothing visible changes.
  • redeem-promo: keeps its { success: false, message } shape, with message now holding the shared string. Its RPC now runs on the user-scoped client that requireUser returns, which carries the same caller Authorization header and anon key as the local client it replaces. The client never read message on a 401 (it falls back to "Request failed (HTTP 401)"), so nothing visible changes.
  • export-gdoc: keeps { error: "Unauthorized", details }. The browser shows error and treats any 401 as "sign in again", so nothing visible changes. details now 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-gdoc and share used to turn an exception thrown inside the Auth lookup into a 401. It now propagates as a 500, the same as ai since #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-promo and share called Deno.serve at module level. They now export handleRequest and serve it under if (import.meta.main), the way ai-stream, import-source and voice already work in production, so tests can call them. supabase/config.toml and verify_jwt are unchanged.

Tests

  • New <function>/auth_test.ts for all nine, with a shared helper in supabase/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. For admin, create-checkout, create-portal, export-gdoc, redeem-promo and share there 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's created_by is that user, and the promo RPC carries the caller's token and the anon key.
  • Before the fix, the nine rejection tests failed and the six valid-token tests passed. ai-stream failed on Bearer with one Auth call where zero were expected. The others failed first on their old strings.
  • test:edge now allows STRIPE_SECRET_KEY, STRIPE_PRICE_ID and STRIPE_PRICE_LOOKUP_KEY, which create-checkout and create-portal read when loaded. Tests that import a migrated function set SUPABASE_ANON_KEY, which _shared/auth.ts requires when loaded.
  • src/test/security-hardening.test.ts checked share's source for the removed if (!userId). It now checks for userId = (await requireUser(req)).user.id and created_by: userId. share/auth_test.ts covers 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 on origin/main was 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 check on every changed function and test file passes. deno lint finds nothing new compared with main; the only new item is the jsr: import prefix in the test helper, which the existing tests also use.
  • verify-debatelab in fake mode, with doctor all 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, voice and billing: 4 passed each.
    • The functions log shows four real promo redemptions going through the new user-scoped client.
  • I sent curl requests 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.
  • A Codex review (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

  • Security
    • Protected account, billing, content-import/export, voice, and AI-streaming actions now consistently require valid sign-in credentials.
    • Requests with missing or invalid credentials receive clear authentication errors; admin actions remain restricted to administrators.
    • New share links are associated with the signed-in user who creates them.
  • Reliability
    • Expanded automated coverage for authentication across these actions, including valid, missing, and rejected credentials.

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
Copilot AI balanced review requested due to automatic review settings October 2, 2026 12:51
@vercel

vercel Bot commented Oct 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
debate-navigator Ready Ready Preview Oct 2, 2026 12:51pm UTC

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 84995e93-4bad-4385-a872-1a3529ea0607

📥 Commits

Reviewing files that changed from the base of the PR and between cf89554 and 9e8e7c8.

📒 Files selected for processing (25)
  • package.json
  • src/test/security-hardening.test.ts
  • supabase/functions/_shared/testing/bearer-auth.ts
  • supabase/functions/admin/auth_test.ts
  • supabase/functions/admin/index.ts
  • supabase/functions/ai-stream/auth_test.ts
  • supabase/functions/ai-stream/handler_test.ts
  • supabase/functions/ai-stream/index.ts
  • supabase/functions/ai-stream/index_test.ts
  • supabase/functions/create-checkout/auth_test.ts
  • supabase/functions/create-checkout/index.ts
  • supabase/functions/create-portal/auth_test.ts
  • supabase/functions/create-portal/index.ts
  • supabase/functions/export-gdoc/auth_test.ts
  • supabase/functions/export-gdoc/index.ts
  • supabase/functions/import-source/auth_test.ts
  • supabase/functions/import-source/index.ts
  • supabase/functions/import-source/index_test.ts
  • supabase/functions/redeem-promo/auth_test.ts
  • supabase/functions/redeem-promo/index.ts
  • supabase/functions/share/auth_test.ts
  • supabase/functions/share/index.ts
  • supabase/functions/voice/auth_test.ts
  • supabase/functions/voice/index.ts
  • supabase/functions/voice/index_test.ts

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Nine Supabase Edge Functions now use shared requireUser authentication. Shared bearer-auth utilities and endpoint tests cover rejected and accepted credentials. The share tests check that the authenticated user is recorded as the creator. The test:edge script now permits Stripe test environment variables.

Changes

Edge Function authentication

Layer / File(s) Summary
Shared bearer-auth test harness
supabase/functions/_shared/testing/bearer-auth.ts
Adds reusable bearer-auth cases and a runner that stubs Auth requests, checks responses and Auth-call counts, and restores fetch.
Content and media handler authentication
supabase/functions/{ai-stream,import-source,share,voice}/{index.ts,auth_test.ts,index_test.ts,handler_test.ts}, src/test/security-hardening.test.ts
These handlers use requireUser for authentication. The tests cover rejected credentials; share tests also check that the authenticated user ID is stored as created_by.
Admin, export, and billing handler authentication
supabase/functions/{admin,export-gdoc,create-checkout,create-portal,redeem-promo}/{index.ts,auth_test.ts}, package.json
These handlers use requireUser and expose request handlers for direct testing. Tests cover rejected credentials and endpoint-specific behavior for accepted credentials. test:edge permits the Stripe test variables.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Security Architecture Review

Security architecture risk: 🔵 Low · up to 9e8e7

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
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The common authentication control spans nine endpoint callers. Its most privileged inspected downstream path is admin's service-role database access, which remains behind authenticated identity and administrator authorization; promo redemption instead retains caller-scoped authority.

Trust Boundaries and Controls

  • observed — Attacker-supplied Authorization headers are parsed before token validation. A validated user identity, not a request-supplied user ID, drives admin role checks and share ownership. Expected authentication failures stop these inspected paths before privileged dispatch or persistence.
  • observed — User clients are constructed per authenticated request and carry the caller Authorization header. Redeem-promo continues to use that client for its RPC, preserving separation between identity validation authority and state-changing caller authority.

Resilience and Maintainability Implications

  • observed — The inspected state transitions retain their existing security invariants: share inserts retain authenticated ownership and bounded collision retries, while promo redemption retains caller-derived identity and the existing transactional RPC. Base/head comparison did not establish a new partial-failure, repetition, or concurrency weakness from the authentication handoff.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Note

🎁 Summarized by CodeRabbit Free

Your 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 @coderabbitai help to get the list of available commands.

@Programcode

Copy link
Copy Markdown
Owner Author

CI notes:

  • database-tests failed on the first run. The cause was e2e/full-stack/account-data.spec.ts running out of its 30 s test budget in addSource; the runs took 32 to 34 s. The page snapshot at the timeout shows the "Source title..." textbox rendered. That flow is a manual Library add with no Edge Function, and the same runner had also hit ECR pull rate limits during supabase start. The failed job passed on rerun. The same spec also passes 8/8 locally on both viewports at this head.
  • model-matrix is the known failure already on main. All 10 page snapshots show the "Start a debate with this motion?" dialog, and none of the 10 generate requests reached ai (its 2 log entries match the warm-up count of every other function). It's not caused by this PR and isn't fixed here.
  • The Codex review (gpt-6.1-sol, high) found nothing actionable. CodeRabbit's security review rated the risk low with no findings. Its one open note, that the import.meta.main entrypoints are unverified in production, will be checked by the post-deploy live probes.

@Programcode
Programcode merged commit d68e9c8 into main Oct 2, 2026
10 of 12 checks passed
@Programcode
Programcode deleted the fix/edge-shared-auth branch October 2, 2026 13:41

This branch was successfully deployed

1 active deployment
Preview — 9e8e7c8a Deployed Oct 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants