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> = 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: [ // Skip API, Next internals, and any path with a file extension (static // assets: images, video, fonts, etc. served from public/). "/((?!api|_next/static|_next/image|favicon.ico|.*\\.[a-zA-Z0-9]+$).*)", ], };