86 lines
2.5 KiB
TypeScript
86 lines
2.5 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type { NextRequest } from "next/server";
|
|
import jwt from "jsonwebtoken";
|
|
|
|
const PROTECTED_PREFIXES = ["/account", "/admin"];
|
|
const API_BASE =
|
|
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
|
|
const JWT_PUBLIC_KEY_RAW = process.env.JWT_PUBLIC_KEY;
|
|
|
|
if (!JWT_PUBLIC_KEY_RAW) {
|
|
throw new Error("JWT_PUBLIC_KEY environment variable is required");
|
|
}
|
|
|
|
const JWT_PUBLIC_KEY = JWT_PUBLIC_KEY_RAW;
|
|
|
|
function isExpired(token: string): boolean {
|
|
try {
|
|
const decoded = jwt.verify(token, JWT_PUBLIC_KEY, {
|
|
algorithms: ["RS256"],
|
|
}) as any;
|
|
return Date.now() / 1000 >= decoded.exp;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export async function middleware(req: NextRequest) {
|
|
const { pathname } = req.nextUrl;
|
|
|
|
const isProtected = PROTECTED_PREFIXES.some(
|
|
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
|
);
|
|
if (!isProtected) return NextResponse.next();
|
|
|
|
const access = req.cookies.get("access_token")?.value;
|
|
if (access && !isExpired(access)) return NextResponse.next();
|
|
|
|
// Server-side refresh: validate the session against the backend using the
|
|
// refresh cookie instead of trusting the (possibly forged/expired) access JWT.
|
|
const refresh = req.cookies.get("refresh_token")?.value;
|
|
if (refresh) {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/auth/refresh/`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh }),
|
|
cache: "no-store",
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const newAccess = data.access as string | undefined;
|
|
const newRefresh = (data.refresh as string) || refresh;
|
|
if (newAccess) {
|
|
const response = NextResponse.next();
|
|
const secure = req.nextUrl.protocol === "https:";
|
|
const base = {
|
|
httpOnly: true,
|
|
sameSite: "lax" as const,
|
|
path: "/",
|
|
secure,
|
|
};
|
|
response.cookies.set("access_token", newAccess, {
|
|
...base,
|
|
maxAge: 1800,
|
|
});
|
|
response.cookies.set("refresh_token", newRefresh, {
|
|
...base,
|
|
maxAge: 60 * 60 * 24 * 30,
|
|
});
|
|
return response;
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through to redirect
|
|
}
|
|
}
|
|
|
|
const url = req.nextUrl.clone();
|
|
url.pathname = "/login";
|
|
url.searchParams.set("next", pathname);
|
|
return NextResponse.redirect(url);
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ["/account/:path*", "/admin/:path*"],
|
|
}; |