import { NextResponse, type NextRequest } from "next/server"; // SSR 登录态保障: // access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转, // 新 cookie 同时①注入本次请求头供 layout 的 /api/me 使用 ②透传给浏览器。 // 注意 refresh token 是一次性轮转(旧的立即吊销),而 RSC 预取与真实导航可能 // 几乎同时发生,故对同一 refresh token 的并发请求做短窗口去重,避免第二次轮转 // 被后端判为重用而清空全部登录态。 const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; const EXP_SKEW_SECONDS = 30; // 提前 30s 视为过期,规避服务端时钟差 const DEDUP_TTL_MS = 10_000; type RefreshResult = { ok: boolean; setCookies: string[] }; // 模块级去重表:同一 refresh token 在短窗口内只轮转一次 const inflight = new Map }>(); function isAccessTokenExpired(token: string | undefined): boolean { if (!token) return true; try { const seg = token.split(".")[1]?.replace(/-/g, "+").replace(/_/g, "/"); if (!seg) return true; const payload = JSON.parse(atob(seg)) as { exp?: number }; // 只读取过期时间,验签由后端负责 return typeof payload.exp !== "number" ? true : Date.now() >= (payload.exp - EXP_SKEW_SECONDS) * 1000; } catch { return true; } } async function rotateRefreshToken(refreshToken: string, csrf: string): Promise { const cached = inflight.get(refreshToken); if (cached && Date.now() - cached.at < DEDUP_TTL_MS) return cached.p; const p = (async (): Promise => { const res = await fetch(`${API_BASE}/api/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, Cookie: `j13_refresh=${refreshToken}; j13_csrf=${csrf}`, }, cache: "no-store", }).catch(() => null); if (!res) return { ok: false, setCookies: [] }; // 成功=新三枚 cookie;失败(refresh 过期/被吊销)=后端下发的清除指令 const setCookies = res.headers.getSetCookie(); return { ok: res.ok && setCookies.length > 0, setCookies }; })(); inflight.set(refreshToken, { at: Date.now(), p }); return p; } // 从 Set-Cookie 头提取 name=value function parseCookiePair(setCookie: string): [string, string] | null { const pair = setCookie.split(";", 1)[0] ?? ""; const eq = pair.indexOf("="); if (eq <= 0) return null; return [pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()]; } export async function middleware(req: NextRequest) { const refreshToken = req.cookies.get("j13_refresh")?.value; const accessToken = req.cookies.get("j13_token")?.value; // 游客,或 access 仍然有效:直接放行 if (!refreshToken || !isAccessTokenExpired(accessToken)) { return NextResponse.next(); } const csrf = req.cookies.get("j13_csrf")?.value ?? ""; try { const { ok, setCookies } = await rotateRefreshToken(refreshToken, csrf); if (ok) { // 以旧 jar 为基础覆盖轮转结果,保证本次 SSR 的 cookies() 读到新 access const jar = new Map(); req.cookies.getAll().forEach((c) => jar.set(c.name, c.value)); for (const sc of setCookies) { const parsed = parseCookiePair(sc); if (parsed) jar.set(parsed[0], parsed[1]); } const headers = new Headers(req.headers); headers.set("Cookie", [...jar].map(([k, v]) => `${k}=${v}`).join("; ")); const res = NextResponse.next({ request: { headers } }); // 原样透传,HttpOnly/Path/SameSite/Secure 等属性全部以后端为准 for (const sc of setCookies) res.headers.append("Set-Cookie", sc); return res; } if (setCookies.length > 0) { // refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转 const res = NextResponse.next(); for (const sc of setCookies) res.headers.append("Set-Cookie", sc); return res; } } catch { // 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底 } return NextResponse.next(); } export const config = { // 仅拦截页面与 RSC 请求;/api 由客户端 fetchWithRefresh 处理,静态资源放行 matcher: [ "/((?!api/|_next/static/|_next/image/|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|woff2?)$).*)", ], };