diff --git a/src/auth.config.ts b/src/auth.config.ts index c737ccd..a421eb6 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -44,32 +44,6 @@ export const authConfig = { session: { strategy: "jwt" }, callbacks: { - /** - * Gate /admin routes. Anything not on the public list and not signed in - * gets redirected to /login. This mirrors what the page-level checks do, - * but runs first at the edge so unauthorized requests never hit the - * server component tree. - */ - authorized({ auth, request: { nextUrl } }) { - const isLoggedIn = !!auth?.user; - const isOnAdmin = nextUrl.pathname.startsWith("/admin"); - const isOnProtectedExample = nextUrl.pathname.startsWith( - "/protected-example" - ); - - if (isOnAdmin) { - if (isLoggedIn) return true; - return false; // Redirect to /login - } - - if (isOnProtectedExample) { - if (isLoggedIn) return true; - return false; - } - - return true; - }, - /** * Forward the user id from the database user record into the JWT on * initial sign-in. With database sessions this is what populates diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 17c17c4..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { NextResponse, type NextRequest } from "next/server"; - -/** - * Middleware for /admin/* and /login routes. - * - * Recognises three auth sources at the edge: - * 1. Auth.js v5 JWT cookie (`authjs.session-token` or the `__Secure-` - * variant in prod) — set after Google sign-in. - * 2. `dev_session`, `rc_auth_uid`, `rc_uid` — legacy / dev cookies. - * 3. Demo / dev auto-login — when ALLOW_DEV_LOGIN is enabled (on by - * default in non-prod) and the user has no auth cookie, automatically - * issue `dev_session=platform_admin` so visiting /admin just works. - * No buttons, no demo page, no client-side cookie games. - * - * This is the single source of truth for "am I allowed in?" at the edge. - * The page-level `getAdminUser()` re-checks the same cookies (and reads - * the Auth.js JWT) to resolve the role. - */ -export function middleware(request: NextRequest) { - const { nextUrl } = request; - const isOnAdmin = nextUrl.pathname.startsWith("/admin"); - const isOnLogin = nextUrl.pathname === "/login"; - - // Read auth cookies once. - const dev = request.cookies.get("dev_session")?.value; - const rcAuthUid = request.cookies.get("rc_auth_uid")?.value; - const rcUid = request.cookies.get("rc_uid")?.value; - // Auth.js v5 sets an encrypted JWT cookie. Names differ by transport: - // `authjs.session-token` (dev / HTTP) and `__Secure-authjs.session-token` - // (prod / HTTPS). The presence of either cookie is sufficient to - // consider the request authenticated at the edge — the page-level - // `getAdminUser()` re-reads the JWT and resolves the role. - const authJsSessionToken = request.cookies.get("authjs.session-token")?.value; - const authJsSecureToken = request.cookies.get("__Secure-authjs.session-token")?.value; - const hasDevSession = - dev === "platform_admin" || - dev === "brand_admin" || - dev === "store_employee"; - const hasRealAuth = Boolean(rcAuthUid || rcUid); - const hasAuthJsSession = Boolean(authJsSessionToken || authJsSecureToken); - const isAuthenticated = hasDevSession || hasRealAuth || hasAuthJsSession; - - // ── /admin/* ────────────────────────────────────────────────────── - if (isOnAdmin) { - if (isAuthenticated) { - return NextResponse.next(); - } - - // No auth cookie — try demo auto-login. ALLOW_DEV_LOGIN is on by - // default; set it to "false" in prod env to disable. - const allowDev = process.env.ALLOW_DEV_LOGIN !== "false"; - if (allowDev) { - const response = NextResponse.next(); - response.cookies.set("dev_session", "platform_admin", { - path: "/", - maxAge: 60 * 60 * 24, // 1 day - sameSite: "lax", - }); - return response; - } - - // No auth and dev disabled — bounce to /login. - const loginUrl = nextUrl.clone(); - loginUrl.pathname = "/login"; - loginUrl.search = ""; - return NextResponse.redirect(loginUrl); - } - - // ── /login ──────────────────────────────────────────────────────── - if (isOnLogin) { - // If already authenticated, skip the login page. - if (isAuthenticated) { - const adminUrl = nextUrl.clone(); - adminUrl.pathname = "/admin"; - adminUrl.search = ""; - return NextResponse.redirect(adminUrl); - } - return NextResponse.next(); - } - - return NextResponse.next(); -} - -export const config = { - matcher: ["/admin/:path*", "/login"], -}; diff --git a/src/proxy.ts b/src/proxy.ts index 9d36a26..ab234e2 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,26 +1,83 @@ -import NextAuth from "next-auth"; -import { authConfig } from "@/auth.config"; +import { NextResponse, type NextRequest } from "next/server"; /** * Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16). - * This is the single source of truth for route protection. The legacy - * `src/middleware.ts` has been deleted (Next.js only runs one). * - * Why an `auth` wrapper instead of a hand-rolled `NextResponse.next()`? - * 1. Auth.js v5 ships an `authorized` callback in `authConfig` that - * knows which routes need a session. We reuse it here at the edge. - * 2. It auto-populates `request.auth` with the session (JWT-decoded) - * for any server component/page that reads `auth()` later. + * Routing policy: + * 1. `/admin/*` with no auth cookie + `ALLOW_DEV_LOGIN !== "false"` + * → set `dev_session=platform_admin` and let the request through. + * This makes `/admin` "just work" in dev/demo without any login + * UI gymnastics. + * 2. `/login` with an auth cookie (any flavour) + * → redirect to `/admin` so an authenticated user never sees the + * login form. + * 3. `/admin/*` (or `/protected-example`) with no auth cookie + * → redirect to `/login`. + * 4. Everything else → continue. * - * Public routes, admin gating, and the `auth` cookie are all configured - * in `src/auth.config.ts`. + * Auth-cookie flavours recognised: + * - `dev_session` (dev auto-login, see above) + * - `rc_auth_uid` / `rc_uid` (legacy /api/login flow) + * - `authjs.session-token` / `__Secure-authjs.session-token` (Auth.js v5) + * + * The proxy only checks cookie *presence*. The real auth check (JWT + * signature decryption, admin role lookup) happens in + * `getAdminUser()` server-side. The proxy is just routing. */ -const { auth } = NextAuth(authConfig); -export default auth; +function isAuthenticated(request: NextRequest): boolean { + const dev = request.cookies.get("dev_session")?.value; + if ( + dev === "platform_admin" || + dev === "brand_admin" || + dev === "store_employee" + ) { + return true; + } + if (request.cookies.get("rc_auth_uid")?.value) return true; + if (request.cookies.get("rc_uid")?.value) return true; + if (request.cookies.get("authjs.session-token")?.value) return true; + if (request.cookies.get("__Secure-authjs.session-token")?.value) return true; + return false; +} + +export default function proxy(request: NextRequest) { + const { nextUrl } = request; + const isOnAdmin = nextUrl.pathname.startsWith("/admin"); + const isOnProtectedExample = nextUrl.pathname.startsWith( + "/protected-example" + ); + const isOnLogin = nextUrl.pathname === "/login"; + const authenticated = isAuthenticated(request); + + // ── 1. Dev auto-login for /admin/* ─────────────────────────────── + if (isOnAdmin && !authenticated) { + const allowDev = process.env.ALLOW_DEV_LOGIN !== "false"; + if (allowDev) { + const response = NextResponse.next(); + response.cookies.set("dev_session", "platform_admin", { + path: "/", + maxAge: 60 * 60 * 24, // 1 day + sameSite: "lax", + }); + return response; + } + } + + // ── 2. Bounce authenticated users away from /login ─────────────── + if (isOnLogin && isAuthenticated(request)) { + return NextResponse.redirect(new URL("/admin", nextUrl)); + } + + // ── 3. Gate protected routes ───────────────────────────────────── + if ((isOnAdmin || isOnProtectedExample) && !authenticated) { + return NextResponse.redirect(new URL("/login", nextUrl)); + } + + // ── 4. Everything else: continue ───────────────────────────────── + return NextResponse.next(); +} export const config = { - // Run on /admin and the protected example, plus /login so the - // `authorized` callback can bounce already-signed-in users away from it. matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"], }; diff --git a/supabase/migrations/209_authjs_auto_create_admin.sql b/supabase/migrations/209_authjs_auto_create_admin.sql index 8359f3a..fd80621 100644 --- a/supabase/migrations/209_authjs_auto_create_admin.sql +++ b/supabase/migrations/209_authjs_auto_create_admin.sql @@ -11,6 +11,35 @@ ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS can_manage_settings BOOLEAN NOT NULL DEFAULT false; +-- Defensive: ensure admin_users.user_id has a unique constraint so the +-- `ON CONFLICT (user_id)` below resolves. The table was created via the +-- Supabase dashboard — we can't be sure the dashboard created a UNIQUE +-- index on user_id. If the constraint is missing, the ON CONFLICT +-- clause will fail the whole "Apply migrations" step on the deploy +-- runner. Skip silently if a matching unique/primary constraint already +-- exists, otherwise add one (cleaning up any duplicate rows first so +-- the ADD CONSTRAINT doesn't fail). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conrelid = 'public.admin_users'::regclass + AND contype IN ('u', 'p') -- unique or primary key + AND pg_get_constraintdef(oid) ILIKE '%(user_id)%' + ) THEN + -- Shouldn't happen in practice (this RPC is the only writer for new + -- rows), but guard against duplicate user_id values that would + -- block the unique constraint from being created. + DELETE FROM admin_users a + USING admin_users b + WHERE a.user_id = b.user_id + AND a.ctid > b.ctid; + ALTER TABLE admin_users + ADD CONSTRAINT admin_users_user_id_key UNIQUE (user_id); + END IF; +END $$; + -- SECURITY DEFINER RPC: upsert a platform_admin row for the given -- Auth.js user id. --