Authentik OIDC 重定向死循环:Node.js res.setHeader 整头覆盖机制与会话丢失排障

· 3 分钟 · 系统工程 authentikoidcnodejscookies身份认证docker

全是成功日志的重定向死循环

这是一起表现极为诡异的静默故障。管理员访问受保护的 /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 操作:

  1. 写入刚生成的长期业务登录凭证(app_sess=<token>; HttpOnly; Secure; SameSite=Lax)。
  2. 将防重放的临时 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 自动化探测脚本进行端到端全链路验收:

无需修改上游身份提供商的任何参数,单靠规范底层 HTTP 头输出,就彻底斩断了重定向死循环。

🛡️ 生产级基础设施与系统架构

服务器频繁异常重启、云账单过高,或缺一位能穿透内核日志的架构师?

真正的生产环境稳定性,靠的是穿透表象抓出根因。我独立管理着跨 3 台物理/云主机、162 个容器的高可用自托管生产网络。

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts