OAuth 2.0 流程 授权码、PKCE 和客户端凭据
开发者指南:如何选择合适的OAuth 2.0流程。涵盖授权码(网页应用)、PKCE(单页应用和移动应用)以及客户端凭据(服务器到服务器)的使用,包含实际代码示例以及后期可能引发问题的常见错误。
三种流程涵盖了95%种真实的OAuth 2.0使用场景。规范定义了更多内容,但其余的已被弃用、属于边缘情况或两者兼有。在开始时选择正确的流程,可以避免在认证需求变更时进行痛苦的重构。
| 流程 | 何时使用 | 是否涉及用户? | 是否需要客户端密钥? |
|---|---|---|---|
| 授权码 | 具有后端服务器的网页应用 | 是的 | 是 —— 保留在服务器上 |
| 授权码 + PKCE | 单页应用、移动应用,或任何公开客户端 | 是的 | 不 |
| 客户端凭据 | 服务器到服务器(无用户) | 不 | 是 —— 保留在服务器上 |
授权码流程
这是具有后端服务器的网页应用的标准流程。访问令牌和客户端密钥永远不会触碰浏览器——令牌交换在服务器端完成。这才是整个流程的要点。
步骤1:重定向用户
构建一个授权URL并将其重定向到浏览器:
GET https://accounts.example.com/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid+profile+email
&state=RANDOM_CSRF_TOKEN
这 state 值是您重定向的CSRF防御。为每个流程生成一个全新的随机字符串,存储在会话中,并在用户返回时验证。如果跳过此检查,攻击者可以利用自己的授权码引导用户完成流程——静默地将用户的账户与攻击者的身份关联。
步骤2:处理回调
授权服务器将重定向回您的 redirect_uri 并附带一个短期的授权码:
GET https://yourapp.com/callback?code=AUTH_CODE&state=SAME_STATE_YOU_SENT
验证 state 与您存储的内容匹配。然后在服务器端交换该授权码。
步骤3:用授权码换取令牌(仅在服务器端)
POST https://accounts.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
响应:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "def50200a12b3c...",
"scope": "openid profile email"
}
将两个令牌都保留在服务器端。当 access_token 过期(检查 expires_in),使用 refresh_token 获取新的令牌,而无需再次让用户登录。
PKCE —— 适用于无法保存密钥的客户端
单页应用或移动应用没有安全的地方存放 client_secret。任何人都可以打开开发者工具并从您的JavaScript包中找到它。任何人都可以反编译您的APK。PKCE(代码交换的证明密钥,发音为“pixy”)通过一次性加密挑战解决了这个问题——无需共享密钥。
该流程与授权码流程相同,有两个新增内容:一个 code_verifier (您生成的随机字符串) 和一个 code_challenge (验证器的SHA-256哈希值,Base64URL编码)。您在交换令牌时提前发送挑战,然后在交换时证明您持有验证器。
步骤1:生成验证器和挑战
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64url(array);
}
async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64url(new Uint8Array(digest));
}
function base64url(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// Usage
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store codeVerifier in memory — NOT localStorage
将 code_verifier 存储在内存中——一个模块作用域变量,而不是localStorage。您将在令牌交换时发送它。
步骤2:授权请求——添加挑战
GET https://accounts.example.com/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid+profile
&state=RANDOM_CSRF_TOKEN
&code_challenge=BASE64URL_SHA256_OF_VERIFIER
&code_challenge_method=S256
步骤3:令牌交换——使用验证器代替客户端密钥
POST https://accounts.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&code_verifier=ORIGINAL_VERIFIER_YOU_GENERATED
授权服务器会对验证器进行哈希处理,并将其与您在步骤2中发送的挑战进行比对。任何截获了授权码的攻击者都无法知道验证器是什么——他们无法使用它进行交换。
值得了解的是:OAuth 2.1(OAuth 2.0的正在进行现代化版本)要求所有涉及重定向的流程都必须使用PKCE。如果您正在编写新代码,无论提供商当前是否要求,都应使用PKCE。
客户端凭据——无需用户,问题不大
后台作业、微服务调用其他微服务、定时任务调用API——这些都不涉及用户。客户端凭据是正确的流程:服务直接使用其客户端ID和密钥进行身份验证。
POST https://accounts.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&scope=api:read api:write
响应:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 86400
}
没有刷新令牌——当它过期时,只需请求一个新的令牌。常见的错误是每次API调用都调用令牌端点。缓存令牌,在每次请求前检查过期时间,仅在即将过期时重新获取。每天(或每小时,取决于 expires_in)仅请求一次令牌,而不是每次请求都请求一次:
let cachedToken = null;
let tokenExpiresAt = 0;
async function getAccessToken() {
// Refresh 30 seconds before actual expiry
if (cachedToken && Date.now() < tokenExpiresAt - 30_000) {
return cachedToken;
}
const res = await fetch('https://accounts.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
scope: 'api:read api:write',
}),
});
const data = await res.json();
cachedToken = data.access_token;
tokenExpiresAt = Date.now() + (data.expires_in * 1000);
return cachedToken;
}
开发者实际犯的错误
将令牌存储在localStorage中
任何XSS漏洞——无论是您自己的代码、依赖项还是标签管理器脚本——都可以读取localStorage中的所有内容。对于单页应用:将访问令牌存储在内存中(一个模块作用域变量,在刷新时消失)。当您有后端可以设置时,使用httpOnly Cookie存储刷新令牌。JavaScript无法读取httpOnly Cookie。
使用隐式流程
隐式流程将令牌直接返回到URL片段(#access_token=...)。这些令牌最终会出现在浏览器历史记录、服务器访问日志和Referer头中。它在RFC 9700中已被弃用。对于新代码,没有理由使用隐式流程。应使用PKCE。
跳过状态验证
在此处粘贴 .github/workflows/*.yml 内容 state 在回调时进行验证,攻击者可以构造一个重定向URL,使用自己的授权码完成OAuth流程。结果是,您的用户账户被链接到攻击者的身份。为每个流程生成一个全新的状态,存储在会话中,并在回调时验证。
将客户端密钥放入前端代码中
浏览器中不存在所谓的“安全密钥”。最小化不会隐藏它。混淆也无法保护它。如果您的运行环境是浏览器或移动应用,您有一个 公开客户端 ——使用PKCE并完全省略客户端密钥。这不是一个变通方法;这是规范为公开客户端设计的工作方式。
未主动处理令牌过期
每个访问令牌都有一个 expires_in 过期时间。如果您将令牌持有到401错误失败,然后重新认证,用户会遇到神秘错误。在每次请求前检查过期时间,提前刷新(在过期前30秒是一个合理的缓冲),并处理刷新令牌本身过期的罕见情况。
在工作过程中检查令牌
大多数OAuth提供商将JWT作为访问令牌发放。负载是Base64URL编码的,无需私钥即可读取——只有签名需要私钥来验证。当您在调试流程并希望查看令牌中的声明、范围或过期时间时,只需将令牌粘贴到 JWT 解码器.
如果在手动测试PKCE时需要验证一个Base64URL编码的 code_challenge 解码结果, Base64解码器 支持标准和URL安全变体。
简明版
一个关键问题决定了正确的流程:您的运行环境是否有安全的地方来保存密钥?
- 后端服务器 → 授权码或客户端凭据(密钥保留在服务器上)
- 浏览器或移动应用 → 授权码 + PKCE(完全没有密钥)
- 没有用户参与 → 客户端凭据
PKCE适用于所有涉及重定向的流程——即使提供商目前不强制要求,使用它也没有任何缺点。OAuth 2.1将强制要求这一点。
