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,而是以下几个因素叠加:
oauth_query不是当前页面 URL 上那条签名串,导致服务端验签失败。- 浏览器直接访问后端域名时,Session Cookie 种在
iam-nodejs-server.vercel.app,前端页面无法读取,表现为“登录后又回登录页”。 - 未登录 OIDC 流程里,
POST /api/auth/sign-in/email成功后返回的是302 Location,如果前端用fetch/SDK AJAX 调用,会把“成功继续 OIDC 流程”误判成失败。 - 普通 Chrome 模式带有旧 Cookie、Vercel Toolbar、Draft Mode 等站点污染状态,而无痕模式环境干净,因此两者表现不同。
Better Auth 源码中的关键约束
Section titled “Better Auth 源码中的关键约束”授权参数是带签名的流程上下文
Section titled “授权参数是带签名的流程上下文”Better Auth 在授权端点会对 query 参数签名,并附加 exp 与 sig。核心逻辑在 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 流程的签名上下文”。
服务端会重新验证 oauth_query
Section titled “服务端会重新验证 oauth_query”后续 /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 不再属于同一轮流程
2. Cookie 域不一致
Section titled “2. Cookie 域不一致”如果浏览器直接访问:
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 授权码已签发
- 浏览器应继续跳转到第三方回调地址
如果前端用 fetch 或 authClient.signIn.email() 去调用,它会把这次请求当成脚本请求,而不是浏览器导航。
结果是:
fetch看到302- 继续尝试访问跨域的
oidcdebugger.com - 触发 CORS,或者进入 SDK error 分支
- UI 误显示“登录失败”
所以这个场景的最优解不是继续调整 fetchOptions,而是直接使用浏览器原生表单提交,让浏览器自己处理 302 Location。
4. 普通模式与无痕模式差异
Section titled “4. 普通模式与无痕模式差异”排查中发现普通模式请求带有:
__vercel_toolbar=1x-vercel-draft-status: 1- 旧站点 Cookie
说明普通模式存在 Vercel Toolbar、Draft Mode、历史会话等污染状态。
无痕模式没有这些遗留上下文,因此流程更稳定。
最终修复方案
Section titled “最终修复方案”1. 浏览器侧统一走 web 域名
Section titled “1. 浏览器侧统一走 web 域名”前端 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 等逻辑的真实来源。
3. consent 页面显式提交当前页面的 oauth_query
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 流程”误判为失败
5. 增加后端链路调试日志
Section titled “5. 增加后端链路调试日志”在 index.ts 中增加了以下日志:
URLHostx-vercel-idRefererReferer Query === Submitted oauth_query
这些日志用于快速确认:
- 当前请求是否来自同一部署实例
- consent 页提交的 query 是否就是页面地址栏上的那条
当前可工作的稳定流程
Section titled “当前可工作的稳定流程”- OIDC Debugger 访问授权端点
https://iam-nodejs-web.vercel.app/api/auth/oauth2/authorize - web rewrite 到 server
- server 检测到用户未登录,签名后重定向到
https://iam-nodejs-web.vercel.app/oauth-login?...&exp=...&sig=... - 用户在 OAuth 登录页提交原生表单,同时带上
oauth_query - server 登录成功并自动恢复原始 OIDC 流程
- 如果需要授权确认,进入
/consent?...&exp=...&sig=... - consent 页提交当前页面的
oauth_query - server 验签通过,签发 code 并
302回第三方redirect_uri - OIDC Debugger 再通过
https://iam-nodejs-web.vercel.app/api/auth/oauth2/token交换 token
oauth_query不是普通参数,而是带签名的流程上下文,必须来自当前页面。- OIDC 登录页是授权链路的一部分,不能简单套用普通 AJAX 登录页实现。
- 浏览器访问域名和后端真实服务域名必须分层处理: 浏览器走 web,同域种 Cookie;server 负责真实 OAuth Provider 行为。
- 无痕模式能通、普通模式不能通,优先怀疑浏览器状态污染,而不是协议实现本身。
- 对 OAuth/OIDC 这类多跳转流程,日志中必须包含请求 URL、host、referer、deployment id 和关键 query。
目前 OIDC Debugger 已可以稳定跑通完整流程,包括:
- 未登录进入 OAuth 登录页
- 邮箱密码登录
- consent 授权
- 返回 authorization code
- token endpoint 交换 token