Authentik OIDC 重定向死循环:Node.js res.setHeader 整头覆盖机制与会话丢失排障
全是成功日志的重定向死循环
这是一起表现极为诡异的静默故障。管理员访问受保护的 /admin 后台,系统按预期重定向到 Authentik 统一认证网关。在完成二次验证并点击授权后,页面跳回应用的 OIDC 回调地址。
然而浏览器并没有进入后台,而是瞬间再次被踢回 Authentik。Authentik 检测到用户已经持有有效会话,立刻签发新的授权码并再次跳回回调页。这个过程周而复始,直到浏览器触发死循环熔断并报错 ERR_TOO_MANY_REDIRECTS。
查看应用容器的服务端日志,里面一片祥和,甚至没有任何警告信息:
127.0.0.1 - - [14/Feb/2025:08:12:04 +0000] "GET /admin HTTP/1.1" 302 -
127.0.0.1 - - [14/Feb/2025:08:12:05 +0000] "GET /callback?code=... HTTP/1.1" 302 -
127.0.0.1 - - [14/Feb/2025:08:12:05 +0000] "GET /admin HTTP/1.1" 302 -
127.0.0.1 - - [14/Feb/2025:08:12:06 +0000] "GET /callback?code=... HTTP/1.1" 302 -
每一个跳转都是合法的 HTTP 302。Authentik 那边记录的 Token 交换全部成功。OIDC 的 JWT 签名、过期时间均完全合规。但下游应用就是顽固地认为“当前请求未登录”。
根因:res.setHeader 是如何抹掉会话的
该服务采用原生 Node.js 构建,没有包裹沉重的全栈框架。在处理 OIDC 回调握手时,服务端需要先后完成两项 Cookie 操作:
- 写入刚生成的长期业务登录凭证(
app_sess=<token>; HttpOnly; Secure; SameSite=Lax)。 - 将防重放的临时 OIDC 校验 Cookie 清空作废(
app_oidc=; Max-Age=0)。
初始的辅助写入函数实现非常直接:
function setCookie(res, cookieStr) {
res.setHeader('Set-Cookie', cookieStr);
}
在 Node.js 原生 http 模块(OutgoingMessage)的设计中,如果给 setHeader(name, value) 传入字符串参数,它的行为是整头覆盖,而不是追加。
在回调控制器里,代码先后执行了两次调用:
// 第一步:写入登录会话
setCookie(res, `app_sess=${sessionToken}; Path=/; HttpOnly; Secure; SameSite=Lax`);
// 第二步:清理临时状态
setCookie(res, `app_oidc=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`);
第二次调用执行的瞬间,第一步刚写入的 app_sess 被整个抹掉。
使用 curl -v 抓取回调的原始响应头,真相一目了然:
< HTTP/1.1 302 Found
< Location: /admin
< Set-Cookie: app_oidc=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax
浏览器从未收到过 app_sess。在跟随 302 跳转到 /admin 时,由于请求中缺乏凭据,路由门禁再次拦截,并重新将请求丢回 Authentik。由于链路上每个组件都在忠实执行自身逻辑,整条故障链路没有产生任何一行 stderr 报错。
修复:基于数组的多响应头合并
修复的核心在于:任何向 Set-Cookie 追加内容的操作,必须先取出已存在的字段并转为数组保存。
🔍 server.mjs — setCookie 修复实现(12 行)
function setCookie(res, cookieStr) {
const existing = res.getHeader('Set-Cookie');
let cookies = [];
if (Array.isArray(existing)) {
cookies = existing.concat(cookieStr);
} else if (typeof existing === 'string') {
cookies = [existing, cookieStr];
} else {
cookies = [cookieStr];
}
res.setHeader('Set-Cookie', cookies);
}
当传入值为字符串数组时,Node 的 HTTP 序列化器会自动将其拆解为多条独立的 Set-Cookie: 标头吐给客户端。
结果
代码更新后,编写了专用的 Playwright 自动化探测脚本进行端到端全链路验收:
- 请求
/admin正确触发 302 并转跳 Authentik。 - 自动完成 SSO 表单提交与授权确认。
- 回调响应头中完整包含
app_sess与过期的app_oidc。 - 最终落地页以 HTTP 200 打开,管理后台的 21 行数据表格全部渲染成功。
无需修改上游身份提供商的任何参数,单靠规范底层 HTTP 头输出,就彻底斩断了重定向死循环。
服务器频繁异常重启、云账单过高,或缺一位能穿透内核日志的架构师?
真正的生产环境稳定性,靠的是穿透表象抓出根因。我独立管理着跨 3 台物理/云主机、162 个容器的高可用自托管生产网络。