目录

搞懂 OAuth2 + OIDC + PKCE:从一条真实授权链接说起

最近在看一个 CLI 工具的登录流程,抓到了这样一条授权请求:

https://auth.openai.com/oauth/authorize?
  client_id=app_EMoamEEZ73f0CkXaXp7hrann
  &code_challenge=UsFd8nKJgN7J1HPXy5ednto7neuboHlJFH14bLsjC_o
  &code_challenge_method=S256
  &redirect_uri=http://localhost:1455/auth/callback
  &response_type=code
  &scope=openid+email+profile+offline_access
  &state=703a2e3719c8fde2dad10480346f7fb6

参数一堆,看起来花里胡哨。我本来只想快点拿到 token,结果越挖越发现自己之前对「授权码模式」的理解其实是半吊子。

这篇文章就把 OAuth2、OIDC、PKCE 这几个东西串起来讲清楚,尤其是 PKCE 到底在防什么。

名词解释

先把后文会反复出现的词对齐一下:

名词 一句话解释
OAuth 2.0 授权协议,解决「应用能不能代替用户访问资源」
OIDC 建在 OAuth2 上的身份层,解决「这个用户是谁」
Authorization Code 授权码,用户同意后返回的一次性中间凭证,用来换 Token
Access Token 访问令牌,拿去调受保护 API 用
ID Token 身份令牌(通常是 JWT),证明用户身份,OIDC 才有
Refresh Token 刷新令牌,Access Token 过期后用来换新的,一般要申请 offline_access
PKCE Proof Key for Code Exchange,防止授权码被截获后冒用的机制
code_verifier 客户端本地生成的随机串,换 Token 时必须带上
code_challenge code_verifier 的哈希结果,授权请求时发给服务器
state 随机值,用来防 CSRF,确认回调是自己发起的
Discovery OIDC 自动发现机制,通过 /.well-known/openid-configuration 拿配置
JWKS JSON Web Key Set,一组公钥,用来验证 ID Token 签名

OAuth2 和 OIDC 到底差在哪

很多人把它们混着说,其实分工很明确:

协议 主要解决什么
OAuth 2.0 授权:这个应用能不能代替用户访问某些资源
OIDC(OpenID Connect) 认证:这个用户到底是谁

OIDC 是建在 OAuth2 上面的。它多了一个 ID Token(通常是 JWT),里面带着用户身份信息。
只拿 Access Token 只能调接口,并不代表你正式知道「登录的人是谁」。有了 ID Token,身份才算落地。

现在常见的「用某某账号登录」,底层基本都是 OIDC。

标准流程长什么样

以授权码模式 + PKCE 为例,大致是这样:

  1. 客户端本地生成一个随机字符串 code_verifier
  2. 算出 code_challenge = BASE64URL(SHA256(code_verifier))
  3. 跳转到授权服务器,带上 code_challenge、state、scope 等参数
  4. 用户登录并同意授权
  5. 授权服务器重定向回客户端,带上 code 和原来的 state
  6. 客户端用 code + 原始的 code_verifier 去换 Token
  7. 服务器验证 SHA256(code_verifier) 是否等于之前存的 code_challenge,通过后返回 Access Token、ID Token,有时还有 Refresh Token

我抓到的那条链接,走的就是这个流程。回调回来时变成了:

http://localhost:1455/auth/callback?
  code=ac_mvIVkAYv8aJHc7Gvxi_FNkjgLHaX6qq1LffvTSIMqEc.OWTb9zFp3qv7hYyIdG3RHBi8HVEBhsDJER2_fbqBrmA
  &scope=openid+email+profile+offline_access
  &state=703a2e3719c8fde2dad10480346f7fb6

state 对得上,说明不是被伪造的回调。接下来客户端就要拿这个 code 去换真正的 Token 了。

为什么需要 PKCE?我一开始没想通的地方

PKCE 的核心验证就一句话:

hash(code_verifier) == code_challenge

我第一反应是:这哈希算法是公开的啊(一般是 SHA-256),那算出来肯定一致,这能防什么?

后来才反应过来——安全不靠哈希保密,靠的是 code_verifier 本身不泄露。

code_challenge 会随着授权请求发出去,服务器会存下来。
但 code_verifier 一直只活在合法客户端的内存里,直到最后换 Token 的那一步才发送。

真正要防的攻击是「授权码被截获」。

在移动 App 或某些桌面场景里很常见:

  • 登录成功后,授权服务器通过自定义 URL Scheme 把带 code 的链接重定向回来
  • 恶意 App 也可以注册同一个 Scheme,把这个重定向抢走
  • 于是它轻松拿到了 code

如果没有 PKCE,公共客户端又没有 client_secret,攻击者拿着偷来的 code 直接去 Token 接口换,基本就能成功。

有了 PKCE 之后,即使 code 被偷了,攻击者没有 code_verifier,换 Token 时验证直接失败。

所以:

  • 偷 code 在某些环境下相对容易(重定向劫持)
  • 直接偷最终 Token 难得多(它走的是后端 HTTPS 接口,不会出现在重定向 URL 里)

PKCE 的价值就是把「偷到 code 就能换 Token」变成「偷到 code 也没用」。

Discovery 和 JWKS 顺便提一下

现代 OIDC 里还会碰到这两个:

  • Discovery:访问 /.well-known/openid-configuration,能自动拿到授权端点、Token 端点、JWKS 地址等配置,不用写死。
  • JWKS:一组公钥。ID Token 是用私钥签的,客户端下载 JWKS 里的公钥来验证签名,确认 Token 没被篡改、确实是这个发行方发的。

它们和 PKCE 不是同一层的东西,但经常一起出现。

常见疑问

Q:前面换来换去那么多,不就是为了最后拿 Token 吗?为什么盯着 code 防?

因为在真实攻击面里,code 暴露的机会比最终 Token 大。Token 交换发生在客户端和授权服务器之间的直接请求里,而 code 常常会经过一次浏览器/系统级的重定向,容易被截。

Q:哈希是公开算法,那验证不是必过吗?

对合法客户端必过。对攻击者不过,因为他根本拿不到原始的 code_verifier。SHA-256 是单向的,从 code_challenge 反推几乎不可行。

Q:公共客户端为什么不直接用 client_secret?

因为 secret 放在前端或 App 里等于公开。PKCE 就是给「无法安全保存 secret 的客户端」准备的替代方案。

Q:state 和 PKCE 有什么区别?

state 主要防 CSRF(确认回调是自己发起的那一次)。
PKCE 防的是授权码本身被第三方截获后冒用。两个解决的问题不一样,经常一起用。

小结

这条看起来复杂的授权链接,其实就是在做一件事:
在「用户已经同意」和「客户端真正拿到 Token」之间,加一道只有合法客户端才能跨过的门槛。

OAuth2 负责授权,OIDC 补上身份,PKCE 则把授权码模式在公共客户端上的漏洞补上。
理解这三者之后,再看各种「登录」实现,就不会只觉得是一堆参数在跳来跳去了。