GitHub OAuth 登录突然全挂:一次 RFC 9207 灰度撞上 Auth.js 占位 issuer 的排查实录
某天中午点 GitHub 登录,浏览器转了一圈回到首页,URL 挂着 ?error=Configuration。两台生产服务器(境外 + 国内)同时挂,而且——最要命的巧合——当天上午刚部署了数据库读写分离。
换你是第一反应,大概率也是:「今天的部署搞坏了登录」。
这篇文章复盘这次事故的完整排查链:怎么排除部署嫌疑、真正的根因是什么、一行修复背后的验证方法,以及几条能带走的通用经验。
现象:error=Configuration,日志里一条陌生的报错
前端能看到的只有 /?error=Configuration,信息量为零。真正有价值的在服务端日志:
[auth][error] CallbackRouteError: Read more at https://errors.authjs.dev#callbackrouteerror
[auth][cause]: OperationProcessingError: unexpected "iss" (issuer) response parameter value
[auth][details]: {
"expected": "https://authjs.dev"
}
两个关键信息:
- 报错发生在 OAuth 回调阶段,是
iss(issuer)参数校验不过; - 期望值
https://authjs.dev明显不是一个真实的 issuer——这是 Auth.js 的占位符。
日志里还伴生一些 MissingCSRF 报错,后来确认是用户在错误页反复重试产生的噪音,不是线索。排查时先抓最早、最具体的那条错误,别被伴生噪音带偏。
第一嫌疑人:当天的部署(以及怎么无罪释放它)
当天上午刚上线数据库读写分离(Prisma 读路由扩展 + 只读副本),登录中午挂掉——时间上严丝合缝。但「时间吻合」只是相关性,定罪需要证据链。三步排除:
第一步:diff 依赖 lock 文件。 这次部署动了什么?git diff 看 package-lock.json,auth 相关依赖(next-auth、@auth/core、@auth/prisma-adapter、底层的 oauth4webapi)零变化,只新增了一个 Prisma 读写分离扩展。登录链路的代码和依赖都没动。
第二步:查数据库里最后一次成功登录。 Session 表显示最后一次成功登录是 6 天前。也就是说,这 6 天里根本没人登录过——今天的失败是灰度命中后的第一次尝试,而不是「部署前能登、部署后不能登」。所谓的时间吻合,只是采样稀疏造成的错觉。
第三步:看故障面。 两台机器跑同一份代码、同时挂。如果是某台机器的环境问题(比如副本延迟、网络),不会两台同步表现一致。同一份代码 + 外部变更,才能解释「同时全挂」。
三步下来,部署无罪。嫌疑指向外部——GitHub 自己变了。
真正的根因:GitHub 灰度 RFC 9207 × Auth.js 占位 issuer
拿 unexpected "iss" response parameter 去搜,很快命中两个实锤:
- GitHub 官方社区讨论 #192143:GitHub 从 2026 年 4 月起分批向 OAuth 授权回调注入
iss=https://github.com/login/oauth参数——这是 RFC 9207(OAuth 2.0 Authorization Server Issuer Identification)的实现,目的是防混淆攻击(mix-up attack),本意是好的; - langfuse 的同款事故 #13091:一模一样的报错,一模一样的原因。
Auth.js 这边的行为是:对没有配置 issuer 的纯 OAuth2 provider(GitHub 不是 OIDC,历史上确实不需要 issuer),底层 oauth4webapi 用占位符 https://authjs.dev 兜底。只要回调里不带 iss,这个占位符永远不会被比对,相安无事;一旦上游开始返回 iss,RFC 9207 要求客户端校验它——占位符 vs 真实值,必然不匹配,登录必挂。
所以这是一次典型的「双方都没错,但组合起来就炸」:GitHub 按 RFC 加固安全,Auth.js 按 RFC 校验 iss,唯独占位符设计假设了「上游永远不发 iss」——这个假设在 2026 年 4 月之后分批失效。哪个应用哪天挂,取决于灰度什么时候轮到它的 OAuth App。
顺带说明:Google 登录不受影响——Google 走 OIDC,Auth.js 内置了正确的 issuer(https://accounts.google.com),本来就按真实值校验。
修复:一行配置
给 GitHub provider 显式声明 issuer:
// src/lib/auth.ts
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
// GitHub 2026-04 起分批启用 RFC 9207,回调携带 iss 参数;Auth.js 默认占位
// issuer(https://authjs.dev) 校验不过会致 CallbackRouteError → error=Configuration
issuer: "https://github.com/login/oauth",
}),
就这一行。难的从来不是修,是把「今天部署的锅」这个第一直觉排除掉、找到真凶。
验证:走不完整 OAuth 流程时的「探针法」
修复好写,怎么在不真人点登录的前提下验证它生效?完整 OAuth 流程需要真实浏览器会话(GitHub 授权页 + cookie),没法轻易自动化。这里用了一个值得留档的技巧——构造最小假回调探针:
curl -s "http://localhost:3001/api/auth/callback/github?code=fake&state=fake&iss=https%3A%2F%2Fgithub.com%2Flogin%2Foauth"
这个请求带着 GitHub 会注入的 iss 参数,但 code/state 是假的、也没有 cookie。观察服务端报什么错:
- 修复前:报
unexpected "iss" response parameter——和生产事故完全一致,说明探针成功复现了问题; - 修复后:同一探针的报错迁移为
pkceCodeVerifier缺失——iss 校验已放行,请求死在了「探针没带 cookie」这个预期的下一关。
错误从本关卡迁移到下一关卡,就是本关卡已修复的自动化证明。 不需要走完全流程,只需要证明流程推进过了出问题的那一关。部署落地后在生产打同一探针复核,最后真人点一次登录做端到端确认——三级验证,全部通过。
复盘:四条带得走的经验
1. 故障与部署撞时间 ≠ 因果。 排除方法是机械的三板斧:① diff 依赖 lock 看相关链路是否真的被动过;② 查数据库/日志里「最后一次成功」的时间——如果成功记录早于部署很多天,「部署前正常」很可能只是没人触发;③ 看故障面,多机同挂指向共同因素(代码或外部),单机挂指向环境。
2. 第三方灰度是隐形变更源。 你的代码零改动、依赖零升级,行为照样会变——上游在按自己的节奏灰度。遇到「什么都没动却挂了」,把外部服务商的 changelog / 社区讨论纳入排查范围,拿最具体的报错原文去搜,通常有同行已经踩过。
3. 用非 OIDC OAuth provider 时,issuer 显式配置。 只要框架文档里出现「placeholder issuer」这类兜底设计,就意味着上游某天开始返回 iss 时会炸。RFC 9207 会被越来越多授权服务器采纳,这个坑不止 GitHub 一家会触发。
4. 探针验证法。 无法自动化完整流程时,构造带关键参数的最小假请求,用「错误迁移到下一关卡」证明目标关卡已通过。比「改完祈祷 + 真人试」快得多,也能在 CI 里跑。