mirror of
https://github.com/tiennm99/bsk.git
synced 2026-07-05 17:06:17 +00:00
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:
+24
-1
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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<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 };
|
||||
}
|
||||
|
||||
@@ -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|.*\\..*).*)"],
|
||||
|
||||
Reference in New Issue
Block a user