2026-06-23 20:36:07 -04:00
|
|
|
import { NextResponse, type NextRequest } from "next/server"
|
|
|
|
|
import { getSessionCookie } from "better-auth/cookies"
|
|
|
|
|
|
|
|
|
|
const PROTECTED_PATHS = [
|
|
|
|
|
"/admin",
|
|
|
|
|
"/dashboard",
|
|
|
|
|
"/properties",
|
|
|
|
|
"/tenants",
|
|
|
|
|
"/rent",
|
|
|
|
|
"/maintenance",
|
|
|
|
|
"/leases",
|
|
|
|
|
"/expenses",
|
|
|
|
|
"/settings",
|
|
|
|
|
"/onboarding",
|
|
|
|
|
"/calendar",
|
|
|
|
|
"/inspections",
|
|
|
|
|
"/vendors",
|
|
|
|
|
"/reports",
|
|
|
|
|
"/activity",
|
|
|
|
|
"/ai",
|
|
|
|
|
"/ai-dashboard",
|
|
|
|
|
"/predictions",
|
|
|
|
|
"/recommendations",
|
|
|
|
|
"/impact",
|
|
|
|
|
"/follow-ups",
|
2026-07-02 13:42:34 -04:00
|
|
|
"/team",
|
2026-06-23 20:36:07 -04:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
|
|
|
|
|
|
2026-07-03 04:45:24 -04:00
|
|
|
// Origin of the Sentry ingest endpoint, derived from the public DSN so the
|
|
|
|
|
// CSP stays in sync with whatever project/region the DSN points at. Returns
|
|
|
|
|
// null when Sentry is not configured.
|
|
|
|
|
function sentryIngestOrigin(): string | null {
|
|
|
|
|
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN
|
|
|
|
|
if (!dsn) return null
|
|
|
|
|
try {
|
|
|
|
|
return new URL(dsn).origin
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 13:56:34 -04:00
|
|
|
// Build the per-request Content-Security-Policy. `script-src` carries a
|
|
|
|
|
// per-request nonce instead of 'unsafe-inline'. `style-src` keeps
|
|
|
|
|
// 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline
|
|
|
|
|
// styles and removing it would break the UI. Next.js reads the nonce from the
|
|
|
|
|
// `content-security-policy` request header and applies it to its own inline
|
|
|
|
|
// scripts automatically.
|
|
|
|
|
function buildCsp(nonce: string): string {
|
2026-07-02 13:42:34 -04:00
|
|
|
const isDev = process.env.NODE_ENV !== "production"
|
2026-07-03 04:45:24 -04:00
|
|
|
const sentry = sentryIngestOrigin()
|
2026-07-02 13:42:34 -04:00
|
|
|
|
|
|
|
|
// In development, Next.js/React and Turbopack HMR require eval() for hot
|
|
|
|
|
// reloading and debugging features, and open a dev websocket. These are NOT
|
|
|
|
|
// added in production, where the nonce-based policy stays strict.
|
|
|
|
|
const scriptSrc = isDev
|
|
|
|
|
? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://challenges.cloudflare.com`
|
|
|
|
|
: `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com`
|
2026-07-03 04:45:24 -04:00
|
|
|
const connectSrc = [
|
|
|
|
|
"connect-src 'self'",
|
|
|
|
|
isDev ? "ws: wss:" : "",
|
|
|
|
|
"https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com",
|
|
|
|
|
sentry ?? "",
|
|
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(" ")
|
2026-07-02 13:42:34 -04:00
|
|
|
|
2026-07-01 13:56:34 -04:00
|
|
|
return [
|
|
|
|
|
"default-src 'self'",
|
|
|
|
|
"img-src 'self' data: blob: https:",
|
|
|
|
|
"style-src 'self' 'unsafe-inline'",
|
2026-07-02 13:42:34 -04:00
|
|
|
scriptSrc,
|
2026-07-01 13:56:34 -04:00
|
|
|
"font-src 'self' data:",
|
2026-07-02 13:42:34 -04:00
|
|
|
connectSrc,
|
2026-07-03 04:45:24 -04:00
|
|
|
// Sentry Session Replay spins up its compression worker from a blob: URL;
|
|
|
|
|
// without worker-src the browser falls back to script-src and blocks it.
|
|
|
|
|
"worker-src 'self' blob:",
|
2026-07-01 13:56:34 -04:00
|
|
|
"frame-src https://js.stripe.com https://hooks.stripe.com https://challenges.cloudflare.com",
|
|
|
|
|
"frame-ancestors 'none'",
|
|
|
|
|
"base-uri 'self'",
|
|
|
|
|
"form-action 'self'",
|
2026-07-03 04:45:24 -04:00
|
|
|
"object-src 'none'",
|
2026-07-01 13:56:34 -04:00
|
|
|
].join("; ")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 04:45:24 -04:00
|
|
|
// Cookie presence is only a hint (cheap, no DB). Before bouncing a visitor off
|
|
|
|
|
// an auth page we confirm the session is actually alive — otherwise a stale
|
|
|
|
|
// cookie loops forever: /dashboard → /login (server sees no session) →
|
|
|
|
|
// /dashboard (proxy sees a cookie) → … until ERR_TOO_MANY_REDIRECTS.
|
|
|
|
|
// "unknown" (auth service unreachable / rate-limited) renders the auth page
|
|
|
|
|
// without touching cookies, which is safe in both directions.
|
|
|
|
|
async function sessionState(request: NextRequest): Promise<"valid" | "invalid" | "unknown"> {
|
|
|
|
|
try {
|
|
|
|
|
const base = process.env.BETTER_AUTH_URL ?? request.nextUrl.origin
|
|
|
|
|
const res = await fetch(new URL("/api/auth/get-session", base), {
|
|
|
|
|
headers: { cookie: request.headers.get("cookie") ?? "" },
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) return "unknown"
|
|
|
|
|
// Better Auth returns JSON `null` when the session is missing or revoked.
|
|
|
|
|
const session = await res.json()
|
|
|
|
|
return session ? "valid" : "invalid"
|
|
|
|
|
} catch {
|
|
|
|
|
return "unknown"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 20:36:07 -04:00
|
|
|
export async function proxy(request: NextRequest) {
|
|
|
|
|
const pathname = request.nextUrl.pathname
|
|
|
|
|
|
|
|
|
|
// Optimistic check based on the presence of the session cookie. Real
|
|
|
|
|
// enforcement happens in routes / server components via getSessionUser().
|
|
|
|
|
const sessionCookie = getSessionCookie(request)
|
|
|
|
|
|
|
|
|
|
const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p))
|
|
|
|
|
if (isProtected && !sessionCookie) {
|
|
|
|
|
const url = request.nextUrl.clone()
|
|
|
|
|
url.pathname = "/login"
|
|
|
|
|
return NextResponse.redirect(url)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p))
|
2026-07-03 04:45:24 -04:00
|
|
|
let dropStaleSessionCookie = false
|
2026-06-23 20:36:07 -04:00
|
|
|
if (isAuthPage && sessionCookie) {
|
2026-07-03 04:45:24 -04:00
|
|
|
const state = await sessionState(request)
|
|
|
|
|
if (state === "valid") {
|
|
|
|
|
const url = request.nextUrl.clone()
|
|
|
|
|
url.pathname = "/dashboard"
|
|
|
|
|
return NextResponse.redirect(url)
|
|
|
|
|
}
|
|
|
|
|
// Dead cookie (session revoked or expired): render the auth page and drop
|
|
|
|
|
// the cookie below so protected paths stop treating this visitor as
|
|
|
|
|
// signed in. On "unknown", render the page but keep the cookie.
|
|
|
|
|
dropStaleSessionCookie = state === "invalid"
|
2026-06-23 20:36:07 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-01 13:56:34 -04:00
|
|
|
// Per-request CSP nonce. UUID contains only hex + dashes, so it never
|
|
|
|
|
// includes HTML-escape characters (which Next rejects in nonces).
|
|
|
|
|
const nonce = crypto.randomUUID()
|
|
|
|
|
const csp = buildCsp(nonce)
|
|
|
|
|
|
|
|
|
|
// Forward the nonce + CSP on the request headers so Next.js can pick up the
|
|
|
|
|
// nonce and apply it to its own inline scripts during render.
|
|
|
|
|
const requestHeaders = new Headers(request.headers)
|
|
|
|
|
requestHeaders.set("x-nonce", nonce)
|
|
|
|
|
requestHeaders.set("Content-Security-Policy", csp)
|
|
|
|
|
|
|
|
|
|
const response = NextResponse.next({ request: { headers: requestHeaders } })
|
|
|
|
|
// Also set the CSP on the outgoing response so the browser enforces it.
|
|
|
|
|
response.headers.set("Content-Security-Policy", csp)
|
2026-07-03 04:45:24 -04:00
|
|
|
|
|
|
|
|
if (dropStaleSessionCookie) {
|
|
|
|
|
// Covers both the plain and __Secure-prefixed Better Auth cookie names.
|
|
|
|
|
for (const cookie of request.cookies.getAll()) {
|
|
|
|
|
if (!cookie.name.includes("better-auth.session_token")) continue
|
|
|
|
|
response.cookies.set(cookie.name, "", {
|
|
|
|
|
maxAge: 0,
|
|
|
|
|
path: "/",
|
|
|
|
|
httpOnly: true,
|
|
|
|
|
sameSite: "lax",
|
|
|
|
|
secure: cookie.name.startsWith("__Secure-"),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 13:56:34 -04:00
|
|
|
return response
|
2026-06-23 20:36:07 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const config = {
|
|
|
|
|
matcher: [
|
|
|
|
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
|
|
|
|
],
|
|
|
|
|
}
|