OAuth 2.1 与 OIDC 授权服务器实践

面向工程落地的 OAuth 2.1 与 OpenID Connect 指南:授权码 + PKCE 流程、ID Token 校验、令牌轮换与 DPoP 发送者约束、JWKS 与自省、redirect_uri 精确匹配、单点登录集成及常见漏洞防御。

几乎每个有登录功能的系统都在用 OAuth 2.0,但"能用"和"用对"之间隔着一整代安全审计发现。OAuth 2.0(RFC 6749)本身只定义了授权框架,把大量安全决策留给实现者,结果就是重定向 URI 校验不严、隐式模式泄露令牌、refresh token 被重放等经典问题反复出现。OAuth 2.1 把这些最佳实践收编为强制要求,OpenID Connect(OIDC)则在授权之上补出身份层。本文从协议演进讲到授权服务器的落地实现,覆盖流程、令牌、校验与常见坑。

一、OAuth 2.1 相对 2.0 的关键变化

OAuth 2.1 不是一份全新协议,而是把十年来的安全 BCP(RFC 8252、RFC 7636、RFC 9700)合并进主规范,同时删掉已被证明不安全的模式。

变化2.0 状态2.1 要求
PKCE可选(移动端推荐)所有授权码流程强制
隐式模式(Implicit)可用已移除
密码模式(ROPC)可用已移除
客户端凭证可用保留,仅限机密客户端
重定向 URI 匹配允许前缀/模糊匹配必须精确字符串匹配
refresh token可长期有效对公开客户端必须轮换或发送者约束
bearer token 使用主流鼓励改用发送者约束令牌(DPoP/mTLS)

三点最需要记住:

  1. PKCE 不再是移动端专属。即便是服务端 Web 应用,也必须走 PKCE,因为授权码可能通过 Referer、日志、浏览器历史泄露。
  2. 隐式模式彻底退场。SPA 应改用授权码 + PKCE,不再从 URL fragment 里取 token。
  3. 公开客户端必须让 refresh token 单次使用,一旦检测到重放立即吊销整条令牌链。

二、授权码 + PKCE 流程

这是当前唯一推荐的交互式流程。核心是:客户端先本地生成 code_verifier,把它的哈希 code_challenge 随授权请求带上,换令牌时再用原始 code_verifier 证明"我就是发起授权的那个客户端"。

客户端                     授权服务器                资源服务器
  |-- 1. 生成 code_verifier + code_challenge --|
  |-- 2. /authorize?response_type=code&code_challenge=... -->|
  |<-- 3. 302 redirect_uri?code=xxx&state=yyy --------------|
  |-- 4. POST /token (code + code_verifier) ---------------->|
  |<-- 5. access_token + refresh_token + id_token ----------|
  |-- 6. GET /resource  Authorization: Bearer <access> ------------------>|

2.1 参数构造

GET /authorize?
  response_type=code
  &client_id=web-app
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
  &scope=openid%20profile%20email%20orders.read
  &state=<32字节随机值>
  &nonce=<32字节随机值>
  &code_challenge=<BASE64URL(SHA256(verifier))>
  &code_challenge_method=S256

逐项含义:

  • state:防 CSRF,回调时必须原样比对,用后即弃。
  • nonce:仅 OIDC 需要,会被写进 ID Token,用于防重放。
  • code_challenge_method:固定用 S256,不要用 plain。
  • scope:openid 是 OIDC 的开关,缺了它返回的就不是 ID Token 而是普通授权响应。

2.2 客户端实现(Node.js)

import crypto from 'node:crypto';

const base64url = (buf) =>
  buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

// 1. 生成 verifier 与 challenge
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
const state = base64url(crypto.randomBytes(32));

// 2. 把 verifier 与 state 存进会话(服务端)或 sessionStorage(SPA)
session.oauth = { verifier, state };

// 3. 跳转授权端点
const authUrl = new URL('https://id.example.com/authorize');
authUrl.search = new URLSearchParams({
  response_type: 'code',
  client_id: 'web-app',
  redirect_uri: 'https://app.example.com/callback',
  scope: 'openid profile email',
  state,
  code_challenge: challenge,
  code_challenge_method: 'S256',
});
res.redirect(authUrl.toString());

回调处理时先校验 state,再用 code + verifier 换令牌:

app.get('/callback', async (req, res) => {
  if (req.query.state !== session.oauth.state) return res.status(400).end();
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code: req.query.code,
    redirect_uri: 'https://app.example.com/callback',
    client_id: 'web-app',
    code_verifier: session.oauth.verifier,
  });
  const tokenRes = await fetch('https://id.example.com/token', {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body,
  });
  // ...
});

注意 redirect_uri 在 /authorize 与 /token 两次请求里必须完全一致,否则授权服务器应拒绝。

三、OIDC 与 ID Token

OAuth 只解决"授权"(我能访问什么),OIDC 解决"认证"(你是谁)。ID Token 是一个 JWT,由授权服务器签名,客户端不得用它去访问资源服务器,它的唯一用途是告诉客户端"用户身份是什么"。

3.1 关键 claims

claim含义校验要点
iss签发者必须等于预期的 issuer,且做精确匹配
aud受众必须包含本客户端 client_id
exp / iat过期/签发时间校验过期,容忍少量时钟偏移
nonce请求时携带必须与发起时一致,防重放
sub用户唯一标识同一 issuer 内稳定,跨 issuer 不保证
azp授权方多受众时必须校验
at_hashaccess_token 哈希与 access_token 绑定,防替换

3.2 校验步骤

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(new URL('https://id.example.com/.well-known/jwks.json'));

async function verifyIdToken(idToken, expectedNonce) {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: 'https://id.example.com',
    audience: 'web-app',
    clockTolerance: 5, // 秒
  });
  if (payload.nonce !== expectedNonce) throw new Error('nonce mismatch');
  return payload;
}

三个容易漏掉的点:

  • 算法白名单:只接受 RS256/ES256 等非对称算法,绝不允许 none,也不要用客户端密钥去校验 HS256(算法混淆攻击)。
  • JWKS 缓存:每次请求都拉 JWKS 会成为性能瓶颈,应缓存并在遇到未知 kid 时刷新一次。
  • Discovery:授权服务器的元数据在 /.well-known/openid-configuration,客户端应据此发现端点而非硬编码。

3.3 端点一览

curl -s https://id.example.com/.well-known/openid-configuration | jq '{
  issuer, authorization_endpoint, token_endpoint, jwks_uri,
  userinfo_endpoint, end_session_endpoint,
  code_challenge_methods_supported, id_token_signing_alg_values_supported
}'

四、授权服务器实现要点

无论选 Keycloak、ORY Hydra 还是自研,授权服务器侧有几处必须做对。

4.1 客户端注册与类型

客户端类型能否保密典型场景认证方式
机密(confidential)能后端 Web 应用client_secret_basic 或 mTLS
公开(public)不能SPA、移动 App仅 PKCE,无密钥
机对机能服务间调用client_credentials + mTLS/私钥 JWT

公开客户端不得嵌入 client_secret——反编译即泄露。它们的安全性完全建立在 PKCE 与精确 redirect_uri 之上。

4.2 redirect_uri 精确匹配

这是历史上漏洞最多的参数。规则必须是:

注册:https://app.example.com/callback
接受:https://app.example.com/callback
拒绝:https://app.example.com/callback/../evil
拒绝:https://app.example.com/callback?x=1
拒绝:https://app.example.com.evil.com/callback
拒绝:https://app.example.com/CALLBACK

校验应做字符串全等,而不是"前缀匹配"或"URL 规范化后匹配"。若需要多个回调地址,逐一注册。移动端可用私有 URI scheme(如 myapp://callback)或 RFC 8252 推荐的 loopback 重定向(http://127.0.0.1:<随机端口>/callback),后者必须允许任意端口但固定主机。

4.3 scope 与权限

scope 是粗粒度的权限声明,服务端必须按 scope 逐项鉴权,而不是"有 token 就放行"。资源服务器收到 token 后要校验 scope 是否包含该接口所需权限:

@PreAuthorize("hasAuthority('SCOPE_orders.read')")
@GetMapping("/api/orders/{id}")
public Order getOrder(@PathVariable String id) { ... }

细粒度授权(如"只能读自己的订单")不能靠 scope,要靠资源服务器自己的业务校验,或引入 UMA/RAR(Rich Authorization Requests)。

4.4 令牌端点加固

/token 是攻击者重点关照的端点,需要额外的防护:

  • 客户端认证:机密客户端用 client_secret_basic(HTTP Basic)或 private_key_jwt;公开客户端只能靠 PKCE,不要给它分配密钥。
  • 速率限制:按 client_id 与来源 IP 双维度限流,防止授权码/令牌暴力枚举。
  • 授权码单次使用:授权码在成功换令牌后立即失效;若同一 code 被第二次使用,吊销该 code 已签发的全部令牌(这是重放攻击的强信号)。
  • 响应头:Cache-Control: no-store,避免令牌被中间缓存。
POST /token HTTP/1.1
Host: id.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic d2ViLWFwcDpzZWNyZXQ=

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&
redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Content-Type: application/json

{"access_token":"...","token_type":"Bearer","expires_in":900,"refresh_token":"...","id_token":"..."}

4.5 登出与会话终止

OIDC 的 RP-Initiated Logout 定义了 end_session_endpoint,客户端登出时跳转到该端点并带上 id_token_hint 与 post_logout_redirect_uri:

GET /logout?
  id_token_hint=<ID Token>
  &post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Flogged-out
  &client_id=web-app

要点:post_logout_redirect_uri 同样需要预先注册并精确匹配,否则又是一条开放重定向。SSO 场景下,登出必须清理授权服务器侧的会话,否则用户在下一个应用会被静默重新登录。

五、令牌生命周期与发送者约束

5.1 access token:短时效 + 不透明优先

  • access token 建议有效期 5~15 分钟,过期后用 refresh token 换新。
  • 优先使用不透明令牌(opaque),资源服务器通过自省端点(RFC 7662)校验。它可即时吊销,不像 JWT 需要等过期。
  • 若用 JWT 做 access token,必须接受"签发后到过期前无法撤销"的现实,因此有效期要更短,并配合撤销列表(jti 黑名单)。

5.2 refresh token 轮换与重放检测

对公开客户端,refresh token 必须单次使用 + 轮换:

客户端 POST /token grant_type=refresh_token refresh_token=RT1
服务器:吊销 RT1,签发 RT2 + 新 access_token
若再次收到 RT1(重放)→ 判定令牌链被窃,吊销该用户所有 refresh token

这条"检测到重放就全链吊销"的规则是把泄露窗口压到最小的关键。refresh token 本身应存于服务端存储(或 HttpOnly Cookie),绝不放 localStorage。

5.3 发送者约束:DPoP 与 mTLS

bearer token 的问题是"谁拿到谁能用"。发送者约束令牌(sender-constrained token)要求客户端证明持有私钥:

  • mTLS(RFC 8705):客户端用证书绑定 token,资源服务器校验证书指纹与 token 里的 cnf 一致。
  • DPoP(RFC 9449):客户端为每个请求生成一个 DPoP proof JWT,用私钥签名并绑定 HTTP 方法与 URL。适合无法部署证书的 SPA/移动端。
POST /token
DPoP: <proof-jwt>            ← 换令牌时证明持有私钥
→ 返回 token_type=DPoP 的 access_token

GET /resource
Authorization: DPoP <access_token>
DPoP: <新的 proof-jwt>       ← 每个请求都带,且绑定到该 URL

DPoP 让被窃取的 access token 在攻击者手里形同废纸,因为攻击者拿不到私钥。

5.4 JWKS 轮换

授权服务器轮换签名密钥时,必须在新密钥启用后保留旧公钥一段时间(至少等于 access token 最大有效期 + 时钟偏移),否则已签发但未过期的 JWT 会全部校验失败。JWKS 里用 kid 区分,客户端按 kid 选取,遇到未知 kid 时刷新 JWKS。

六、常见漏洞与防御

漏洞成因防御
开放重定向 + 令牌窃取redirect_uri 校验宽松精确匹配,拒绝 ../、?、大小写变体
CSRF(授权请求伪造)未校验 state强制 state,服务端绑定会话
Mix-up 攻击多授权服务器下客户端发错端点用 iss 校验,绑定 issuer 与端点
算法混淆接受 HS256 + 公钥当密钥算法白名单,非对称算法优先
令牌泄露(Referer/日志)token 出现在 URL用授权码模式,token 只在 body/header
重放refresh token 未轮换轮换 + 重放检测 + 全链吊销

一个高频的组合攻击是:攻击者控制一个已注册客户端的子域,诱导用户授权后通过宽松的 redirect_uri 前缀匹配把 code 引到自己域名。防御只有一条——精确匹配,不搞任何模糊规则。这类"信任边界被绕开"的问题在 API 安全设计 里同样反复出现。

七、落地清单

  1. 所有交互式流程走授权码 + PKCE(S256),禁用隐式与密码模式。
  2. redirect_uri 精确字符串匹配,多回调逐一注册。
  3. 校验 ID Token 的 iss/aud/exp/nonce,算法白名单不含 none 与 HS256。
  4. access token 短时效、优先不透明;refresh token 轮换 + 重放全链吊销。
  5. 对高价值场景启用 DPoP 或 mTLS 发送者约束。
  6. JWKS 轮换保留旧公钥,客户端按 kid 缓存刷新。
  7. 与 身份认证与会话安全 的会话管理、无密码认证 WebAuthn 的凭证体系协同设计,形成完整的身份链路。

OAuth 2.1 与 OIDC 的复杂度不在协议本身,而在实现细节的"最后一公里"。把 PKCE、精确重定向、令牌轮换、发送者约束这四件事做扎实,就能挡掉绝大多数历史 CVE。更完整的 JWT 攻防细节可参考 OAuth2 与 JWT 安全实践 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「安全」更多文章

  1. SOAR 安全编排自动化与响应
  2. 内部威胁与 UEBA 用户行为分析
  3. 模糊测试与安全测试自动化