Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions docs/multi-user-auth-setup.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
# Multi-user auth — Supabase configuration checklist (you apply)

The **code** for multi-user (persistent cookie sessions, magic link + password +
SSO, per-user isolation) lands via the `claude/multiuser-auth` branch. The
**live Supabase configuration** below is done by you in the dashboard / provider
consoles — Claude does not change the live Auth config. Target project:
`Clinical KB Database` (`sjrfecxgysukkwxsowpy`).

> **Order matters:** do **not** enable open signup on live until the fail-closed
> owner-scoping hardening on this branch has merged (the DB owner-RLS + private
> storage backstop is already in place — see §7). Validate the whole flow in a
> **staging** project first.

## 1. Auth → Providers

- **Email**: enable **Confirm email** (verifies ownership; blocks throwaway
signups). Enable **Email OTP** (magic link — already used) **and** **Password**.
- **Google**: create an OAuth client in Google Cloud Console → add the Supabase
callback `https://sjrfecxgysukkwxsowpy.supabase.co/auth/v1/callback` as an
authorized redirect URI → paste client ID/secret into Supabase → enable.
- **Azure (Microsoft)**: register an app in Azure AD (Entra ID) with the same
Supabase callback as a redirect URI → paste client ID/secret + tenant →
enable the **Azure** provider.

## 2. Auth → Sign in / Providers → "Allow new users to sign up"

- Turn **ON** (open public signup, per decision). Each new account starts as an
empty private silo — a new user cannot see anyone else's data.

## 3. Auth → URL Configuration

- **Site URL**: the production origin (e.g. `https://app.example.com`).
- **Redirect URLs** (allowlist): add the app's callback for every environment:
- `https://app.example.com/auth/callback`
- `http://localhost:<port>/auth/callback` (local dev)
- the app routes magic link, OAuth, and confirmation returns through
`/auth/callback` (see `src/app/auth/callback/route.ts`).

## 4. Auth → SMTP (production email)

- Configure **custom SMTP** (Resend / SendGrid / SES / Postmark). The built-in
Supabase email is dev-only (~a few/hour) and will bottleneck magic-link +
confirmation mail for real users.

## 5. Auth → Attack protection (recommended for open signup)

- Enable **CAPTCHA** (hCaptcha or Cloudflare Turnstile) to stop bot signups.
- Keep the default Auth **rate limits**.
- **Cost note:** every signed-in user can drive OpenAI / RAG spend — budget for
it and consider per-owner rate limits (the app already has `consumeApiRateLimit`
buckets keyed by owner).

## 6. App environment variables

Already used by the app; ensure they are set per environment. Concrete values
for **this** project (retrieved read-only from the live project 2026-07-03):

- `NEXT_PUBLIC_SUPABASE_URL` = `https://sjrfecxgysukkwxsowpy.supabase.co`
- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` = `sb_publishable_TgAfWIQDozYC_reOI-d5cw_FLYPnqOa`
(modern publishable key — public by design, safe in the browser; a legacy anon
JWT is also still active for compatibility)
- `SUPABASE_SERVICE_ROLE_KEY` (server-only; never exposed to the client — not
reproduced here; copy it from the Supabase dashboard → Project Settings → API)

The **Supabase OAuth callback** to authorize in the Google Cloud / Azure AD app
registrations (§1) is `https://sjrfecxgysukkwxsowpy.supabase.co/auth/v1/callback`.
OAuth client secrets live in **Supabase**, not in app env.

## 7. Database RLS + storage — already in place (verified against live 2026-07-03)

The DB-level per-user backstop the plan anticipated **already exists on the live
project**, so no broad RLS migration is required:

- Every owner-scoped **user-data** table (documents + children, `rag_queries`,
`rag_query_misses`, `rag_retrieval_logs`, `import_batches`, `rag_aliases`,
`storage_cleanup_jobs`, `document_*`) has RLS enabled **and** an `authenticated`
owner-read policy: `owner_id = (select auth.uid())`.
- Registry tables (`clinical_registry_records`, `_sources`) and internal tables
(`api_rate_limits`, `audit_logs`, `rag_response_cache`) are RLS-enabled and
**service-role-only** (fully server-mediated — intentional).
- Both storage buckets (`clinical-documents`, `clinical-images`) are **private**;
file access is via server-minted signed URLs after an owner check. No direct
client storage access is enabled (so no per-user folder policy is needed unless
client-direct storage reads are ever added).

Combined with the app-layer **fail-closed owner scoping** shipped on this branch,
per-user isolation is enforced at both layers.

**Two residual, low-priority items (out of scope for multi-user, no action needed
to launch):**

- `rag_visual_eval_cases` (an internal eval table) has RLS **disabled**, but it
has **no anon/authenticated grant** so it is effectively service-role-only. It
is also **not in `supabase/schema.sql`** (untracked live-only drift) — fixing it
properly means codifying the table first, a separate schema-hygiene task.
- Registry tables are service-role-only by design; add `authenticated` owner-read
policies only if you later introduce client-side registry reads.

## Verification (staging, after the above)

1. Sign up with **email + password** → receive + click the confirmation link →
land signed in.
2. **Magic link** → email link → signed in.
3. **Google** and **Microsoft** SSO → signed in.
4. **Hard-refresh** the page → still signed in (persistent cookie session).
5. **Isolation:** sign in as user A, upload a document, sign out; sign in as
user B → B sees none of A's documents, registry, or search results.
1 change: 1 addition & 0 deletions docs/site-map.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check`
- `/api/search/interaction` - Search interaction telemetry. Source: `src/app/api/search/interaction/route.ts`.
- `/api/setup-status` - Setup status. Source: `src/app/api/setup-status/route.ts`.
- `/api/upload` - Upload endpoint. Source: `src/app/api/upload/route.ts`.
- `/auth/callback` - Route discovered from app directory Source: `src/app/auth/callback/route.ts`.

## Redirects

Expand Down
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,7 @@
},
"dependencies": {
"@next/env": "16.2.9",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.108.2",
"exceljs": "^4.4.0",
"jszip": "^3.10.1",
Expand Down
38 changes: 38 additions & 0 deletions src/app/auth/callback/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";

import { createSupabaseServerClient } from "@/lib/supabase/server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// Handles the PKCE code return for OAuth (Google/Microsoft), email-confirmation,
// and magic-link sign-in. Exchanges `?code=` for a session, writing the session
// cookies via the cookie-aware server client, then redirects into the app.
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const errorDescription = searchParams.get("error_description") ?? searchParams.get("error");
// Only honour same-origin relative redirects to avoid an open-redirect.
const rawNext = searchParams.get("next") ?? "/";
const next = rawNext.startsWith("/") && !rawNext.startsWith("//") ? rawNext : "/";

const failure = (reason: string) => NextResponse.redirect(`${origin}/?auth_error=${encodeURIComponent(reason)}`);

if (errorDescription) {
return failure(errorDescription);
}
if (!code) {
return failure("missing_auth_code");
}

const supabase = await createSupabaseServerClient();
if (!supabase) {
return failure("auth_unconfigured");
}

const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
return failure(error.message);
Comment on lines +33 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Support email links opened without the PKCE verifier cookie

For magic-link or signup-confirmation emails opened in a different browser/profile/device than the one that requested the email, this code-only callback has no sb-...-code-verifier cookie, so exchangeCodeForSession(code) fails and the user is redirected back with an auth error. The previous hash-token flow did not require same-browser state; to keep email auth reliable, add a token-hash/verifyOtp confirmation path or make the configured email templates explicitly produce links this route can verify without the verifier cookie.

Useful? React with 👍 / 👎.

}
return NextResponse.redirect(`${origin}${next}`);
}
Loading
Loading