authagonal

大约一周以来,打开我们门户的返回用户都会看到屏幕上的“Loading…” 停留整整 10 秒,之后登录页才会出现。这种情况不是偶尔发生,也不是大约 10 秒,而是每次都正好 10 秒,之后一切工作正常。新访客从未遇到过,只有曾经登录过的用户才受影响。

耗时不确定的 bug 是性能问题,而耗时正好 10 秒的 bug 就是招供。健康的 Web 请求不会把自己舍入到整十的数字。这个数字不是实际工作的总和,而是超时的上限;超时意味着某处正在耐心地等待一件永远不会到达的东西。

下面就是它在等待什么,以及它等待的东西为何被我们引以为傲的安全响应头阻止。

门户在挂载时做了什么

我们的门户是一个单页应用。加载时,在显示任何内容之前,它会尝试回答一个问题:你是否已经登录?用 OIDC 礼貌地回答这个问题的方法是进行一次静默检查。应用会询问身份提供者:“如果此浏览器已有会话,请在不打扰用户的情况下给我一个新令牌。”我们的客户端库 oidc-client-ts 将此暴露为 signinSilent(),我们在 mount 时从 renewSession() 帮助函数中调用它。

静默检查有两种运行方式。如果应用持有刷新令牌,库会执行一个安静的后通道交换,不涉及任何 UI,你就已登录。如果它没有持有刷新令牌,库会回退到旧机制:打开一个指向身份提供者 authorize 端点的隐藏 iframe,带上 prompt=none,并等待该 iframe 回传结果。iframe 的全部意义在于它是不可见的。你永远不应该看到它,而且在健康的设置中你也确实看不到,因为它会在毫秒级内解决。

我们的 iframe 从未解决。

我们自己筑起的墙

iframe 会加载 auth 主机。而 auth 主机,像我们运行的每一个需要严肃对待的主机一样,发送两个响应头,其全部职责就是说“你不能把我放在 frame 里”:

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

这些是点击劫持防御措施,而且是正确的。能够 iframe 你登录页的攻击者可以将它浮动在诱饵之下,诱使用户在看似无害的页面中输入真实凭据并窃取它们。frame-ancestors 'none' 是现代指令,规定任何来源(甚至是我们自己)都不得嵌入此页面。我们有意开启了它。这正是安全审查会寻找并奖励的那类措施。

因此,当 oidc-client-ts 打开隐藏的 iframe 指向该主机时,浏览器完全执行了我们告诉它的操作:拒绝在 frame 中渲染该页面。而残酷之处在于,被拒绝的 frame 不会抛出错误。没有可供库捕获的 error 事件,没有被拒绝的 promise,没有控制台行。iframe 只是空空地待在那里,永远如此。从库的角度看,什么都没有发生,所以它做了唯一能做的事:等待其超时。该超时,即默认的 silentRequestTimeout,是 10 秒。

10 秒后,隐藏的 iframe 盯着空白的墙,然后库放弃,promise 最终被拒绝,应用耸耸肩并将你重定向到真正的登录页,一切恢复正常。卡顿从来不是一次失败,而是成功地走了一条注定失败的 iframe 的风景路线。

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

真正难以察觉的原因是没有任何东西是损坏的。安全响应头是正确的。silent-renew 回退是正确的,是合法且广泛使用的 OIDC 模式。每个组件都完全按照设计运行,也完全符合任何审查者的期望。10 秒卡顿并不存在于它们中的任何一个里。它存在于它们之间的空间,存在于它们对彼此的假设中。SSO 库假设它可以 frame 身份提供者。身份提供者假设任何人都不得 frame 它。这两个假设都有道理。只是它们互不兼容,而且没有一个单独的文件包含这个矛盾。

为什么它会去尝试 iframe

这仍然留下一个问题。快速路径,即刷新令牌交换,本可以完全跳过 iframe。为什么返回用户会走到慢路径?因为他们没有刷新令牌可持。而他们没有刷新令牌,是因为我们自己的门户 OAuth 客户端在配置时没有启用 AllowOfflineAccess,这个标志授权客户端被颁发刷新令牌。没有离线访问,没有刷新令牌,没有快速路径,每个返回用户都被分流到那个永远无法加载的 iframe。

这才是真正的缺陷,而且是一个散布在每个租户上的数据问题,而不是我们可以一次性发布的一行代码修改。因此,修复方案是一个协调服务,它在启动时将 AllowOfflineAccess 重新应用到每个租户的门户客户端,在下一次部署时修正整个机群,而无需任何人手动接触租户。刷新令牌重新开始流动,快速路径自动恢复。

修复与教训

协调服务修复了根本原因。但即使最终走上慢路径,登录也不应停顿 10 秒,因此我们也加固了接缝。renewSession() 现在会先检查存储的用户:如果手头没有刷新令牌,它会立即短路并返回空,跳过它已知注定失败的 iframe,直接将用户送往交互式登录。刷新令牌快速路径保持不变。作为任何仍会打开 iframe 的后台更新的后备,我们将超时从 10 秒缩短到 5 秒,使最坏情况减半。

我们真正留下的教训是关于一类 bug,而不是这一个实例。卡顿是一个 bug,即使它不发出错误、异常或日志中的红线。它留下的唯一证据是流逝的时间。而当这个时间是一个干净的整数时,不要去寻找需要优化的慢工作。去寻找一个超时,然后找到它另一端那个静静地、永久地、永远不会回答的东西。我们的例子是一个 iframe,在我们故意闩上的门上礼貌地敲门。

如果你希望你的登录流程已经知道,一个被锁定的 auth 主机和 silent-renew iframe 无法共存,那么这是一个我们已经遇到过的接缝,这样你就永远不必遇到它。Authagonal 将 SSO 管道和安全响应头作为一个经过共同测试的系统一起提供,而不是两个你会在每页加载 10 秒时发现不兼容的正确半部分。