OAuth2 与 OpenID Connect

本页目录

OAuth 2.0 定义应用怎样取得访问资源的授权;OpenID Connect(OIDC)在其上定义用户认证结果的表达和验证。一个应用可以只申请 API 授权,也可以通过 OIDC 登录,再建立自己的 session。JWT 是一种 token 表示格式,既不是 OAuth 的同义词,也不是“无需维护任何状态”的承诺。

角色例子核心责任
Resource Owner拥有文档的用户授权访问;并非所有流程都有用户参与
Client请求文档权限的应用发起授权、保存事务、持有并使用 token
Authorization Server组织身份与授权服务认证、授权决策、签发和管理 token
Resource Server文档 API验证 token,并落实资源级权限

授权服务器与资源服务器可以属于同一产品,也可以独立部署。浏览器主要承担用户交互和重定向;在服务端 Web 模式中,真正兑换授权码、保存 token 的客户端通常是应用后端。RFC 6749

用一次登录解释 Authorization Code + PKCE

授权码登录:前通道携带 code,客户端兑换并建立本地会话 浏览器 应用客户端 授权服务器 ① 保存事务,重定向 ② authorize:challenge、state、nonce ③ 登录后回调:code、state ④ 关联本地事务 ⑤ token:code、verifier、客户端认证 ⑥ 验证 ID Token,创建应用会话 图示采用服务端 Web 客户端;SPA 与原生应用的 token 保存和客户端认证能力不同。

应用开始登录前创建一笔短期事务,记录预期 issuer、回调地址、随机 state、nonce 与 PKCE verifier,并与发起登录的浏览器会话绑定。不同标签页可以并发登录,所以不能只用一个全局变量保存“最后一个 state”。

步骤关键内容完成条件
发起授权response_type=code、client_id、redirect_uri、scope参数与注册和本地事务一致
PKCE 绑定code_challenge、code_challenge_method=S256verifier 留在客户端,challenge 发往授权端
用户交互登录及适用的 consent已有会话可能省去再次输入凭据,并非每次弹窗
处理回调code 或 error,以及返回的 state找到并核对发起事务,拒绝过期或无关响应
兑换code、verifier、适用的 redirect_uri 和客户端认证授权服务器验证绑定关系,授权码不可重复使用
建立应用会话验证 OIDC ID Token、映射本地账户再创建应用自己的会话与权限上下文

客户端需将错误回调也关联到原事务;不能因返回了 error=access_denied 就跳过关联检查。生产实现还要处理回调重复、换码响应丢失和事务过期,而非简单重放同一个授权码。

OAuth 安全最佳实践要求授权服务器支持 PKCE,公共客户端使用 PKCE;机密客户端也应使用。隐式流程并不是从所有系统中消失了,但直接在授权响应返回 access token 的方式不再是新实现的常规选择;密码授权则明确不应使用。机器身份、设备交互等仍有自己的授权方式,不能把 code flow 称为“一切场景唯一流程”。RFC 9700 §2

PKCE 的值怎样算出来

S256 的计算是 BASE64URL(SHA256(ASCII(code_verifier))),移除 Base64 尾部的 =。verifier 长度为 43–128 个允许字符,应由足够熵的随机数产生;challenge 不是 SHA-256 的十六进制字符串。

import base64
import hashlib
import secrets

def challenge(verifier):
    return base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode('ascii')).digest()
    ).rstrip(b'=').decode('ascii')

# RFC 7636 附录 B 的公开向量,不是运行时使用的秘密。
verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'
assert challenge(verifier) == expected
assert challenge(verifier + 'x') != expected
fresh = secrets.token_urlsafe(32)
assert len(fresh) == 43 and len(challenge(fresh)) == 43
print('PKCE 公开向量及修改拒绝通过')

拦截到 code 的一方如果没有对应 verifier,便不能仅凭 code 完成这次兑换。PKCE 不是客户端长期身份认证:公开分发的 SPA 或原生程序不能靠内嵌 client_secret 保密;能够保护秘密的服务端客户端仍按注册方式认证自己。RFC 7636

state、nonce、PKCE 分别绑定什么

值关联的位置应核对的关系
state授权请求与浏览器回调此回调是否属于这个浏览器发起的事务
nonceOIDC 请求与 ID Tokentoken 中的认证结果是否绑定预期事务
PKCE授权请求与 token 兑换兑换者是否持有当时生成的 verifier

三者的防护作用有交集,不能简单说“缺少其中任何一个,在所有协议模式中都必定违规”;本文示例显式使用三者,便于分别验证关联关系。尤其在多个 issuer 场景,还要防止把 A 的响应交给 B 的 token endpoint:预期 issuer 应从可信事务和配置确定,不能只相信回调给出的任意地址。

challenge为什么不是SHA256的十六进制?

这两个输出来自同一份32 B摘要,但只有Base64url输出是协议要求的challenge。改一个允许字符会改变绑定结果;把verifier缩短到43字符以下,则先违反语法要求,不能把任意字符串的hash当合法PKCE。

正在呈现知识画面
challenge为什么不是SHA256的十六进制?

摘要是32个字节,hex与Base64url是不同编码。只有按S256规则得到绑定challenge,才满足这项兑换检查。

ID Token、Access Token、Refresh Token 不能互换

三种 token 的接收者和用途 ID Token → 客户端 证明一次认证结果;aud 通常包含 client_id Access Token → 资源服务器 授予访问 API 的权限;可能为 JWT,也可能为不透明字符串 Refresh Token → 授权服务器 换取后续 token;不发送给业务 API,也不保证一定签发 形状相似不代表可以互换;签名有效还要满足接收者、用途、时间和授权约束。

ID Token 面向 OIDC 客户端,陈述用户认证结果;它的有效期不等于应用 session 的强制有效期,也不代替每次 API 的权限判断。Access Token 面向资源服务器,⁠可以包含用户信息⁠,也可能代表没有交互用户的客户端。是否包含用户声明取决于令牌格式和授权场景,不能从名称推断。

OIDC 的 scope=openid 表明请求 OIDC 认证;profile、email 等是对 claim 的请求,并不保证所有字段都出现。email 可能变化,也不天然全局唯一;应用的外部身份键应以可信 iss 与 sub 的组合为基础,再建立本地账户映射。调用 UserInfo 时,应核对其 sub 与已经验证的 ID Token 一致。OIDC Core

请求 offline_access 也不代表服务端无条件返回 refresh token;签发取决于流程、用户同意和提供方策略。一次 token 响应没有 refresh token,不一定是协议错误。

JWT 验证:先决定信任谁,再选择 key

常见签名 JWT 使用 JWS compact 三段形式:Base64url(header)、Base64url(payload)、signature。编码不是加密,拿到这种 token 的人通常能读取 claims;加密 JWT 使用不同结构,不能用“数三个点段”概括所有 JWT。

kid 是查找可信密钥集合中候选密钥的提示,不是证书、更不是任意取钥 URL。客户端应从可信 issuer 的 discovery 元数据取得 jwks_uri,核对元数据中的 issuer;JWKS 路径不固定为 /.well-known/jwks.json。不要根据未经验证的 token 任意访问一个 issuer 或 jku。OIDC Discovery

验证层判断内容
密码与格式允许的算法、签名、密钥用途、解析和重复字段策略
信任来源issuer 必须精确匹配预期配置
接收者ID Token 的 aud 包含本客户端;Access Token 面向当前 API
时间exp、适用的 nbf、可接受时钟偏差及认证新鲜度
事务绑定若请求 nonce,核对其值;处理 azp 等相应规则
token 用途为 ID Token 和 Access Token 使用不同验证规则
业务授权scope、主体、租户、资源归属与动作

密钥轮换时可对未知 kid 有限度刷新缓存,同时避免每个陌生 kid 都触发无限外部请求。旧公钥的保留期要覆盖仍应被接受的旧 token;删除验证密钥与让 token 自然过期不是同一件事。JWT BCP

符合 RFC 9068 的 JWT Access Token 使用 at+jwt 类型,并定义相应 claims 与验证要求;不能要求所有历史或私有 access token 都自动遵循该 profile,也不能把“签名正确的任意 JWT”都接纳成 API 凭据。RFC 9068

下面仅演示可信 token 已验签后的业务授权⁠,故意不把 JSON 字典当作完整 token 验证器。即便 scope 允许读取,也必须检查文档是否属于当前租户。

def may_read(claims, document, now):
    return (
        claims['iss'] == 'https://identity.example'
        and claims['aud'] == 'documents-api'
        and now < claims['exp']
        and 'documents:read' in claims['scope'].split()
        and claims['tenant_id'] == document['tenant_id']
    )

c = dict(iss='https://identity.example', aud='documents-api',
         exp=2000, scope='documents:read', tenant_id='tenant-a')
assert may_read(c, {'tenant_id': 'tenant-a'}, 1999)
assert not may_read(c, {'tenant_id': 'tenant-b'}, 1999)
assert not may_read(c, {'tenant_id': 'tenant-a'}, 2000)
assert not may_read(dict(c, aud='web-client'), {'tenant_id': 'tenant-a'}, 1999)
print('跨租户、过期和错误接收者均拒绝')

刷新、撤销与退出登录是三件事

Access Token 到期后,客户端可向授权服务器使用 refresh token 申请新 token。若采用轮换,每次刷新返回新的 refresh token 并淘汰旧值;客户端需要原子保存新值并协调并发刷新。两个请求同时使用旧 token,可能导致其中一个被视为复用,继而撤销整个 token 家族。

复用检测也会遇到“服务端已轮换,响应在网络丢失”的歧义。不能无限重试旧值;提供方的恢复窗口、复用策略和客户端重新认证路径需一致。公共客户端的 refresh token 应采用发送者约束或轮换等规定的重放保护,单纯把 token 放进客户端存储不是该机制。RFC 9700 §4.14

操作直接改变的状态不自动保证
删除应用 session本应用登录状态所有 API token 已失效
撤销 refresh token后续刷新能力及提供方相关授权状态已发 JWT 在各 API 立即被拒绝
授权服务退出登录身份提供方会话每个应用本地会话同步消失
撤销 access token授权服务器中的有效性仅离线验签的 API 已实时获知

若 API 只检查 JWT 签名和到期时间,撤销后仍可能接受它直到到期;需要更快收敛时,可采用 introspection、撤销状态传播或更短有效期等明确方案。introspection 返回的 active 也依赖其上下文,缓存会产生可见延迟。Token Introspection、Token Revocation

Bearer token 的持有者可以使用它;DPoP 将 token 绑定到证明密钥,并对请求生成证明,改变这一使用条件。但它不替代 scope 或资源授权,也不能让被完全控制的客户端自动恢复可信。DPoP

应用形态决定 token 放在哪里

形态身份与保存方式需要处理的问题
服务端 Web / BFF后端兑换并保存 token,浏览器持有应用 session cookieCookie 属性、CSRF、后端会话生命周期
浏览器 SPA公共客户端,不能保守内嵌秘密XSS、刷新协调、页面重载后的恢复
原生应用公共客户端,使用系统授权交互与 PKCE重定向接收、系统存储、进程生命周期
后台服务适用时用 client credentials 表达服务身份凭据保护、scope、轮换与工作负载归属

client credentials 没有终端用户登录步骤,不应用某个员工的身份解释它的 sub。基础规范中此流程不应签发 refresh token;服务通常用自身凭据重新获取 access token。应用内嵌 secret、OAuth 授权和用户认证三个问题要分别设计。RFC 6749 §4.4

排查集成时先定位阶段:授权请求被拒绝看注册和参数;invalid_grant 看 code 是否过期、已用或绑定不匹配;验签失败看可信 issuer 和密钥;登录成功但 API 被拒绝看 token 类型、audience、scope 与资源权限。不要用“能解码 JWT”作为登录集成完成的判据。