From 129cbb7bf08e6a30fc7a2f8d0aaebc41709bbf51 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 25 May 2026 17:30:41 +0700 Subject: [PATCH] feat(phase-1): auth session wiring (proxy + layout + helpers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proxy.ts: composes Supabase session refresh + next-intl middleware into a single NextResponse via copyCookies helper. Coarse auth gate on /dashboard + /admin prefixes redirects unauth users to /[locale]/sign-in (no ?next= per trimmed plan). - lib/supabase/session.ts: implements updateSupabaseSession() returning { response, user }. Cookies written onto both request.cookies (for downstream reads) and response.cookies (for browser). PROTECTED_PATH_PREFIXES exported as the gate list. - lib/proxy/copy-cookies.ts: small helper that ports Set-Cookie entries between two NextResponses. - lib/auth/get-server-session.ts: getServerSession() returning { user, role } | null. Derives User type from the factory's return type so @supabase/supabase-js stays out of allow-listed lib/auth/* per ESLint no-restricted-imports. - lib/auth/session-provider.tsx: client-side context exposing user to client components via useSession() — populated once per request in the locale layout. - app/[locale]/layout.tsx: reads user via getUser() outside any 'use cache' scope; wraps children in SessionProvider; explicit 'use cache' warning comment. --- app/[locale]/layout.tsx | 25 +++++++++- lib/auth/get-server-session.ts | 72 +++++++++++++++++++++++++++ lib/auth/session-provider.tsx | 36 ++++++++++++++ lib/proxy/copy-cookies.ts | 16 ++++++ lib/supabase/session.ts | 40 ++++++++++++--- proxy.ts | 91 +++++++++++++++++++++++++++++++++- 6 files changed, 269 insertions(+), 11 deletions(-) create mode 100644 lib/auth/get-server-session.ts create mode 100644 lib/auth/session-provider.tsx create mode 100644 lib/proxy/copy-cookies.ts diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index ee48e74..8e8ba21 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -1,9 +1,14 @@ +// WARNING: Do NOT add `'use cache'` to this layout — it calls +// createSupabaseServerClient() which reads cookies(). Caching this scope would +// either throw at build time or serve stale auth state across users. import { NextIntlClientProvider, hasLocale } from "next-intl"; import { setRequestLocale } from "next-intl/server"; import { notFound } from "next/navigation"; import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; import { routing } from "@/i18n/routing"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { SessionProvider } from "@/lib/auth/session-provider"; import "../globals.css"; export const metadata: Metadata = { @@ -37,10 +42,28 @@ export default async function LocaleLayout({ setRequestLocale(locale); + // Read the authenticated user here, outside any `'use cache'` scope. + // getUser() validates the JWT server-side on every render — do not move this + // call into a cached helper. Cached helpers that need the user receive it as + // a function argument (see phase 06 patterns). + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + // user is null when unauthenticated or when Supabase Auth is unreachable. + // No redirect here — route gating is phase 06's responsibility. + // The proxy already handles the coarse "unauth → /sign-in" redirect for + // explicitly protected path prefixes (/dashboard, /admin). + return ( - {children} + + {/* SessionProvider makes `user` available to client components via + useSession() without any additional Supabase calls from the client. */} + {children} + ); diff --git a/lib/auth/get-server-session.ts b/lib/auth/get-server-session.ts new file mode 100644 index 0000000..6da1255 --- /dev/null +++ b/lib/auth/get-server-session.ts @@ -0,0 +1,72 @@ +import "server-only"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { isAppRole, type AppRole } from "@/lib/db/roles"; + +// Derive the User type from the factory's return type so we never import +// @supabase/supabase-js directly (ESLint no-restricted-imports enforces that +// only the named factory files in lib/supabase/* may do so). +type SupabaseServerClient = Awaited>; +type GetUserResult = Awaited>; +export type User = NonNullable; + +export type ServerSession = { + user: User; + role: AppRole | null; +}; + +/** + * Reads the authenticated user and their BSK role from the current request. + * + * Returns `null` when unauthenticated or when `getUser()` fails (transient + * Supabase outage). Returns `{ user, role: null }` when the user is + * authenticated but has no row in `bsk.app_users` (e.g. just signed up, + * awaiting role assignment by admin). + * + * MUST be called outside any `'use cache'` scope — it calls + * `createSupabaseServerClient()` which reads `cookies()`. Cached helpers that + * need the session must receive `user` / `role` as arguments, never re-read + * cookies internally. + * + * Used by: `[locale]/layout.tsx` (phase 02 establishes the pattern), + * protected route layouts (phase 06), and Server Actions that need role checks. + */ +export async function getServerSession(): Promise { + let supabase: SupabaseServerClient; + + try { + supabase = await createSupabaseServerClient(); + } catch { + // Cookie store unavailable (e.g. called during static generation). + return null; + } + + // getUser() round-trips to Supabase Auth and validates the JWT server-side. + // Do NOT use getSession() here — it trusts the cookie blob without validation. + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError ?? !user) { + return null; + } + + // Query the BSK role via the RPC defined in migration 20260525163300. + // Returns null when the user has no row in bsk.app_users. + // The RPC relies on auth.uid() matching a row in bsk.app_users; if the + // migration has not been applied yet (pre-provisioning), the RPC will throw — + // treated as role: null, not as an auth failure. + let role: AppRole | null = null; + + try { + const { data: rpcRole, error: rpcError } = await supabase.rpc("current_role"); + + if (!rpcError && rpcRole !== null && isAppRole(rpcRole)) { + role = rpcRole; + } + } catch { + // Pre-provisioning or transient DB error: proceed with role: null. + } + + return { user, role }; +} diff --git a/lib/auth/session-provider.tsx b/lib/auth/session-provider.tsx new file mode 100644 index 0000000..6dffb56 --- /dev/null +++ b/lib/auth/session-provider.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { ReactNode } from "react"; +import type { User } from "@/lib/auth/get-server-session"; + +// ── Context ─────────────────────────────────────────────────────────────────── + +const SessionContext = createContext(null); + +// ── Provider ────────────────────────────────────────────────────────────────── + +/** + * Thin client wrapper that makes the server-read `user` available to any + * client component in the subtree via `useSession()`. + * + * The `user` value is read once per request in `[locale]/layout.tsx` (outside + * any `'use cache'` scope) and passed in as a prop — this component does NOT + * make any Supabase calls itself. + * + * Phase 06 consumes this via `useSession()` for: sidebar user badge, + * sign-out button, and client-side role checks. + */ +export function SessionProvider({ user, children }: { user: User | null; children: ReactNode }) { + return {children}; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +/** + * Returns the current authenticated user, or `null` when unauthenticated. + * Must be called from a client component inside ``. + */ +export function useSession(): User | null { + return useContext(SessionContext); +} diff --git a/lib/proxy/copy-cookies.ts b/lib/proxy/copy-cookies.ts new file mode 100644 index 0000000..52a39ce --- /dev/null +++ b/lib/proxy/copy-cookies.ts @@ -0,0 +1,16 @@ +import type { NextResponse } from "next/server"; + +/** + * Copies all Set-Cookie entries from `from` onto `to`. + * + * Used when two middleware layers each produce a NextResponse — we must funnel + * both sets of cookies onto the single response that is ultimately returned. + * Specifically: Supabase session-refresh writes sb-* cookies onto a response; + * if next-intl then produces its own redirect/rewrite response, we must port + * the Supabase cookies onto it before returning or the auth token is lost. + */ +export function copyCookies(from: NextResponse, to: NextResponse): void { + for (const cookie of from.cookies.getAll()) { + to.cookies.set(cookie.name, cookie.value, cookie); + } +} diff --git a/lib/supabase/session.ts b/lib/supabase/session.ts index 3d44b25..3e95d2b 100644 --- a/lib/supabase/session.ts +++ b/lib/supabase/session.ts @@ -2,17 +2,36 @@ import "server-only"; import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; import { serverEnv, SUPABASE_SCHEMA } from "@/lib/env/server"; +import type { User } from "@supabase/supabase-js"; + +/** + * Coarse list of path prefixes that require authentication. + * Checked by proxy.ts AFTER stripping the locale segment. + * Extend here as new protected route groups are added (phases 05–06). + * `/sign-in` is intentionally absent — it must be reachable while unauth'd. + */ +export const PROTECTED_PATH_PREFIXES: ReadonlyArray = ["/dashboard", "/admin"]; /** * Refreshes the Supabase auth session for an incoming request. * - * NOT yet wired into `proxy.ts` — that integration lands in Phase 1 along with - * the sign-in flow. When wiring it in, call this BEFORE handing the request to - * `next-intl/middleware`, and merge the returned response's Set-Cookie headers - * into the response next-intl produces (the two run in sequence; do not build - * two independent NextResponse instances). + * Call this BEFORE handing the request to `next-intl/middleware`. Merge the + * returned `response`'s Set-Cookie headers onto whichever response next-intl + * ultimately returns — do NOT build two independent NextResponse instances or + * one set of cookies will be silently dropped (use `copyCookies` helper). + * + * Returns `{ response, user }` where `user` is `null` on auth failure or when + * no session exists. The `response` always carries refreshed (or unchanged) + * Supabase cookie deltas. + * + * Error-swallow on `getUser()` is intentional: a transient Supabase Auth + * outage should not hard-fail every request. The caller treats `user: null` as + * unauthenticated and applies the protected-path redirect; Supabase cookies are + * preserved so the next request retries. (Confirmed acceptable — code-reviewer N6.) */ -export async function updateSupabaseSession(request: NextRequest) { +export async function updateSupabaseSession( + request: NextRequest, +): Promise<{ response: NextResponse; user: User | null }> { const response = NextResponse.next({ request }); const supabase = createServerClient( @@ -26,7 +45,9 @@ export async function updateSupabaseSession(request: NextRequest) { }, setAll(cookiesToSet) { for (const { name, value, options } of cookiesToSet) { + // Write onto the request so downstream middleware see the fresh token. request.cookies.set(name, value); + // Write onto the response so the browser receives the refreshed cookie. response.cookies.set(name, value, options); } }, @@ -34,11 +55,14 @@ export async function updateSupabaseSession(request: NextRequest) { }, ); + let user: User | null = null; + try { - await supabase.auth.getUser(); + const { data } = await supabase.auth.getUser(); + user = data.user; } catch { // Transient Supabase Auth outage: keep stale cookies; next request retries. } - return response; + return { response, user }; } diff --git a/proxy.ts b/proxy.ts index fcf488a..85094c4 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,7 +1,94 @@ import createMiddleware from "next-intl/middleware"; -import { routing } from "./i18n/routing"; +import { NextResponse, type NextRequest } from "next/server"; +import { routing } from "@/i18n/routing"; +import { updateSupabaseSession, PROTECTED_PATH_PREFIXES } from "@/lib/supabase/session"; +import { copyCookies } from "@/lib/proxy/copy-cookies"; -export default createMiddleware(routing); +/** + * Built once at module load — not per request. + * next-intl composition pattern: call createMiddleware() here, then invoke the + * returned function inside proxy() with the actual request. + * Ref: next-intl docs "Usage without framework integration". + */ +const handleI18nRouting = createMiddleware(routing); + +/** + * Returns true when the path (locale-stripped) starts with a protected prefix. + * Input: raw pathname from NextRequest (e.g. "/vi/dashboard/patients"). + * Strips the leading locale segment before comparing so "/vi/dashboard" and + * "/en/dashboard" both match "/dashboard". + */ +function isProtectedPath(pathname: string): boolean { + // Remove a leading locale segment if present (e.g. "/vi" or "/en"). + const localeSegmentRe = new RegExp(`^\\/(${routing.locales.join("|")})(\\/.*)?(\\?.*)?$`); + const match = localeSegmentRe.exec(pathname); + // After stripping: "/vi/dashboard" → "/dashboard"; "/dashboard" stays as-is. + const stripped = match ? (match[2] ?? "/") : pathname; + + return PROTECTED_PATH_PREFIXES.some((prefix) => stripped.startsWith(prefix)); +} + +/** + * Unified middleware entry point. + * + * Request flow: + * 1. Run Supabase session refresh → writes refreshed sb-* cookies onto BOTH + * request.cookies (for downstream reads) and a NextResponse (for Set-Cookie). + * 2. Coarse auth gate: unauthenticated requests to protected paths get a 307 + * redirect to /${locale}/sign-in?next=. + * Supabase cookies are copied onto the redirect response so the browser + * receives the token delta even on redirect. + * 3. Hand off to next-intl for locale detection / prefix rewrites. + * • If next-intl produces a redirect/rewrite (status !== 200 OR x-middleware-rewrite + * header present), copy Supabase cookies onto it and return it. + * • Otherwise return the Supabase response directly (cookies already attached). + * + * Single response guarantee: only one NextResponse is ever returned per + * request. Cookie sets from both layers are always merged before returning. + * Two competing responses would silently drop one set of Set-Cookie headers, + * appearing as a sign-out on the next request. + * + * NOTE: No `export const runtime = 'edge'` — proxy runs on the Node.js runtime + * in Next.js 16+. Supabase SSR is not certified for the Edge runtime. + */ +export default async function proxy(request: NextRequest): Promise { + // ── Step 1: Supabase session refresh ───────────────────────────────────── + const { response: supabaseResponse, user } = await updateSupabaseSession(request); + + // ── Step 2: Coarse protected-path gate ─────────────────────────────────── + // Always redirects to `/${locale}/sign-in` with NO `?next=` param — + // the trimmed plan dropped post-login redirect plumbing (original BSK has no + // URL deep-linking surface; signInAction always lands users on /dashboard). + if (!user && isProtectedPath(request.nextUrl.pathname)) { + const locale = + routing.locales.find((l) => request.nextUrl.pathname.startsWith(`/${l}`)) ?? + routing.defaultLocale; + + const signInUrl = new URL(`/${locale}/sign-in`, request.url); + + const redirectResponse = NextResponse.redirect(signInUrl, { status: 307 }); + // Port Supabase cookie deltas onto the redirect so the browser stores them. + copyCookies(supabaseResponse, redirectResponse); + return redirectResponse; + } + + // ── Step 3: next-intl locale routing ───────────────────────────────────── + const intlResponse = handleI18nRouting(request); + + // next-intl produced a redirect (3xx) or a rewrite (x-middleware-rewrite header). + // In both cases it is a distinct NextResponse — merge Supabase cookies onto it. + const isRedirect = intlResponse.status >= 300 && intlResponse.status < 400; + const isRewrite = intlResponse.headers.has("x-middleware-rewrite"); + + if (isRedirect || isRewrite) { + copyCookies(supabaseResponse, intlResponse); + return intlResponse; + } + + // next-intl returned a plain next() response — the Supabase response already + // carries the correct cookies (and the request rewrites from step 1). + return supabaseResponse; +} export const config = { matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],