import { NextResponse, type NextRequest } from "next/server"; import { TOKEN_COOKIE, REFRESH_COOKIE, CSRF_COOKIE } from "@/lib/cookies"; // SSR 登录态保障: // access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转, // 新 cookie 同时①注入本次请求头供 layout 的 /api/me 使用 ②透传给浏览器。 // refresh token 是一次性轮转(旧的立即吊销),而 RSC 预取与真实导航、甚至多 // 运行时实例可能几乎同时发起轮转: // - 同一实例内用 in-flight Promise 去重(只合并进行中的请求,失败不缓存); // - 跨实例的重复请求由后端宽限期兜底(返回同一个新 token 对)。 // 服务端专用地址;边缘部署(如 Cloudflare Workers)必须通过 BACKEND_URL / // NEXT_PUBLIC_API_URL 显式配置(生产构建不保留 localhost 兜底,避免在边缘 // 环境发起必然失败的请求;未配置时放行,由客户端 fetchWithRefresh 兜底)。 const API_BASE = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === "production" ? "" : "http://localhost:3001"); const EXP_SKEW_SECONDS = 30; // 提前 30s 视为过期,规避服务端时钟差 const REFRESH_TIMEOUT_MS = 8000; type RefreshResult = { ok: boolean; setCookies: string[] }; // 进行中的轮转表:key 为 refresh token 的 SHA-256(不持有明文), // 请求结束即删除,失败结果绝不缓存 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 hashToken(token: string): Promise { const data = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); return Array.from(new Uint8Array(data), (b) => b.toString(16).padStart(2, "0")).join(""); } // 从合并形式的 set-cookie 头中拆出多条(部分运行时没有 headers.getSetCookie())。 // Expires 属性格式含 ", ",不能直接按逗号切分:只有顶层段是 "非属性名=..." // 才是一条新 cookie 的开始,其余片段拼回上一条。 const COOKIE_ATTR_NAMES = new Set([ "expires", "max-age", "domain", "path", "samesite", "secure", "httponly", "priority", ]); function splitCombinedSetCookie(raw: string): string[] { const segments = raw.split(", "); const cookies: string[] = []; for (const seg of segments) { const pair = seg.split(";", 1)[0] ?? ""; const eq = pair.indexOf("="); const name = eq > 0 ? pair.slice(0, eq).trim().toLowerCase() : ""; if (eq > 0 && name && !COOKIE_ATTR_NAMES.has(name)) { cookies.push(seg); } else if (cookies.length > 0) { cookies[cookies.length - 1] += ", " + seg; } } return cookies; } function readSetCookies(res: Response): string[] { const headers = res.headers as unknown as { getSetCookie?: () => string[]; }; if (typeof headers.getSetCookie === "function") { try { const list = headers.getSetCookie(); if (list.length > 0) return list; } catch { // 落到手动解析 } } const raw = res.headers.get("set-cookie"); return raw ? splitCombinedSetCookie(raw) : []; } async function doRotate(refreshToken: string, csrf: string): Promise { const res = await fetch(`${API_BASE}/api/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, Cookie: `${REFRESH_COOKIE}=${refreshToken}; ${CSRF_COOKIE}=${csrf}`, }, cache: "no-store", signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS), }).catch(() => null); if (!res) return { ok: false, setCookies: [] }; // 成功=新三枚 cookie;失败(refresh 过期/被吊销)=后端下发的清除指令 const setCookies = readSetCookies(res); return { ok: res.ok && setCookies.length > 0, setCookies }; } // 同一 refresh token 的并发请求共享同一个进行中的 Promise(完成即删除,不缓存结果) async function rotateRefreshToken(refreshToken: string, csrf: string): Promise { const key = await hashToken(refreshToken); const existing = inflight.get(key); if (existing) return existing; const p = doRotate(refreshToken, csrf).finally(() => { inflight.delete(key); }); inflight.set(key, 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()]; } /** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉 */ function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }) { const headers = new Headers(init?.request?.headers ?? req.headers); headers.set("x-pathname", req.nextUrl.pathname); return NextResponse.next({ request: { headers } }); } export async function middleware(req: NextRequest) { const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value; const accessToken = req.cookies.get(TOKEN_COOKIE)?.value; // 游客、access 仍有效、或服务端地址未配置:直接放行 if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) { return withPathname(req); } const csrf = req.cookies.get(CSRF_COOKIE)?.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 = withPathname(req, { 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 = withPathname(req); for (const sc of setCookies) res.headers.append("Set-Cookie", sc); return res; } } catch { // 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底 } return withPathname(req); } export const config = { // 仅拦截页面与 RSC 请求;/api 由客户端 fetchWithRefresh 处理,静态资源放行 matcher: [ "/((?!api/|healthz|_next/static/|_next/image/|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|woff2?)$).*)", ], };