← All posts

我们的登录界面恰好卡了 10 秒。罪魁祸首是我们自己的安全响应头。

Authagonal·July 23, 2026
oidcssocspfrontenddebuggingwar-story

大约有一周时间,打开我们门户的回访用户会看到 "Loading…" 这个词在屏幕上停留十秒,然后登录页面才出现。不是偶尔如此,也不是大概如此。是每一次都恰好十秒,之后一切又都完美运行。初次访问的人从未见过这个画面,只有此前登录过的人才会遇到。

一个耗时随机的 bug 是性能问题。一个耗时恰好十秒的 bug 则是一份供词。健康的 Web 请求里,没有任何东西会把自己凑成一个整齐的十的幂。那个数字不是某些真实工作的总和,它是一次超时的上限,而超时意味着某处有某个东西正耐心地等待着一样永远不会到来的东西。

这就是关于它在等待什么、以及它所等待的东西为何被一个我们引以为傲的安全响应头拦下的故事。

门户在挂载时会做什么

我们的门户是一个单页应用。它加载时,在给你显示任何内容之前,会先试着回答一个问题:你是否已经登录了?用 OIDC 优雅地做到这一点的方式是一次静默检查。应用会问身份提供方:"如果这个浏览器已经有了会话,就在不打扰用户的情况下给我一个新令牌。"我们的客户端库 oidc-client-ts 把这个能力暴露为 signinSilent(),而我们在挂载时从一个 renewSession() 辅助函数里调用了它。

那次静默检查有两种运行方式。如果应用手上握有一个刷新令牌,库就会做一次安静的后台通道交换,不涉及任何 UI,你就登进去了。如果它没有握着刷新令牌,库就会退回到更老的机制:它会打开一个指向身份提供方 authorize 端点、带着 prompt=none 的隐藏 iframe,然后等待那个 iframe 把结果回传过来。这个 iframe 的全部意义就在于它是不可见的。你本不该看到它,而在健康的配置下你也确实看不到,因为它会在毫秒之内完成。

而我们的这个,则根本从未完成过。

我们亲手筑起的那堵墙

这个 iframe 加载的是认证主机。而这个认证主机,就像我们运行的每一个值得被认真对待的主机一样,会发送两个响应头,它们的全部职责就是宣告 "你不许把我放进 frame 里":

  • X-Frame-Options: DENY
  • Content-Security-Policy: frame-ancestors 'none'

这些是防点击劫持的防御措施,而且它们是正确的。一个能把你的登录页面放进 iframe 的攻击者,可以把它悬浮在一个诱饵之下,诱骗用户把真实的凭据输入到某个看似无害的东西里,然后把这些凭据收割走。frame-ancestors 'none' 是一条现代指令,它表示任何源,哪怕是我们自己的源,都不得嵌入这个页面。我们是刻意把它打开的。这正是安全审查会去寻找并给予嘉许的那类东西。

于是当 oidc-client-ts 对那个主机打开它的隐藏 iframe 时,浏览器精确地做了我们吩咐它做的事:它拒绝在 frame 里渲染那个页面。而残酷之处就在这里。一个被拒绝的 frame 不会抛出异常。没有错误事件供库去捕获,没有被拒绝的 promise,没有一行控制台输出。那个 iframe 就那样空着,无限期地待在那里。从库的角度看,还什么都没发生,所以它只能做它唯一能做的事:等待它的超时。那个超时,也就是默认的 silentRequestTimeout,是十秒。

十秒钟里,一个隐藏的 iframe 盯着一堵空白的墙,然后库放弃了,promise 终于被拒绝,应用耸耸肩,把你重定向到真正的登录页面,然后一切都能用了。这次卡顿从来都不是一次失败。它是一次绕了远路的成功,途中穿过了一个注定失败的 iframe。

两件都正确的事,一道糟糕的接缝

真正让这件事难以看清的是:没有任何东西是坏的。安全响应头是对的。静默续期的回退机制也是对的,那是一种合法且被广泛使用的 OIDC 模式。每一个组件的行为都恰如其设计,也恰如任何审查者所愿。那十秒的卡顿并不住在它们中的任何一个里面。它住在它们之间的空隙里,住在它们各自对对方所做的假设里。SSO 库假设它可以给身份提供方套上一个 frame。身份提供方则假设任何人都永远不该被允许给它套上 frame。两个假设都站得住脚。它们只是彼此不相容,而没有任何一个文件包含着这处矛盾。

它究竟为什么会去动用那个 iframe

这仍然留下一个问题。那条快速路径,也就是刷新令牌交换,本会完全跳过 iframe。为什么回访用户偏偏落到了慢速路径上?因为他们手上没有刷新令牌可握。而他们之所以没有刷新令牌,是因为我们自己的门户 OAuth 客户端在配置时没有带上 AllowOfflineAccess,也就是那个授权客户端可以获得刷新令牌的标志。没有离线访问,就没有刷新令牌,就没有快速路径,于是每一个回访用户都被推进了那个永远无法加载的 iframe。

那才是真正的缺陷,而且它是一个散布在每一个租户身上的数据问题,不是我们发布一次就能搞定的一行代码改动。所以修复方案是一个对账服务,它会在启动时把 AllowOfflineAccess 重新应用到每一个租户的门户客户端上,在下一次部署时纠正整支机队,而无需任何人手动去动一个租户。刷新令牌又开始流动起来,快速路径也自行复活了。

修复,以及教训

对账服务修好了根本原因。但即便登录最终落到了慢速路径上,它也不应该停滞十秒,所以我们也加固了那道接缝。renewSession() 现在会先检查已存储的用户:如果手上没有刷新令牌,它就短路,立即返回空,跳过那个它已经知道注定失败的 iframe,直接把用户送去交互式登录。刷新令牌那条快速路径则原封不动。而作为对任何仍会打开 iframe 的后台续期的兜底,我们把超时从十秒砍到了五秒,这样最坏情况也只糟糕了一半。

我们真正记住的教训,是关于一类 bug,而不是这一个个例。卡顿是一种 bug,尽管它不发出任何错误、任何异常、日志里也没有任何一行红字。它留下的唯一证据就是流逝的时间。而当那段时间是一个整齐的整数时,别去猎捕需要优化的慢速工作。去猎捕一次超时,然后找到它另一端那个正在悄无声息地、永久地、永远不会回应的东西。我们的那个东西是一个 iframe,它正礼貌地敲着一扇我们故意闩死的门。

如果你更希望你的登录流程一开始就知道:一个被锁死的认证主机和一个静默续期的 iframe 无法共存,那么这道接缝我们已经替你撞过了,好让你永远不必去撞。Authagonal 把 SSO 的管道和安全响应头作为一个整体系统来交付,这个系统是被放在一起测试过的,而不是让你在每次页面加载十秒的代价里去发现:它们其实是不相容的两块各自正确的半成品。