PassportJS Google登录后设置HttpOnly Cookie并重定向报错求助
问题现象
使用PassportJS的Google OAuth2策略时,需要在服务端设置HttpOnly类型的Refresh Token Cookie,之后重定向到客户端URL,但无法同时完成这两个操作,抛出错误:
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
at new NodeError (node:internal/errors:371:5)
at ServerResponse.setHeader (node:_http_outgoing:576:11)
at ServerResponse.header (H:\1HF_solution\location\server\node_modules\express\lib\response.js:794:10)
at ServerResponse.location (H:\1HF_solution\location\server\node_modules\express\lib\response.js:915:15)
at ServerResponse.redirect (H:\1HF_solution\location\server\node_modules\express\lib\response.js:953:18)
当前仅能单独执行设置Cookie或重定向操作,无法同时实现两者。
相关代码
google-strategy.ts
import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { IGoogleDto } from '../interfaces/user-interface'; import userService, { IServerData } from '../service/user-service'; passport.use( new GoogleStrategy( { clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, callbackURL: '/api/oauth/google/callback', }, async function ( accessToken: any, refreshToken: any, profile: { _json: IGoogleDto }, done: (arg0: any, arg1: boolean | IServerData | { registrationToken: string }, arg2: undefined) => void ) { try { const googleDto: IGoogleDto = profile._json; const result = await userService.findOrCreateForGoogle(googleDto); // result: {refreshToken:'',userDto:'user data object'} done(null, result, null); } catch (err) { done(err, null, null); } } ) ); passport.serializeUser((user, done) => { done(null, user); }); passport.deserializeUser((user, done) => { done(null, user); });
oauth-router.ts
import { Router } from 'express'; import passport from 'passport'; import { IRTRequest } from '../interfaces/user-interface'; import { IServerData } from '../service/user-service'; require('../strategy/google-strategy'); const router = Router(); const CLIENT_URL = process.env.CLIENT_URL; router.get('/logout', (req, res, next) => { req.logout(function (err) { if (err) { next(err); } }); res.clearCookie('refreshToken'); res.redirect(CLIENT_URL); }); router.get('/google/success', (req: IRTRequest, res, next) => { if (req.user) { res.status(200).json({ success: true, message: 'successfully', user: req.user, }); res.cookie('refreshToken', req.user.refreshToken, { maxAge: 30 * 24 * 60 * 60 * 1000, httpOnly: true, }); } }); router.get('/google/failed', (req, res) => { res.status(401).json({ success: false, message: 'failure', }); }); router.get( '/google/callback', passport.authenticate('google', { successRedirect: 'success', failureRedirect: 'failed', }) ); router.get('/google', passport.authenticate('google', { scope: ['email', 'profile'] }));
客户端调用代码
const GoogleHandler = () => { window.open(process.env.REACT_APP_GOOGLE_OAUTH_URL, '_self'); };
尝试过的修改(未解决)
router.get('/google/callback', async (req: IRTRequest, res, next) => { return await passport.authenticate( 'google', { successRedirect: 'login/success', failureRedirect: 'failed', }, async (err, data: IServerData) => { // set cookie here and redirect but i have error } )(req, res); });
错误原因
- 响应顺序错误:原
/google/success路由中,先调用res.status(200).json(...)发送完整响应(包含响应头和体),之后再执行res.cookie(...)时,响应已完成,无法再修改响应头(Cookie属于响应头字段),触发报错。 - 选项冲突:修改回调路由时,同时使用
successRedirect选项和自定义回调函数。Passport的authenticate中间件会优先执行successRedirect发送重定向响应,后续自定义回调中再操作响应会导致重复发送头部。
解决方案
方案1:自定义回调中处理Cookie与重定向(推荐)
放弃successRedirect和failureRedirect选项,使用Passport自定义回调函数,手动控制Cookie设置和重定向流程:
修改oauth-router.ts中的/google/callback路由:
router.get('/google/callback', (req: IRTRequest, res, next) => { passport.authenticate('google', async (err, user: IServerData) => { // 处理认证错误 if (err) return next(err); // 认证失败,跳转到失败页 if (!user) return res.redirect('/api/oauth/google/failed'); // 设置HttpOnly Refresh Token Cookie res.cookie('refreshToken', user.refreshToken, { maxAge: 30 * 24 * 60 * 60 * 1000, // 30天有效期 httpOnly: true, // 禁止前端JS读取,防XSS secure: process.env.NODE_ENV === 'production', // 生产环境仅HTTPS传输 sameSite: 'strict' // 防CSRF,根据需求可选'lax' }); // 重定向到客户端应用 res.redirect(CLIENT_URL); })(req, res, next); });
同时删除原/google/success路由(因为直接重定向到客户端,无需返回JSON),/google/failed路由可保留用于认证失败场景。
方案2:调整响应顺序(适用于需返回JSON的场景)
如果业务需要在认证成功时返回JSON响应(如AJAX请求场景),必须先设置Cookie再发送响应:
修改/google/success路由:
router.get('/google/success', (req: IRTRequest, res, next) => { if (req.user) { // 先设置Cookie res.cookie('refreshToken', req.user.refreshToken, { maxAge: 30 * 24 * 60 * 60 * 1000, httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' }); // 再发送JSON响应 res.status(200).json({ success: true, message: 'successfully', user: req.user, }); } else { res.redirect('/api/oauth/google/failed'); } });
注意:当前客户端是通过window.open直接跳转,此方案会让浏览器显示JSON内容而非回到客户端应用,因此方案1更符合需求。
补充说明
HttpOnlyCookie是安全设计,避免Refresh Token被XSS攻击窃取,不可通过前端JS访问。- 生产环境务必开启
secure: true,确保Cookie仅通过HTTPS协议传输。 sameSite属性可根据业务需求选择strict或lax,降低CSRF攻击风险。
内容的提问来源于stack exchange,提问作者Nikita Kruglyakov

