Next.js JWT认证下getServerSideProps处理Cookie的更优方案
(编辑补充:以下是对所解决问题的更清晰总结,现有方案最终也采用了和spreadCookiesToContext(ctx, apiResponse);相同的实现思路)
核心需求与问题背景:
- 页面渲染前需要在服务端拉取用户数据,流程第一步是验证加密存储在浏览器Cookie中的access_token,同时需要支持页面渲染前完成多个Cookie的新增、更新、删除操作
- 用户Token验证逻辑需要复用,因此要在
getServerSideProps函数作用域外的独立模块统一处理Cookie逻辑,比如getServerSideProps内部发起API请求时统一处理Cookie,这个场景实现存在较多坑- 实际调试发现:
getServerSideProps中调用Cookie处理逻辑,和普通API端点中的调用逻辑存在差异:Postman调接口时Cookie可以正常设置,但getServerSideProps加载页面时Cookie无法写入浏览器- 问题根因:
getServerSideProps不是浏览器像调用API路由那样直接发起的请求,因此不能用和API路由相同的方式设置浏览器Cookie,必须操作Next.js的上下文对象,不能直接操作Node.js原生request对象
目前在Next.js中使用getServerSideProps()在页面加载前从外部API拉取数据,基础拉取逻辑本身运行正常,参考代码如下:
async function getInitialValues( ctx: GetServerSidePropsContext, ): Promise<GetServerSidePropsResult<Props>> { const { query } = ctx; try { const authTestData = await axiosAuthTest(ctx); spreadCookiesToContext(ctx, authTestData); const data = await axiosFetchData(query, ctx); return { props: { data: data.data, query, }, }; } catch (e) { // 错误处理逻辑 } }
但调用需要有效access_token的受保护API路由、且同一个getServerSideProps函数中需要发起多次API调用时,必须重复编写如下代码同步Cookie:
spreadCookiesToContext(ctx, apiResponse);
该函数的定义如下:
function spreadCookiesToContext( ctx: GetServerSidePropsContext, res: AxiosResponse, ): Record<string, string> | null { const cookies: string[] | undefined = res.config.headers['set-cookie'] ?? res.headers['set-cookie']; if (cookies) { ctx.res.setHeader('Set-Cookie', cookies); return cookie.parse(cookies.join(';')); } return null; }
这个函数接收getServerSideProps()上下文和Axios响应对象,把响应中的set-cookie头写入上下文响应。需要这个逻辑的原因是用户access_token有效期仅5分钟,access_token过期时Axios拦截器会自动刷新Token后重试请求,拦截器返回的响应会携带新的refresh_token和access_token,需要保存到用户Cookie中,其中refresh_token为httpOnly属性,仅能在服务端访问。
目前想到的优化方案是使用authGuard包装器包裹所有受保护路由的请求:
const testData = await authGuard<AuthTestResposne>( axiosAuthTest(ctx), ctx, );
该函数会调用对应API接口,自动将响应中的Cookie同步到传入的getServerSideProps()上下文,无需在每个getServerSideProps函数中重复调用spreadCookiesToContext(),所有鉴权API请求都通过这个authGuard包装器调用即可,包装器代码如下:
async function authGuard<T>( apiFunc: Promise<AxiosResponse<T>>, ctx: GetServerSidePropsContext, ): Promise<AxiosResponse<T>> { const data = await apiFunc; spreadCookiesToContext(ctx, data); return data; }
Axios拦截器代码
有提问提到“为什么不在拦截器中直接设置Cookie?”,目前拦截器中已经实现了Cookie设置逻辑,代码如下:
if (error.response.status === 401 && !originalRequest._retry) { originalRequest._retry = true; const config: AxiosRequestConfig | undefined = !hasWindow ? { headers: { cookie: originalRequest.headers.cookie }, withCredentials: true, } : undefined; try { const { data, headers } = await axiosInternalRefresh(config); const cookieConfig = !hasWindow ? { 'set-cookie': headers['set-cookie'] } : undefined; const updatedRequestWithRefreshedToken = { ...originalRequest, headers: { ...originalRequest.headers, Authorization: `Bearer ${data.accessToken}`, ...cookieConfig, }, }; return await axiosInstance(updatedRequestWithRefreshedToken); } catch (err) { return Promise.reject(new APIError(error)); } }
虽然拦截器中已经做了Cookie设置,但新的access_token仍然无法保存到客户端浏览器,会导致用户被登出,鉴权API的重试逻辑本身可以正常执行并返回数据。
其中axiosInternalRefresh(config);用于调用API的Token刷新接口,返回新的access_token和refresh_token对。
目前的实现需要手动封装包装器同步Cookie,拦截器刷新的Token也无法自动同步到浏览器,是否存在更简洁的方式实现上述鉴权、Cookie同步、Token自动刷新的完整功能?或者是否有完全不同的实现思路可以解决该问题?
现有实现思路本身没有问题,拦截器没法自动同步Cookie的核心原因是:服务端运行的Axios实例拿不到当前请求的ctx对象,自然没法把刷新拿到的Cookie写到Next.js的上下文响应头里。不需要推翻现有逻辑,做两层改造就能完全去掉重复代码,实现全自动同步:
1. 改造Axios实例创建逻辑,给服务端请求绑定上下文
不要用全局单例的Axios实例处理服务端请求,改成按请求创建实例的模式,把当前请求的ctx绑定到Axios实例的配置上,这样拦截器里就能直接拿到ctx对象,不需要在外层手动传。
参考实现:
import { GetServerSidePropsContext } from 'next'; import axios, { AxiosInstance } from 'axios'; // 客户端用全局单例即可,不存在ctx隔离问题 const clientAxiosInstance = axios.create({ withCredentials: true, baseURL: process.env.NEXT_PUBLIC_API_BASE_URL }); // 服务端每次请求创建带ctx的独立实例 export const getServerAxiosInstance = (ctx: GetServerSidePropsContext): AxiosInstance => { const instance = axios.create({ baseURL: process.env.API_BASE_URL, // 服务端可以走内网地址提升性能 headers: { cookie: ctx.req.headers.cookie || '' // 自动把当前请求的Cookie带到上游API }, withCredentials: true, // 把ctx挂到实例配置上,拦截器里可以直接读取 _ctx: ctx } as any); // 响应拦截器:所有响应(包括Token刷新后的重试响应)自动同步Cookie到ctx instance.interceptors.response.use( (res) => { const setCookieHeader = res.headers['set-cookie']; if (setCookieHeader) { const existingCookies = ctx.res.getHeader('Set-Cookie') as string[] || []; // 追加而不是覆盖Set-Cookie头,避免多次请求设置的Cookie互相覆盖 ctx.res.setHeader('Set-Cookie', [...existingCookies, ...setCookieHeader]); } return res; }, async (error) => { const originalRequest = error.config; // 原有401刷新逻辑直接迁移,刷新接口返回的Set-Cookie会被上面的响应拦截器自动同步到ctx if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { const { data } = await instance.post('/auth/refresh'); originalRequest.headers.Authorization = `Bearer ${data.accessToken}`; return instance(originalRequest); } catch (refreshErr) { // 刷新失败直接重定向到登录页,不需要上层页面再处理 ctx.res.writeHead(302, { Location: '/login' }); ctx.res.end(); return Promise.reject(refreshErr); } } return Promise.reject(error); } ); return instance; };
2. 简化getServerSideProps里的调用逻辑
改造之后完全不需要authGuard包装器,也不需要手动调用spreadCookiesToContext,所有通过这个服务端Axios实例发出的请求,不管是第一次请求还是Token刷新后的重试请求,只要上游API返回了Set-Cookie头,都会被自动追加到Next.js的响应头里,最终自动写到用户浏览器。
原有的getInitialValues可以简化成这样:
async function getInitialValues( ctx: GetServerSidePropsContext, ): Promise<GetServerSidePropsResult<Props>> { const { query } = ctx; // 直接获取绑定了当前ctx的Axios实例 const serverAxios = getServerAxiosInstance(ctx); try { // 所有请求直接用这个实例发送,不需要手动处理Cookie const authTestData = await serverAxios.get('/auth/test'); const data = await serverAxios.get('/data', { params: query }); return { props: { data: data.data, query, }, }; } catch (e) { // 仅处理业务错误,Token刷新失败已经在拦截器里处理重定向 } }
关键注意事项
- 注意:
Set-Cookie头是多值数组格式,不要直接调用setHeader覆盖原有值,要把多次请求返回的Cookie追加到已有值后面,不然同一次getServerSideProps里多次请求设置的Cookie会互相覆盖,最终只有最后一个请求的Cookie会生效 - 注意:服务端Axios实例绝对不能做成全局单例,多用户并发请求时会出现上下文串号、Cookie写到其他用户响应里的严重安全问题,必须每个服务端请求单独创建实例,请求处理完成后实例随上下文一起销毁,做到用户级隔离
- 如果使用Next.js 13+的App Router,逻辑完全一致,只需要把
GetServerSidePropsContext换成框架提供的cookies()、headers()操作方法即可,核心逻辑还是拿到服务端响应对象后自动追加Set-Cookie头 - 客户端侧的Axios实例不需要这套逻辑,浏览器本身会自动处理Cookie存储,拦截器只需要保留401刷新Token的逻辑即可
内容的提问来源于stack exchange,提问作者Johny Wills

