OAuth 登录失败时,最后看到的错误往往没有指出真正出错的步骤。页面可能只显示“invalid_grant”“state mismatch”或“unable to find signing key”,但问题可能早在授权 URL 生成时就已经埋下:回调地址多了一个斜杠、verifier 被另一个标签页覆盖、服务器缓存了旧 JWKS,都会在流程末尾才爆出来。
比较省时间的做法不是从报错文字猜,而是按一次授权请求真正发生的顺序检查:发起授权、接收回调、用 code 换 Token、验证 ID Token。每一步只核对属于它的输入和状态。
先保存一次登录尝试的最小记录#
调试前给当前登录尝试分配一个内部 request ID,然后记录这些非敏感信息:
- 使用的 authorization endpoint 和 token endpoint。
client_id、redirect_uri、scope,以及是否使用 OIDC。- PKCE 方法、verifier 长度、challenge,不能把生产 verifier 写进长期日志。
- state 和 nonce 的摘要或临时会话键,不要把完整 Token 公开到工单。
- 回调时间、Token 端点状态码和 OAuth error 字段。
- ID Token Header 中的
alg、kid,以及当前 JWKS 的 key ID 清单。
不要把 client secret、code_verifier、authorization code、Access Token 或私钥整段写入日志。排错需要的是确认同一次请求有没有串线,不是把所有凭据永久保存。
第一步:检查授权 URL 和 PKCE#
可以用 PKCE 生成器与 OAuth 请求构建工具 复现当前请求。RFC 7636 要求 code_verifier 长度为 43–128 个允许字符,S256 的 challenge 计算方式是:
BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))授权 URL 只发送 code_challenge 和 code_challenge_method=S256。原始 verifier 留在发起登录的客户端会话里,拿到 authorization code 后才发送给 Token 端点。如果在授权 URL 里看到了 verifier,或换 Token 时重新随机生成了一条 verifier,流程一定无法通过。
多标签页是常见问题。用户连续点击两次登录,第二次生成的 state 和 verifier 覆盖了第一次;第一个窗口稍后回调,就会拿新 verifier 去换旧 code。保存时应按一次登录尝试隔离,而不是只在 localStorage 里放一个全局 pkce_verifier。
还要逐字符比较 redirect_uri。协议、域名、端口、路径、大小写、末尾斜杠和 URL 编码都可能参与授权服务器的精确匹配。授权请求与 Token 请求也应使用同一个回调地址。
第二步:分清 state 和 nonce#
state 负责把回调绑定到当前浏览器发起的授权尝试。回调里的 state 应与发起前保存的随机值完全一致,不一致就停止处理,不能为了“先登录进去”而忽略。
nonce 属于 OIDC。它通常随授权请求发送,并出现在签发后的 ID Token Claim 中。验证方要确认 Token 里的 nonce 与当前登录尝试保存的原值一致。state 防的是回调串线与登录 CSRF,nonce 绑定的是 ID Token,两者不能共用一个检查后就当作全部完成。
如果只做 OAuth API 授权而不请求 openid,不一定会得到 ID Token;这时也不要凭空要求 nonce Claim。先确认 scope 和服务商实际支持的流程。
第三步:换 Token 时检查 code 的生命周期#
authorization code 通常一次有效且生命周期很短。invalid_grant 常见原因包括:
- code 已被同一个回调重试使用过。
- code 属于另一个 client_id 或 redirect_uri。
- PKCE verifier 与原 challenge 不匹配。
- code 已过期,或服务器时间明显错误。
- public client 错误地发送了前端源码中的假 client secret。
浏览器单页应用、桌面和移动应用无法可靠保守 client secret,应使用 public client + PKCE。能安全保存凭据的后端 confidential client 则继续按授权服务器要求认证。PKCE 证明换 Token 的客户端与发起授权的是同一方,并不代替客户端认证、state 或回调地址检查。
第四步:用 kid 找到正确的 JWK#
拿到 ID Token 后,可以先用 JWT 解码器 查看 Header 和 Claims,但“能解码”不代表 Token 可信。验证签名前,服务端应从预先信任的 issuer 配置找到 JWKS 地址,再用 Header 中的 kid 选择公钥。
把 JWKS 粘贴到 JWK / JWKS / PEM 转换与校验工具,重点看:
- 是否存在与 Token
kid完全相同的公钥。 - 同一个 JWKS 中是否出现重复 kid。
kty、alg、use和key_ops是否与预期验证算法一致。- 公开集合里是否误放了
d、p、q或 oct 对称密钥。 - PEM 转换后是否仍得到相同的 RFC 7638 Thumbprint。
不要看到 Token Header 写 alg=RS256 就自动接受 RS256。允许算法、issuer 和 audience 应由应用配置固定。否则攻击者可以改 Header,引导验证代码进入不该使用的算法分支。
密钥轮换期间,issuer 可能同时发布新旧公钥。验证端应按 HTTP 缓存规则保存 JWKS;遇到未知 kid 时允许受控刷新一次,但不能每个请求都绕过缓存抓远端,也不能在刷新失败时改成“不验签也接受”。旧 Token 还没过期前,发布方也不应过早移除旧公钥。
最后检查 Claims 和时间#
签名通过只是第一关。随后还要精确检查:
iss是否等于预先配置的发行方。aud是否包含当前 client_id 或 API audience。exp是否未过期,nbf是否已经生效。iat和 Token 生命周期是否符合系统策略。- nonce、授权范围、账号状态和撤销信息是否有效。
JWT 时间检查器 可以帮助核对 NumericDate 秒数和 leeway。不要用几小时的容差掩盖服务器时钟错误;leeway 只应解决小范围时钟偏差。
一条 OAuth 登录链路里,PKCE、state、nonce、code、JWK 和 Claims 各管一段。按发生顺序保存最小证据,再在失败的那一段停下来查,通常比围着最后一个错误码反复改配置快得多。