搞懂 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 为例,大致是这样:
- 客户端本地生成一个随机字符串
code_verifier - 算出
code_challenge = BASE64URL(SHA256(code_verifier)) - 跳转到授权服务器,带上
code_challenge、state、scope等参数 - 用户登录并同意授权
- 授权服务器重定向回客户端,带上
code和原来的state - 客户端用
code+ 原始的code_verifier去换 Token - 服务器验证
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 则把授权码模式在公共客户端上的漏洞补上。
理解这三者之后,再看各种「登录」实现,就不会只觉得是一堆参数在跳来跳去了。