diff --git a/docs/multi-user-auth-setup.md b/docs/multi-user-auth-setup.md new file mode 100644 index 0000000000..f413eb0158 --- /dev/null +++ b/docs/multi-user-auth-setup.md @@ -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:/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. diff --git a/docs/site-map.md b/docs/site-map.md index ad1cd4c3cc..359c5fd592 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -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 diff --git a/package-lock.json b/package-lock.json index fbc7fff719..f66e893160 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "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", @@ -2335,6 +2336,18 @@ "node": ">=20.0.0" } }, + "node_modules/@supabase/ssr": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.0.tgz", + "integrity": "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.108.0" + } + }, "node_modules/@supabase/storage-js": { "version": "2.108.2", "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz", @@ -4344,6 +4357,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", diff --git a/package.json b/package.json index 8d39891185..2a7dfbcc23 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts new file mode 100644 index 0000000000..b562e12d07 --- /dev/null +++ b/src/app/auth/callback/route.ts @@ -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); + } + return NextResponse.redirect(`${origin}${next}`); +} diff --git a/src/components/clinical-dashboard/auth-panel.tsx b/src/components/clinical-dashboard/auth-panel.tsx index c78fc806d3..0d8cfac350 100644 --- a/src/components/clinical-dashboard/auth-panel.tsx +++ b/src/components/clinical-dashboard/auth-panel.tsx @@ -1,9 +1,9 @@ "use client"; import { type FormEvent, useState, useSyncExternalStore } from "react"; -import { Loader2, LogIn, LogOut, Mail, ShieldAlert } from "lucide-react"; +import { KeyRound, Loader2, LogIn, LogOut, Mail, ShieldAlert } from "lucide-react"; -import { AUTH_EMAIL_STORAGE_KEY, useAuthSession } from "@/lib/supabase/client"; +import { AUTH_EMAIL_STORAGE_KEY, type OAuthProvider, useAuthSession } from "@/lib/supabase/client"; import { cn, fieldControlWithIcon, @@ -43,18 +43,55 @@ function subscribeAuthEmail(onStoreChange: () => void) { }; } +type AuthMode = "signin" | "signup"; +type AuthMethod = "magic" | "password"; + +const providerLabels: Record = { google: "Google", azure: "Microsoft" }; + +function segmentClass(active: boolean) { + return cn( + "min-h-9 flex-1 rounded-md px-2.5 text-xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", + active + ? "bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)]" + : "text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]", + ); +} + export function AuthPanel() { - const { status, error, isConfigured, signInWithEmail, signOut, session } = useAuthSession(); + const { + status, + error, + isConfigured, + signInWithEmail, + signInWithPassword, + signUpWithPassword, + signInWithOAuth, + signOut, + session, + } = useAuthSession(); const savedEmail = useSyncExternalStore(subscribeAuthEmail, getAuthEmailSnapshot, getServerAuthEmailSnapshot); const [draftEmail, setDraftEmail] = useState(null); + const [password, setPassword] = useState(""); + const [mode, setMode] = useState("signin"); + const [method, setMethod] = useState("magic"); const email = draftEmail ?? savedEmail; const busy = status === "loading"; const isExpired = status === "expired"; async function submit(event: FormEvent) { event.preventDefault(); - if (!email.trim()) return; - await signInWithEmail(email.trim()); + const trimmed = email.trim(); + if (!trimmed) return; + if (method === "magic") { + await signInWithEmail(trimmed); + return; + } + if (!password) return; + if (mode === "signup") { + await signUpWithPassword(trimmed, password); + } else { + await signInWithPassword(trimmed, password); + } } if (!isConfigured) { @@ -90,13 +127,20 @@ export function AuthPanel() { ); } + const submitLabel = method === "magic" ? "Send sign-in link" : mode === "signup" ? "Create account" : "Sign in"; + const SubmitIcon = method === "magic" ? Mail : KeyRound; + return ( -
+

- {isExpired ? "Sign-in link expired" : "Sign in for private documents"} + {isExpired + ? "Sign-in link expired" + : mode === "signup" + ? "Create your account" + : "Sign in for private documents"}

{isExpired @@ -105,23 +149,91 @@ export function AuthPanel() {

- - + + {/* Sign in / Create account */} +
+ + +
+ + {/* Magic link / Password */} +
+ + +
+ + + + + {method === "password" && ( + + )} + + + + +
+ + or continue with + +
+ +
+ {(Object.keys(providerLabels) as OAuthProvider[]).map((provider) => ( + + ))} +
+ {error && (

)} - +

); } diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts index 63aad53406..6883a2d119 100644 --- a/src/lib/deep-memory.ts +++ b/src/lib/deep-memory.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { buildClinicalTextSearchQuery, classifyRagQuery, normalizedClinicalSearchTokens } from "@/lib/clinical-search"; import { logger } from "@/lib/logger"; +import { requireOwnerScope } from "@/lib/owner-scope"; import { buildDocumentIndexUnitInputs, countDocumentIndexUnitsByType, @@ -822,7 +823,7 @@ export async function fetchMemoryCardsForQuery(args: { match_count: args.matchCount ?? 32, min_similarity: 0.1, document_filters: args.documentIds?.length ? args.documentIds : null, - owner_filter: args.ownerId ?? null, + owner_filter: requireOwnerScope(args.ownerId) ?? null, }); if (error) { diff --git a/src/lib/document-enrichment.ts b/src/lib/document-enrichment.ts index d506046b66..2f33fbabbe 100644 --- a/src/lib/document-enrichment.ts +++ b/src/lib/document-enrichment.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { classifyDocumentOrganization } from "@/lib/document-organization"; import { env } from "@/lib/env"; +import { requireOwnerScope } from "@/lib/owner-scope"; import { isClinicalImageEvidence } from "@/lib/image-filtering"; import { buildCoveragePromptNote, @@ -712,7 +713,7 @@ export async function fetchRelatedDocumentMetadata(args: { }) { const { data: rpcData, error: rpcError } = await args.supabase.rpc("get_related_document_metadata", { document_ids: args.documentIds, - owner_filter: args.ownerId ?? null, + owner_filter: requireOwnerScope(args.ownerId) ?? null, }); if (!rpcError) { diff --git a/src/lib/owner-scope.ts b/src/lib/owner-scope.ts new file mode 100644 index 0000000000..4bec91eca5 --- /dev/null +++ b/src/lib/owner-scope.ts @@ -0,0 +1,23 @@ +import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; + +/** + * Fail-closed guard for multi-tenant owner scoping. + * + * The hybrid retrieval RPCs treat a null `owner_filter` as "all owners" + * (fail-open), so calling them without an ownerId would silently return another + * tenant's data. Call this at every retrieval RPC boundary that filters by + * owner. In a real (multi-user) deployment a missing ownerId throws instead of + * leaking; in demo / local-no-auth / the test runner there is no multi-tenancy, + * so it stays permissive (returns undefined, preserving the previous behaviour). + * + * See the owner-scoping isolation audit. + */ +export function requireOwnerScope(ownerId: string | null | undefined): string | undefined { + if (ownerId) return ownerId; + if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") { + return undefined; + } + throw new Error( + "Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.", + ); +} diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 6764accd2a..c5b77dbcf8 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1,4 +1,5 @@ import { createAdminClient } from "@/lib/supabase/admin"; +import { requireOwnerScope } from "@/lib/owner-scope"; import type { Database, Json } from "@/lib/supabase/database.types"; import { embedTextWithTelemetry, @@ -2174,7 +2175,7 @@ async function searchTextChunkCandidates(args: { query_text: queryText, match_count: matchCount, document_filters: args.documentIds ?? undefined, - owner_filter: args.ownerId ?? undefined, + owner_filter: requireOwnerScope(args.ownerId), }); return error || !data?.length ? ([] as SearchResult[]) : (data as SearchResult[]); }; @@ -2327,7 +2328,7 @@ async function fetchBestDocumentLookupChunks(args: { query_text: args.query, document_filters: args.documentIds ?? undefined, match_count: Math.max(args.limit * 3, 24), - owner_filter: args.ownerId ?? undefined, + owner_filter: requireOwnerScope(args.ownerId), }); if (!rpcError && rpcChunks?.length) { const ranked = (rpcChunks as DocumentLookupChunkRow[]) @@ -2431,7 +2432,7 @@ async function searchDocumentLookupFastPath(args: { const { data, error } = await args.supabase.rpc("match_documents_for_query", { query_text: variant, match_count: index === 0 ? 12 : 8, - owner_filter: args.ownerId ?? undefined, + owner_filter: requireOwnerScope(args.ownerId), }); if (error || !data?.length) return [] as DocumentLookupRow[]; return data as DocumentLookupRow[]; @@ -2785,7 +2786,7 @@ async function searchTableFactCandidates(args: { query_text: variant, match_count: index === 0 ? args.matchCount : Math.min(args.matchCount, 24), document_filters: args.documentIds ?? undefined, - owner_filter: args.ownerId ?? undefined, + owner_filter: requireOwnerScope(args.ownerId), }); if (error || !data?.length) return [] as TableFactRpcRow[]; return data as TableFactRpcRow[]; @@ -2833,7 +2834,7 @@ async function searchEmbeddingFieldCandidates(args: { match_count: args.matchCount, min_similarity: 0.12, document_filters: args.documentIds ?? undefined, - owner_filter: args.ownerId ?? undefined, + owner_filter: requireOwnerScope(args.ownerId), }); if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -2883,7 +2884,7 @@ async function searchIndexUnitCandidates(args: { match_count: args.matchCount, min_similarity: 0.1, document_filters: args.documentIds ?? undefined, - owner_filter: args.ownerId ?? undefined, + owner_filter: requireOwnerScope(args.ownerId), }); if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -5784,7 +5785,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { match_count: candidateCount, min_similarity: minSimilarity, document_filters: documentFilterList ?? undefined, - owner_filter: args.ownerId ?? undefined, + owner_filter: requireOwnerScope(args.ownerId), }); return { data, error, latencyMs: Date.now() - startedAt }; })(), @@ -5876,7 +5877,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { match_count: candidateCount, min_similarity: minSimilarity, document_filter: documentFilter ?? undefined, - owner_filter: args.ownerId ?? undefined, + owner_filter: requireOwnerScope(args.ownerId), }); if (error) throw new Error(error.message); diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index e408be34a6..72d336f615 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -1,5 +1,7 @@ +import { createServerClient, parseCookieHeader } from "@supabase/ssr"; import { NextResponse } from "next/server"; import { createAdminClient } from "@/lib/supabase/admin"; +import { env } from "@/lib/env"; type AdminClient = ReturnType; @@ -67,17 +69,54 @@ export function unauthorizedResponse(error?: AuthenticationError) { return NextResponse.json({ error: "Authentication required." }, { status: 401 }); } +/** + * Resolve the user from the `@supabase/ssr` cookie session. The + * `sb--auth-token` cookie it writes is base64-encoded (and chunked when + * large), which `extractSessionAccessToken`'s plain-JSON parser cannot read, so + * this uses the ssr server client to decode + validate it. Returns null when + * the public env is absent or no `sb-` cookie is present. + */ +async function getUserFromRequestCookies(request: Request): Promise { + const url = env.NEXT_PUBLIC_SUPABASE_URL; + const key = env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; + const cookieHeader = request.headers.get("cookie"); + if (!url || !key || !cookieHeader || !cookieHeader.includes("sb-")) { + return null; + } + + const client = createServerClient(url, key, { + cookies: { + getAll() { + return parseCookieHeader(cookieHeader).map(({ name, value }) => ({ name, value: value ?? "" })); + }, + setAll() { + // Read-only during the route-handler auth check; the proxy refreshes cookies. + }, + }, + }); + + const { data, error } = await client.auth.getUser(); + if (error || !data.user?.id) { + return null; + } + return { id: data.user.id }; +} + export async function requireAuthenticatedUser(request: Request, supabase: AdminClient): Promise { + // 1. Bearer token / legacy cookie (programmatic callers + current clients). const token = extractSessionAccessToken(request); - if (!token) { - throw new AuthenticationError(); + if (token) { + const { data, error } = await supabase.auth.getUser(token); + if (!error && data.user?.id) { + return { id: data.user.id }; + } } - const { data, error } = await supabase.auth.getUser(token); - const userId = data.user?.id; - if (error || !userId) { - throw new AuthenticationError(); + // 2. @supabase/ssr cookie session (persistent cookie logins). + const cookieUser = await getUserFromRequestCookies(request); + if (cookieUser) { + return cookieUser; } - return { id: userId }; + throw new AuthenticationError(); } diff --git a/src/lib/supabase/client.tsx b/src/lib/supabase/client.tsx index aa20e8e10f..859ff6b928 100644 --- a/src/lib/supabase/client.tsx +++ b/src/lib/supabase/client.tsx @@ -1,10 +1,12 @@ "use client"; -import { createClient, type Session, type SupabaseClient } from "@supabase/supabase-js"; +import { createBrowserClient } from "@supabase/ssr"; +import { type Session, type SupabaseClient } from "@supabase/supabase-js"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; type AuthStatus = "unconfigured" | "loading" | "signed_out" | "authenticated" | "expired" | "error"; +export type OAuthProvider = "google" | "azure"; type AuthContextValue = { client: SupabaseClient | null; @@ -14,11 +16,15 @@ type AuthContextValue = { isConfigured: boolean; authorizationHeader: Record; signInWithEmail: (email: string) => Promise; + signInWithPassword: (email: string, password: string) => Promise; + signUpWithPassword: (email: string, password: string) => Promise; + signInWithOAuth: (provider: OAuthProvider) => Promise; signOut: () => Promise; markSessionExpired: () => void; }; export const AUTH_EMAIL_STORAGE_KEY = "clinical.dashboard.lastAuthEmail"; +const AUTH_CALLBACK_PATH = "/auth/callback"; const AuthContext = createContext(null); let browserSupabaseClient: SupabaseClient | null | undefined; @@ -55,13 +61,10 @@ function createBrowserSupabaseClient() { } browserSupabaseClientConfig = configKey; - browserSupabaseClient = createClient(url, publishableKey, { - auth: { - persistSession: false, - autoRefreshToken: true, - detectSessionInUrl: true, - }, - }); + // @supabase/ssr browser client persists the session in cookies shared with the + // server (proxy + route handlers), so logins survive refreshes and the API can + // read the session. PKCE code flow returns via /auth/callback. + browserSupabaseClient = createBrowserClient(url, publishableKey); return browserSupabaseClient; } @@ -70,19 +73,21 @@ export function authorizationHeadersForAccessToken(accessToken: string | null | return { authorization: `Bearer ${accessToken}` }; } -function clearLocationHash() { - if (typeof window === "undefined") return; - if (!window.location.hash) return; - window.history.replaceState({}, "", `${window.location.pathname}${window.location.search}`); +function authCallbackRedirect() { + if (typeof window === "undefined") return undefined; + return `${window.location.origin}${AUTH_CALLBACK_PATH}`; } -function isExpiredOtpError(errorCode: string | null, message: string) { - const normalizedMessage = message.toLowerCase(); - return ( - errorCode === "otp_expired" || - normalizedMessage.includes("expired") || - normalizedMessage.includes("invalid or has expired") - ); +/** Read and clear a `?auth_error=` param left by the /auth/callback route. */ +function consumeAuthErrorParam(): string | null { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + const authError = params.get("auth_error"); + if (!authError) return null; + params.delete("auth_error"); + const query = params.toString(); + window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`); + return authError; } export function AuthProvider({ children }: { children: ReactNode }) { @@ -93,83 +98,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { if (!client) return () => undefined; - let active = true; - const initializeSession = async () => { - if (typeof window !== "undefined") { - const hash = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : window.location.hash; - const callbackParams = new URLSearchParams(hash); - const hasCallbackParams = - callbackParams.size > 0 && - (callbackParams.has("access_token") || - callbackParams.has("refresh_token") || - callbackParams.has("type") || - callbackParams.has("error") || - callbackParams.has("error_code") || - callbackParams.has("code")); - - if (hasCallbackParams) { - const hasCallbackError = callbackParams.has("error") || callbackParams.has("error_code"); - if (hasCallbackError) { - const errorCode = callbackParams.get("error_code"); - const rawDescription = callbackParams.get("error_description"); - const message = rawDescription - ? decodeURIComponent(rawDescription.replace(/\+/g, " ")) - : "Sign-in verification failed."; - const expired = isExpiredOtpError(errorCode, message); - setSession(null); - setStatus(expired ? "expired" : "error"); - setError(expired ? "This sign-in link is invalid or has expired. Send a new one." : message); - clearLocationHash(); - return; - } - - type AuthCallbackResult = { - data?: { - session?: Session | null; - }; - error?: { message?: string } | null; - }; - - const getSessionFromUrl = ( - client.auth as { - getSessionFromUrl?: () => Promise; - } - ).getSessionFromUrl; - const callbackResult = getSessionFromUrl - ? await getSessionFromUrl() - : await client.auth.setSession({ - access_token: decodeURIComponent(callbackParams.get("access_token") ?? ""), - refresh_token: decodeURIComponent(callbackParams.get("refresh_token") ?? ""), - }); - if (!active) return; - clearLocationHash(); - - if (!callbackResult || callbackResult.error) { - const message = callbackResult?.error?.message ?? "Sign-in verification failed."; - const expired = isExpiredOtpError(callbackParams.get("error_code"), message); - setSession(null); - setStatus(expired ? "expired" : "error"); - setError(expired ? "This sign-in link is invalid or has expired. Send a new one." : message); - return; - } - - const callbackSession = callbackResult?.data?.session; - if (callbackSession) { - setSession(callbackSession); - setStatus("authenticated"); - setError(null); - return; - } - - setSession(null); - setStatus("signed_out"); - setError("Sign-in verification did not return a session."); - return; - } - } + // Clear the URL param synchronously (no React state here — that would trip + // react-hooks/set-state-in-effect); surface it after the async load below. + const callbackError = consumeAuthErrorParam(); + const initializeSession = async () => { try { const { data, error: sessionError } = await client.auth.getSession(); if (!active) return; @@ -180,7 +115,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { } setSession(data.session); setStatus(data.session ? "authenticated" : "signed_out"); - setError(null); + if (data.session) { + setError(null); + } else if (callbackError) { + setError(decodeURIComponent(callbackError)); + } } catch { if (!active) return; setStatus("error"); @@ -195,7 +134,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } = client.auth.onAuthStateChange((_event, nextSession) => { setSession(nextSession); setStatus(nextSession ? "authenticated" : "signed_out"); - setError(null); + if (nextSession) setError(null); }); return () => { @@ -204,33 +143,92 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; }, [client]); + const requireClient = useCallback(() => { + if (client) return client; + setStatus("unconfigured"); + setError("Supabase browser authentication is not configured."); + return null; + }, [client]); + const signInWithEmail = useCallback( async (email: string) => { - if (!client) { - setStatus("unconfigured"); - setError("Supabase browser authentication is not configured."); - return; - } - + const active = requireClient(); + if (!active) return; setStatus("loading"); setError(null); - const { error: signInError } = await client.auth.signInWithOtp({ + const { error: signInError } = await active.auth.signInWithOtp({ email, - options: { - emailRedirectTo: typeof window === "undefined" ? undefined : window.location.origin, - }, + options: { emailRedirectTo: authCallbackRedirect() }, }); - if (signInError) { setStatus("error"); setError("Sign-in email could not be sent."); return; } - setStatus("signed_out"); setError("Check your email for the sign-in link."); }, - [client], + [requireClient], + ); + + const signInWithPassword = useCallback( + async (email: string, password: string) => { + const active = requireClient(); + if (!active) return; + setStatus("loading"); + setError(null); + const { error: signInError } = await active.auth.signInWithPassword({ email, password }); + if (signInError) { + setStatus("error"); + setError(signInError.message); + } + // onAuthStateChange flips status to "authenticated" on success. + }, + [requireClient], + ); + + const signUpWithPassword = useCallback( + async (email: string, password: string) => { + const active = requireClient(); + if (!active) return; + setStatus("loading"); + setError(null); + const { data, error: signUpError } = await active.auth.signUp({ + email, + password, + options: { emailRedirectTo: authCallbackRedirect() }, + }); + if (signUpError) { + setStatus("error"); + setError(signUpError.message); + return; + } + // With "Confirm email" ON, no session is returned until confirmation. + if (!data.session) { + setStatus("signed_out"); + setError("Check your email to confirm your account, then sign in."); + } + }, + [requireClient], + ); + + const signInWithOAuth = useCallback( + async (provider: OAuthProvider) => { + const active = requireClient(); + if (!active) return; + setStatus("loading"); + setError(null); + const { error: oauthError } = await active.auth.signInWithOAuth({ + provider, + options: { redirectTo: authCallbackRedirect() }, + }); + if (oauthError) { + setStatus("error"); + setError(oauthError.message); + } + // On success the browser is redirected to the provider. + }, + [requireClient], ); const signOut = useCallback(async () => { @@ -258,6 +256,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { isConfigured: Boolean(client), authorizationHeader, signInWithEmail, + signInWithPassword, + signUpWithPassword, + signInWithOAuth, signOut, markSessionExpired, }; diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts new file mode 100644 index 0000000000..1abd6227bd --- /dev/null +++ b/src/lib/supabase/server.ts @@ -0,0 +1,50 @@ +import "server-only"; + +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +import { env } from "@/lib/env"; +import type { Database } from "@/lib/supabase/database.types"; + +/** Public (browser/user-context) Supabase config, or null when the public env + * is not configured (demo / local-no-auth). Distinct from the service-role + * admin client in `admin.ts` — this one carries the user's session and is + * subject to RLS. */ +export function publicSupabaseConfig(): { url: string; key: string } | null { + const url = env.NEXT_PUBLIC_SUPABASE_URL; + const key = env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; + if (!url || !key) return null; + return { url, key }; +} + +/** + * Cookie-aware Supabase client for Server Components and Route Handlers. Reads + * (and, where the context allows, refreshes) the user's `@supabase/ssr` session + * cookies via `next/headers`. Returns null when the public Supabase env is + * absent so callers can fall back to demo behaviour. RLS applies to this client + * (unlike the service-role admin client). + */ +export async function createSupabaseServerClient() { + const config = publicSupabaseConfig(); + if (!config) return null; + + const cookieStore = await cookies(); + return createServerClient(config.url, config.key, { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + for (const { name, value, options } of cookiesToSet) { + cookieStore.set(name, value, options); + } + } catch { + // Called during a Server Component render where cookies are + // read-only. The proxy refresh writes the cookies instead, so this + // is safe to ignore. + } + }, + }, + }); +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000000..bf0c97c1c0 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,48 @@ +import { createServerClient } from "@supabase/ssr"; +import { NextResponse, type NextRequest } from "next/server"; + +import { env } from "@/lib/env"; + +// Next 16 renamed the `middleware` file convention to `proxy` (see +// node_modules/next/dist/docs/.../file-conventions/proxy.md). Proxy defaults to +// the Node.js runtime, which the Supabase client requires. +// +// Purpose: keep the user's @supabase/ssr session cookie fresh on navigation and +// API calls so persistent logins survive refreshes. It is a no-op unless the +// public Supabase env is configured AND an `sb-` auth cookie is present, so +// demo / local-no-auth traffic is untouched and adds no auth round-trip. + +export async function proxy(request: NextRequest) { + const url = env.NEXT_PUBLIC_SUPABASE_URL; + const key = env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; + const hasAuthCookie = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-")); + if (!url || !key || !hasAuthCookie) { + return NextResponse.next({ request }); + } + + let response = NextResponse.next({ request }); + const supabase = createServerClient(url, key, { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + for (const { name, value } of cookiesToSet) request.cookies.set(name, value); + response = NextResponse.next({ request }); + for (const { name, value, options } of cookiesToSet) response.cookies.set(name, value, options); + }, + }, + }); + + // Refresh the session. Per @supabase/ssr guidance, do not run other logic + // between createServerClient and getUser — a stale token here would sign the + // user out on the next request. + await supabase.auth.getUser(); + return response; +} + +export const config = { + // Run on everything except static assets and image files. API routes are + // intentionally included so cookie-based sessions refresh for them too. + matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)"], +}; diff --git a/tests/owner-scope.test.ts b/tests/owner-scope.test.ts new file mode 100644 index 0000000000..a47a6bbdc9 --- /dev/null +++ b/tests/owner-scope.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); +}); + +describe("requireOwnerScope (fail-closed owner scoping)", () => { + it("returns the ownerId when present", async () => { + const { requireOwnerScope } = await import("../src/lib/owner-scope"); + expect(requireOwnerScope("owner-1")).toBe("owner-1"); + }); + + it("stays permissive (undefined) without an owner in demo mode", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => true, isLocalNoAuthMode: () => false })); + const { requireOwnerScope } = await import("../src/lib/owner-scope"); + expect(requireOwnerScope(undefined)).toBeUndefined(); + expect(requireOwnerScope(null)).toBeUndefined(); + }); + + it("throws when a real (non-demo, non-local) deployment omits the ownerId", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, isLocalNoAuthMode: () => false })); + vi.stubEnv("NODE_ENV", "production"); + const { requireOwnerScope } = await import("../src/lib/owner-scope"); + expect(() => requireOwnerScope(undefined)).toThrow(/without an ownerId/); + expect(() => requireOwnerScope(null)).toThrow(/tenant/); + }); +});