Files
flatrender/src/middleware.ts
T

138 lines
4.1 KiB
TypeScript
Raw Normal View History

import { type NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";
import {
ACCESS_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
} from "@/lib/auth/constants";
import { decodeJwt, isJwtExpired } from "@/lib/auth/jwt";
const handleI18n = createIntlMiddleware(routing);
// Routes that require an authenticated Identity session.
const PROTECTED = /^\/(?:en\/)?(?:dashboard|studio|admin)(?:\/|$)/;
// Admin-only routes.
const ADMIN_ONLY = /^\/(?:en\/)?admin(?:\/|$)/;
// Proactively refresh the access token when fewer than 120 s remain.
const REFRESH_BEFORE_EXPIRY_S = 120;
async function tryRefreshToken(
request: NextRequest
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number } | null> {
const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;
if (!refreshToken) return null;
const gatewayUrl = (
process.env.API_GATEWAY_URL ?? "http://localhost:8088"
).replace(/\/$/, "");
try {
const res = await fetch(`${gatewayUrl}/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
// Never cache refresh calls.
cache: "no-store",
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
if (!data?.access_token || !data?.refresh_token) return null;
return {
accessToken: data.access_token as string,
refreshToken: data.refresh_token as string,
expiresIn: (data.expires_in as number) ?? 900,
};
} catch {
return null;
}
}
function applyNewTokens(
response: NextResponse,
accessToken: string,
refreshToken: string,
expiresIn: number
): NextResponse {
const secure = process.env.AUTH_COOKIE_SECURE === "true";
const base = { httpOnly: true, sameSite: "lax" as const, secure, path: "/" };
response.cookies.set(ACCESS_TOKEN_COOKIE, accessToken, {
...base,
maxAge: expiresIn,
});
response.cookies.set(REFRESH_TOKEN_COOKIE, refreshToken, {
...base,
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
export async function middleware(request: NextRequest) {
// 1. Locale detection / redirect (next-intl)
const i18nResponse = handleI18n(request);
if (i18nResponse.status !== 200 || i18nResponse.headers.has("location")) {
return i18nResponse;
}
const { pathname } = request.nextUrl;
if (!PROTECTED.test(pathname)) return i18nResponse;
// 2. Read the current access token
let accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value ?? null;
let claims = decodeJwt(accessToken ?? "");
let newTokens: Awaited<ReturnType<typeof tryRefreshToken>> = null;
// 3. Proactively refresh when token is about to expire (< 120 s left)
if (
accessToken &&
claims?.exp &&
claims.exp - Date.now() / 1000 < REFRESH_BEFORE_EXPIRY_S
) {
newTokens = await tryRefreshToken(request);
if (newTokens) {
accessToken = newTokens.accessToken;
claims = decodeJwt(accessToken);
}
}
// 4. If token is missing or expired (and refresh failed), redirect to login
if (!accessToken || isJwtExpired(claims)) {
const url = request.nextUrl.clone();
url.pathname = pathname.startsWith("/en") ? "/en/auth" : "/auth";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
// 5. Admin guard — is_admin must be truthy
if (ADMIN_ONLY.test(pathname)) {
const isAdmin =
String(claims?.is_admin) === "true" ||
claims?.is_admin === true ||
String(claims?.is_tenant_admin) === "true";
if (!isAdmin) {
const url = request.nextUrl.clone();
url.pathname = pathname.startsWith("/en") ? "/en/dashboard" : "/dashboard";
return NextResponse.redirect(url);
}
}
// 6. Stamp fresh cookies onto the response if we refreshed
if (newTokens) {
return applyNewTokens(
i18nResponse,
newTokens.accessToken,
newTokens.refreshToken,
newTokens.expiresIn
);
}
return i18nResponse;
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};