Skip to content

OIDC 排障报告

本文记录 iam-nodejs-web.vercel.app + iam-nodejs-server.vercel.app 架构下,Better Auth OIDC Provider 集成的完整排障过程。目标是解释问题为何出现、如何定位,以及最终为什么当前方案可以稳定跑通。

在接入 OIDC Debugger 测试授权码模式时,曾出现以下问题:

  • /api/auth/oauth2/consent 返回 invalid_signature
  • 未登录进入 /oauth-login 后,邮箱密码登录成功但无跳转,或提示“登录失败,请检查账号密码”
  • 无痕模式可通过,普通 Chrome 模式失败
  • 登录成功后偶发再次回到登录页

问题不是单点 bug,而是以下几个因素叠加:

  1. oauth_query 不是当前页面 URL 上那条签名串,导致服务端验签失败。
  2. 浏览器直接访问后端域名时,Session Cookie 种在 iam-nodejs-server.vercel.app,前端页面无法读取,表现为“登录后又回登录页”。
  3. 未登录 OIDC 流程里,POST /api/auth/sign-in/email 成功后返回的是 302 Location,如果前端用 fetch/SDK AJAX 调用,会把“成功继续 OIDC 流程”误判成失败。
  4. 普通 Chrome 模式带有旧 Cookie、Vercel Toolbar、Draft Mode 等站点污染状态,而无痕模式环境干净,因此两者表现不同。

授权参数是带签名的流程上下文

Section titled “授权参数是带签名的流程上下文”

Better Auth 在授权端点会对 query 参数签名,并附加 expsig。核心逻辑在 authorize.ts

const params = new URLSearchParams(ctx.query)
params.set('exp', String(exp))
const signature = await makeSignature(params.toString(), ctx.context.secret)
params.append('sig', signature)

这意味着登录页和 consent 页 URL 上看到的 query 不是普通参数,而是“当前这次 OIDC 流程的签名上下文”。

后续 /sign-in/email/oauth2/consent/oauth2/continue 如果收到 oauth_query,oauth-provider hook 会重新验签。核心逻辑在 oauth.ts

let queryParams = new URLSearchParams(query)
const sig = queryParams.get('sig')
queryParams.delete('sig')
const verifySig = await makeSignature(queryParams.toString(), ctx.context.secret)

只要提交的 oauth_query 不是当前页面 URL 上那条签名串,就会得到 invalid_signature

1. oauth_query 不是当前页面 URL 那条

Section titled “1. oauth_query 不是当前页面 URL 那条”

这是最直接的 invalid_signature 来源。实际排查中出现过:

  • 页面地址栏上的 query 是一组新的 state/code_challenge/exp/sig
  • 提交到 /oauth2/consent 的却是上一轮流程中的旧 oauth_query

一旦两者不一致,服务端重算签名后就无法通过校验。

根本原因通常是:

  • 前端从缓存、旧状态、旧 props 中取值,而不是从当前 window.location.search 取值
  • 登录成功后手动重建了一次 /oauth2/authorize?...,导致当前页面与后续提交的 query 不再属于同一轮流程

如果浏览器直接访问:

https://iam-nodejs-server.vercel.app/api/auth/...

那么 Session Cookie 会被种在 iam-nodejs-server.vercel.app
而页面实际运行在:

https://iam-nodejs-web.vercel.app

此时前端站点读不到 Cookie,看起来就像“刚登录完又变成未登录”。

因此浏览器侧必须统一访问:

https://iam-nodejs-web.vercel.app/api/auth/...

再由 web 的 rewrite 转发到 server。

3. 未登录 OIDC 场景下,/sign-in/email 不是普通 AJAX 登录接口

Section titled “3. 未登录 OIDC 场景下,/sign-in/email 不是普通 AJAX 登录接口”

在携带 oauth_query 的情况下,/api/auth/sign-in/email 登录成功后,oauth-provider 的 after hook 会自动恢复原始 OIDC 流程,逻辑在 oauth.ts

最终服务端会返回:

302 Location: https://oidcdebugger.com/debug?code=...&state=...

这其实表示:

  • 用户登录成功
  • Session 已创建
  • OAuth 授权码已签发
  • 浏览器应继续跳转到第三方回调地址

如果前端用 fetchauthClient.signIn.email() 去调用,它会把这次请求当成脚本请求,而不是浏览器导航。

结果是:

  • fetch 看到 302
  • 继续尝试访问跨域的 oidcdebugger.com
  • 触发 CORS,或者进入 SDK error 分支
  • UI 误显示“登录失败”

所以这个场景的最优解不是继续调整 fetchOptions,而是直接使用浏览器原生表单提交,让浏览器自己处理 302 Location

排查中发现普通模式请求带有:

  • __vercel_toolbar=1
  • x-vercel-draft-status: 1
  • 旧站点 Cookie

说明普通模式存在 Vercel Toolbar、Draft Mode、历史会话等污染状态。
无痕模式没有这些遗留上下文,因此流程更稳定。

前端 authClient 指向:

https://iam-nodejs-web.vercel.app/api/auth

并由 Next.js rewrite 统一转发到:

https://iam-nodejs-server.vercel.app/api/auth/:path*

这样可以同时保证:

  • 浏览器 Cookie 同域
  • 实际 OAuth Provider 仍由同一个后端服务处理

2. 后端环境变量保持真实服务地址

Section titled “2. 后端环境变量保持真实服务地址”

后端 BETTER_AUTH_URL 仍设置为:

https://iam-nodejs-server.vercel.app/api/auth

这是服务端元数据、issuer、token endpoint 等逻辑的真实来源。

Section titled “3. consent 页面显式提交当前页面的 oauth_query”

page.tsx 中,提交授权时显式携带:

oauth_query: searchParams.toString()

不再依赖自动注入,直接确保回传的是当前页面 URL 上那条签名串。

4. OAuth 专用登录页改为原生表单提交

Section titled “4. OAuth 专用登录页改为原生表单提交”

oauth-login-form.tsx 中做了两件事:

  • 邮箱密码登录改为原生 <form method="POST" action="/api/auth/sign-in/email">
  • 企业微信登录改为动态创建原生表单,提交到 /api/auth/sign-in/social

两种方式都会显式提交当前页面的 oauth_query

这样做的好处是:

  • 302 Location 由浏览器原生处理
  • 不再依赖 fetch 的跨域重定向行为
  • 不会再把“成功继续 OIDC 流程”误判为失败

index.ts 中增加了以下日志:

  • URL
  • Host
  • x-vercel-id
  • Referer
  • Referer Query === Submitted oauth_query

这些日志用于快速确认:

  • 当前请求是否来自同一部署实例
  • consent 页提交的 query 是否就是页面地址栏上的那条
  1. OIDC Debugger 访问授权端点
    https://iam-nodejs-web.vercel.app/api/auth/oauth2/authorize
  2. web rewrite 到 server
  3. server 检测到用户未登录,签名后重定向到
    https://iam-nodejs-web.vercel.app/oauth-login?...&exp=...&sig=...
  4. 用户在 OAuth 登录页提交原生表单,同时带上 oauth_query
  5. server 登录成功并自动恢复原始 OIDC 流程
  6. 如果需要授权确认,进入 /consent?...&exp=...&sig=...
  7. consent 页提交当前页面的 oauth_query
  8. server 验签通过,签发 code 并 302 回第三方 redirect_uri
  9. OIDC Debugger 再通过
    https://iam-nodejs-web.vercel.app/api/auth/oauth2/token 交换 token
  1. oauth_query 不是普通参数,而是带签名的流程上下文,必须来自当前页面。
  2. OIDC 登录页是授权链路的一部分,不能简单套用普通 AJAX 登录页实现。
  3. 浏览器访问域名和后端真实服务域名必须分层处理: 浏览器走 web,同域种 Cookie;server 负责真实 OAuth Provider 行为。
  4. 无痕模式能通、普通模式不能通,优先怀疑浏览器状态污染,而不是协议实现本身。
  5. 对 OAuth/OIDC 这类多跳转流程,日志中必须包含请求 URL、host、referer、deployment id 和关键 query。

目前 OIDC Debugger 已可以稳定跑通完整流程,包括:

  • 未登录进入 OAuth 登录页
  • 邮箱密码登录
  • consent 授权
  • 返回 authorization code
  • token endpoint 交换 token