fix(auth): port dev auto-login to Next.js 16 proxy.ts
Deploy to route.crispygoat.com / deploy (push) Successful in 3m8s
Deploy to route.crispygoat.com / deploy (push) Successful in 3m8s
Next.js 16 renamed middleware.ts to proxy.ts and only allows one of the two. Delete src/middleware.ts and rewrite src/proxy.ts to do the dev auto-login + /login bounce + /admin gate explicitly (no NextAuth auth() wrapper — auth.handler() returns a NextMiddleware taking (req, event), which makes wrapping it awkward when we also need to set cookies on the response). Drop the now-dead 'authorized' callback from auth.config.ts (it was only fired by the NextAuth wrapper, which we no longer use). Migration 209: add a defensive unique constraint on admin_users.user_id so the ON CONFLICT (user_id) clause in the new RPC resolves regardless of whether the Supabase-dashboard-created table shipped with one.
This commit is contained in:
@@ -44,32 +44,6 @@ export const authConfig = {
|
|||||||
session: { strategy: "jwt" },
|
session: { strategy: "jwt" },
|
||||||
|
|
||||||
callbacks: {
|
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
|
* Forward the user id from the database user record into the JWT on
|
||||||
* initial sign-in. With database sessions this is what populates
|
* initial sign-in. With database sessions this is what populates
|
||||||
|
|||||||
@@ -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"],
|
|
||||||
};
|
|
||||||
+72
-15
@@ -1,26 +1,83 @@
|
|||||||
import NextAuth from "next-auth";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { authConfig } from "@/auth.config";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16).
|
* 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()`?
|
* Routing policy:
|
||||||
* 1. Auth.js v5 ships an `authorized` callback in `authConfig` that
|
* 1. `/admin/*` with no auth cookie + `ALLOW_DEV_LOGIN !== "false"`
|
||||||
* knows which routes need a session. We reuse it here at the edge.
|
* → set `dev_session=platform_admin` and let the request through.
|
||||||
* 2. It auto-populates `request.auth` with the session (JWT-decoded)
|
* This makes `/admin` "just work" in dev/demo without any login
|
||||||
* for any server component/page that reads `auth()` later.
|
* 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
|
* Auth-cookie flavours recognised:
|
||||||
* in `src/auth.config.ts`.
|
* - `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 = {
|
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"],
|
matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,35 @@
|
|||||||
ALTER TABLE admin_users
|
ALTER TABLE admin_users
|
||||||
ADD COLUMN IF NOT EXISTS can_manage_settings BOOLEAN NOT NULL DEFAULT false;
|
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
|
-- SECURITY DEFINER RPC: upsert a platform_admin row for the given
|
||||||
-- Auth.js user id.
|
-- Auth.js user id.
|
||||||
--
|
--
|
||||||
|
|||||||
Reference in New Issue
Block a user