2026-05-29 23:29:31 +03:30
|
|
|
import { type NextRequest, NextResponse } from "next/server";
|
2026-05-24 17:37:21 +03:30
|
|
|
import createIntlMiddleware from "next-intl/middleware";
|
|
|
|
|
|
|
|
|
|
import { routing } from "@/i18n/routing";
|
2026-06-01 13:42:30 +03:30
|
|
|
import {
|
|
|
|
|
ACCESS_TOKEN_COOKIE,
|
|
|
|
|
REFRESH_TOKEN_COOKIE,
|
|
|
|
|
} from "@/lib/auth/constants";
|
2026-05-29 23:29:31 +03:30
|
|
|
import { decodeJwt, isJwtExpired } from "@/lib/auth/jwt";
|
2026-05-24 17:37:21 +03:30
|
|
|
|
|
|
|
|
const handleI18n = createIntlMiddleware(routing);
|
|
|
|
|
|
2026-06-01 13:42:30 +03:30
|
|
|
// 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.NODE_ENV === "production";
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-05-29 23:29:31 +03:30
|
|
|
|
2026-05-24 17:37:21 +03:30
|
|
|
export async function middleware(request: NextRequest) {
|
2026-05-29 23:29:31 +03:30
|
|
|
// 1. Locale detection / redirect (next-intl)
|
2026-05-24 17:37:21 +03:30
|
|
|
const i18nResponse = handleI18n(request);
|
2026-06-01 13:42:30 +03:30
|
|
|
if (i18nResponse.status !== 200 || i18nResponse.headers.has("location")) {
|
2026-05-24 17:37:21 +03:30
|
|
|
return i18nResponse;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:29:31 +03:30
|
|
|
const { pathname } = request.nextUrl;
|
2026-06-01 13:42:30 +03:30
|
|
|
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) {
|
2026-05-29 23:29:31 +03:30
|
|
|
const url = request.nextUrl.clone();
|
2026-06-01 13:42:30 +03:30
|
|
|
url.pathname = pathname.startsWith("/en") ? "/en/dashboard" : "/dashboard";
|
2026-05-29 23:29:31 +03:30
|
|
|
return NextResponse.redirect(url);
|
2026-05-24 17:37:21 +03:30
|
|
|
}
|
2026-05-29 23:29:31 +03:30
|
|
|
}
|
2026-05-24 17:37:21 +03:30
|
|
|
|
2026-06-01 13:42:30 +03:30
|
|
|
// 6. Stamp fresh cookies onto the response if we refreshed
|
|
|
|
|
if (newTokens) {
|
|
|
|
|
return applyNewTokens(
|
|
|
|
|
i18nResponse,
|
|
|
|
|
newTokens.accessToken,
|
|
|
|
|
newTokens.refreshToken,
|
|
|
|
|
newTokens.expiresIn
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:29:31 +03:30
|
|
|
return i18nResponse;
|
2026-05-24 17:37:21 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const config = {
|
|
|
|
|
matcher: [
|
|
|
|
|
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
|
|
|
|
],
|
|
|
|
|
};
|