site logo

Marico's space

让 OAuth 可测试:重新思考 JavaScript 中的 OIDC 客户端

前端技术 2026-05-03 12:05:56 25

说实话,OAuth/OIDC 的测试一直是前端圈子的老大难问题。我见过太多项目要么跳过这部分测试,要么用一堆 mock 把测试变成了自欺欺人的游戏。这篇文章提出的思路让我眼前一亮——把协议逻辑和运行时 IO 彻底分开,这不仅仅是架构上的优化,更是一种思维方式的转变。

真正的痛点是什么

大多数 JavaScript 项目里的 OAuth/OIDC 集成,测试起来都非常鸡肋。常规操作就是 mock 网络请求、伪造重定向、stub token 响应、模拟浏览器环境。一顿操作下来,你测试的其实不是 OAuth,而是你写的那些 mock。

一个典型的 OIDC 登录流程测试大概长这样:拦截对 token 端点的 fetch 调用,返回一段写死的 JSON 数据,然后检查 UI 有没有更新。这只能证明你的代码能处理某种特定结构的数据,根本没法证明你的代码正确实现了 OIDC 协议本身。

这个区别可不是小事。OAuth 和 OIDC 是安全协议,测试它们的价值在于验证真实行为:真正的重定向、真正的 token 交换、真正的状态校验。一旦把所有外部交互都换成 stub,测试就变成了套套逻辑(tautology)。

问题不在 OAuth 本身,而在于我们构建客户端的方式。

为什么 OIDC 客户端难测

大多数 OIDC 库把好几层关注点混在一个抽象里:

  • 协议逻辑:PKCE(一种增强授权协议安全性的机制)code challenge、state 参数、nonce 校验、token 解析
  • HTTP 层:fetch 调用、拦截器、重试逻辑
  • 存储层:localStorage、sessionStorage、cookies
  • 框架集成:React hooks、Angular services、Vue composables

这会导致大量隐式行为。一个普普通通的 useAuth() hook 可能在校件完成挂载之前就触发了一系列操作:获取 OIDC 配置文档、检查本地存储的 token、启动后台刷新、更新响应式状态。这整个过程中,每一步对调用方都是黑盒。

更糟糕的是,这造成了和运行时的强耦合。协议逻辑没法脱离 fetch、DOM 和框架特定的渲染逻辑来单独测试。于是大家的本能反应就是全部 mock 掉——用 spy 替换 fetch,stub sessionStorage,fake 重定向。

当所有东西都耦合在一起,所有东西都得 mock。而当所有东西都 mock 了,你测的就是一个模拟物,而不是真实的东西本身。

换个思路:把 OIDC 当作协议来处理

OIDC 其实不需要做成运行时驱动的客户端。如果你仔细看看协议实际需要什么,会发现大部分都是纯计算:构建请求、验证回调、解析 token、检查过期时间。所有这些都是输入数据、输出数据,不需要 fetch,不需要 localStorage,甚至不需要 DOM。

协议是纯函数式的,IO 不是。把这两者混为一谈,是大多数库的通病。

架构转变:把协议和运行时分离

思路很简单:把 OIDC 客户端拆成两层。

第一层是函数式核心(functional core)。它只包含协议逻辑,不掺杂任何其他东西。没有 fetch 调用,没有存储访问,没有全局状态,没有任何框架依赖。每个函数接收明确的参数,返回确定的结果。比如一个 buildTokenRequest 函数,接收配置文档、授权码和 code verifier,返回一个包含 URL、请求头和请求体的对象。它不会发送请求——那是别人的活儿。

第二层是适配器(adapters)。每个适配器对应特定框架,负责处理核心层刻意回避的 IO 操作。React 适配器组合核心函数、fetch 和 React state。Angular 适配器用 HttpClient 和 services。Vue 适配器用 composables。Svelte 适配器用 stores。

适配器是薄薄的一层。它们调用核心函数来构建请求,用框架提供的 HTTP 机制执行请求,然后把响应传回核心函数进行解析和校验。

这样做的好处:

  • 协议逻辑零依赖,连 fetch 都不依赖,只用 Web Crypto API 生成 PKCE
  • 核心层完全不混入框架关注点。token 解析代码里没有 React 的影子
  • 没有隐藏的副作用。每个 IO 操作都是显式的,在适配器层清晰可见
  • 测试边界清晰。核心层可以用纯单元测试覆盖。适配器层用集成测试验证。两者互不依赖,不需要互相 mock

不用 mock 测试 OAuth

这才是架构真正发挥作用的地方。因为核心层是纯函数式的,你可以用简单的单元测试把它测透。传入一份配置文档,拿到授权 URL,校验参数——不需要 HTTP 服务器,不需要浏览器,不需要任何 mock。

但光测核心层只是故事的一半。真正的价值在于这种架构能在集成层面实现什么:针对真实身份提供者测试完整的 OIDC 流程。

测试用的是 Autentico,一个专为测试场景打造的轻量级 OIDC 提供者。Autentico 是一个零外部依赖的单二进制文件。在 CI 环境里,完整初始化大概 500 毫秒搞定:生成加密密钥、创建管理员用户、注册客户端、启动服务。这个速度足够为每个单独的测试启动一个全新的身份提供者实例。

目标不是测试 Autentico 本身,而是通过让提供者变得可丢弃,彻底告别 mock。

每个测试都有自己独立的 Autentico 实例:独立数据库、独立用户、独立注册的客户端。测试之间完全隔离,没有共享状态,没有残留会话,没有跨测试边界的 token 缓存。如果某个测试失败了,那是因为被测代码有问题,而不是因为前一个测试把身份提供者搞进了某种意外状态。

测试 fixture 全程程序化处理:

  • 生成随机加密密钥(access token、refresh token、CSRF、 RSA 签名密钥)
  • 创建全新的 SQLite 数据库
  • 运行初始化步骤,配置管理员用户
  • 在隔离端口启动服务
  • 注册 OAuth 客户端,配置正确的 redirect URI
  • 创建已知凭证的测试用户
  • 等待健康检查端点响应
  • 测试完成后清理所有资源

不需要手动配置,没有共享测试环境,不需要 Docker 容器。就一个二进制文件,一秒内启动完毕。

确定性的端到端测试

有了每个测试独享的真实身份提供者,端到端测试可以在真实浏览器里跑完整协议流程。

用 Playwright,每个测试都执行完整序列:导航到应用、点击登录、被重定向到身份提供者、填写凭证、提交、被带授权码重定向回来、用授权码换 token、获取用户信息、验证 UI 反映了已登录状态。

没有任何拦截,没有任何 stub。浏览器发的是真实的 HTTP 请求。身份提供者发的是用真实 RSA 密钥签名的真实 token。应用解析的是真实的 JWT claims,校验的是真实的 nonce。

测试不仅断言 UI 状态,还断言完整的协议序列。流量追踪器按顺序记录每一条发往身份提供者的 fetch 请求和浏览器导航,过滤出 OIDC 相关的路径。每个测试结束后,断言不仅验证登录成功,还验证预期的完整序列按顺序发生了:

GET  /.well-known/openid-configuration   # 应用加载,获取 OIDC 配置
NAV  /oauth2/authorize                    # 浏览器重定向到 IdP
GET  /.well-known/openid-configuration   # 回调后应用重新加载,再次获取配置
POST /oauth2/token                        # 用授权码换取 token
GET  /oauth2/userinfo                     # 获取用户资料

两次配置获取可不是 bug。中间有完整的页面导航。第一次是应用挂载时触发的,然后浏览器导航到授权端点。用户认证后,IdP 把用户带回来,应用从头重新加载,在 token 交换之前再次获取配置。序列追踪器把这个过程清晰展示出来了。之前版本的测试套件分别追踪 fetch 和导航,结果看起来像是两次配置获取同时发生。合并之后的序列追踪才揭示了真实的交错时序。

大多数测试只断言结果。这些测试同时断言协议本身。某个不应该发生的 token 刷新、某个缺失的 userinfo 请求、某个应该在某个 fetch 之前执行的导航提前触发了——这类问题 mock 风格的测试根本检测不到,因为 mock 只响应你预料之中的那些调用。

测试还验证安全属性:

  • token 永远不会存入 localStorage 或 sessionStorage
  • 回调 URL 参数(code、state)处理完后会被清除
  • 页面刷新后会话不会保留(纯内存存储)
  • 登出后按后退键不会暴露已认证内容
  • 篡改的 state 参数会触发正确的错误

每项测试都跑在真实流程上。断言 token 不在存储里之所以有意义,是因为真的发了 token、真的处理了 token。断言 state 不匹配之所以有意义,是因为真的发起了一次带真实 state 参数的授权请求。

跨框架运行测试

因为核心层和框架无关,每个适配器只是一层薄薄的包装,同一套测试可以在所有框架上跑。同一份测试文件同时测试 React、Angular、Vue、Svelte、Lit、Solid 和 Preact。每个框架用隔离端口启动自己的开发服务器、自己的 Autentico 实例和自己的数据库。

一个 shell 脚本编排这些运行,支持可配置的并行度。本地跑的时候,八个框架并行,完整套件一分钟内完成。CI 环境里则串行执行,避免资源超限。

测试名称带上框架标识,失败时一眼就能定位:

[React] OIDC Login Flow > completes full login flow with tokens
[Angular] RequireAuth > auto-refreshes expired token when navigating to protected page
[Vue] Security > tokens are not stored in localStorage or sessionStorage

这套配置能捕捉框架特定的问题。如果 Svelte 适配器的改动不小心让配置请求被触发了两次,即使 UI 行为看起来正常,流量断言也会失败。

mock 测不出来的东西

举一个具体的例子:token 刷新的竞态条件。

自动 token 刷新的测试是这样的:首先完成完整登录流程。然后在浏览器里 override Date.now,模拟时间快进到 token 过期之后。接着导航到一个受保护的页面。RequireAuth 守卫应该检测到 token 已过期,尝试刷新,刷新成功后让用户通过。

棘手的地方在于恢复时钟。刷新请求回来是个 macrotask,但从 Playwright 的 page.evaluate 恢复 Date.now 时,框架的状态更新是在微任务链里处理的。组件用新 token 重新渲染时,Date.now 还返回假的过期时间,又触发了一次刷新。

解决方案是在 Date.now 之外同时 patch window.fetch,然后在 fetch promise 链里恢复真实时钟,抢在框架处理响应之前。

这不是假设的边缘情况,是开发过程中真实出现的 bug。mock 风格的测试永远测不出来,因为 mock 同时控制了时钟和响应,根本没有真实的异步流来制造竞态条件。

再举一个例子:服务端撤销 refresh token 的测试。先在服务端撤销 refresh token,然后导航到受保护页面。守卫尝试刷新,从真实身份提供者那里得到失败响应,然后回退到完整登录重定向。用 mock 的话,就是从 stub 端点返回 400。而用真实提供者,撤销是真的,失败是真的,重定向也是真的。如果客户端的错误处理在解析提供者错误响应时有细微 bug,真实测试能抓住,mock 永远抓不到,因为 mock 返回的恰好是你预期的错误格式。

权衡

这套方案不是免费的,有实打实的代价。

跑一个真实身份提供者增加了 setup 复杂度。测试 fixture 比简单的 beforeEach 配 mock 麻烦多了。Autentico 二进制文件需要下载,每个测试都要承担启动服务器进程的成本。

单一测试提供者给你确定性行为,但不覆盖提供者特定的 quirks。真实的 OIDC 提供者在 token 格式、claim 结构和错误响应上都有微妙的差异。测 Autentico 验证的是协议本身,而不是每个提供者对协议的理解。

测试比纯单元测试慢。完整的 E2E 测试加上浏览器自动化、服务器启动和真实 HTTP 交换需要秒级耗时,而不是毫秒级。每个测试的 Autentico 实例大概增加 500 毫秒开销。对单个测试来说感知明显,但如果套件并行跑起来,还是可以接受的。

这不是最快的测试方式,但确实是最可靠的。套件全部通过的时候,你确信完整的 OIDC 流程在真实浏览器和真实提供者下是通的。失败的时候,失败指向的是真实问题,而不是 mock 和现实之间的差距。

总结

OAuth 测试本质上没那么难。难是因为把协议逻辑、IO 和框架关注点搅在一个抽象里。一旦分开,每块都可以按自己的方式测试。

协议层是纯计算,用输入输出来测。适配器层是框架特定的 IO,用真实提供者测。身份提供者启动得够快,可以是消耗性的——每个测试给一个全新的实例,彻底消灭共享状态。

套件通过的时候,你不是在信任 mock,是在验证协议本身。

这套方法实现在 oidc-js 里,一个零依赖、跨框架的 OIDC 客户端,围绕函数式核心和薄适配器构建,用 Autentico 做端到端测试——Autentico 正是为这种工作流量身打造的轻量级 OIDC 提供者。