feat(phase-1): auth session wiring (proxy + layout + helpers)

- 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.
This commit is contained in:
2026-05-25 17:30:41 +07:00
parent 0a08f80450
commit 129cbb7bf0
6 changed files with 269 additions and 11 deletions
+24 -1
View File
@@ -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 (
<html lang={locale} suppressHydrationWarning>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
<NextIntlClientProvider>
{/* SessionProvider makes `user` available to client components via
useSession() without any additional Supabase calls from the client. */}
<SessionProvider user={user}>{children}</SessionProvider>
</NextIntlClientProvider>
</body>
</html>
);
+72
View File
@@ -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<ReturnType<typeof createSupabaseServerClient>>;
type GetUserResult = Awaited<ReturnType<SupabaseServerClient["auth"]["getUser"]>>;
export type User = NonNullable<GetUserResult["data"]["user"]>;
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<ServerSession | null> {
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 };
}
+36
View File
@@ -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<User | null>(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 <SessionContext.Provider value={user}>{children}</SessionContext.Provider>;
}
// ── Hook ──────────────────────────────────────────────────────────────────────
/**
* Returns the current authenticated user, or `null` when unauthenticated.
* Must be called from a client component inside `<SessionProvider>`.
*/
export function useSession(): User | null {
return useContext(SessionContext);
}
+16
View File
@@ -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);
}
}
+32 -8
View File
@@ -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 0506).
* `/sign-in` is intentionally absent — it must be reachable while unauth'd.
*/
export const PROTECTED_PATH_PREFIXES: ReadonlyArray<string> = ["/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 };
}
+89 -2
View File
@@ -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=<original-path>.
* 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<NextResponse> {
// ── 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|.*\\..*).*)"],