OAuth 2.0、OIDC、PKCE 与设备流:从授权码到撤销证据
值班工程师看到登录回调返回 302,前端也拿到了用户头像,便把故障归为偶发网络抖动。直到同一个账号在另一个租户下登录后获得了测试环境管理员角色,团队才发现真正的问题:客户端只解码了 ID Token,没有固定 issuer,也没有校验 audience、nonce 和授权响应绑定。页面“登录成功”只是浏览器走完跳转,不是身份语义已经成立,更不是资源访问已经被正确授权。
另一次事故发生在电视终端。设备展示二维码后,客服把完整链接贴进工单帮助用户操作,攻击者借这段链接在自己的浏览器完成确认;与此同时,设备端以固定一秒间隔持续轮询,授权服务器被放大成热点。把 OAuth 当成几个 URL、把 OIDC 当成 JWT 解码、把设备流当成二维码登录,都会丢掉协议真正依赖的一次性状态、主体确认、受众限制和失败终止条件。
先把授权、认证和资源访问拆成三件事
OAuth 2.0 是授权框架:资源所有者允许客户端以受限方式访问资源服务器,授权服务器向客户端签发 access token。它不规定登录页面怎样确认人,也不保证 access token 一定是 JWT。OIDC 在 OAuth 之上增加身份层,客户端请求 openid scope 后,通过 ID Token 判断哪个发行者在什么认证事务中向哪个客户端声明了哪个主体。资源服务器接受的是为自己签发的 access token,不能把 ID Token 当作通用 API 凭证。
一条授权码链路里至少有 user-agent、客户端、授权服务器与资源服务器。客户端还要区分 public 与 confidential:浏览器单页应用、原生应用和分发到用户设备的 CLI 无法可靠保守静态 secret,复制一份 client_secret 不能把它们变成机密客户端;服务端应用可以在受控后端使用注册的认证方法,但客户端认证也不能替代用户授权、PKCE 或 redirect URI 校验。
RFC 9700 是 OAuth 部署的安全基线:新链路应采用授权码并使用 PKCE,公共客户端必须使用 PKCE,授权服务器必须支持 PKCE,机密客户端也推荐使用;挑战方法使用 S256,redirect URI 做精确匹配;资源所有者密码凭据模式不得使用,implicit flow 不应成为新实现默认路径。它还要求针对 refresh token 重放、授权服务器混淆、受众扩散和开放重定向建立防护,而不是只在 TLS 前加一个登录页。
OAuth 2.1 的状态要从 IETF Datatracker 实时确认。它仍是工作草案而不是已经发布的 RFC,修订内容和最终状态可能变化,因此项目合规基线应引用已发布的 RFC 6749、RFC 6750、RFC 9700 以及实际启用的扩展;可以用 OAuth 2.1 草案理解整合方向,不能声称某实现已经符合一个尚未定稿的正式标准。
从 issuer 和注册合同建立信任起点
接入不是从拼 authorization URL 开始,而是先确定唯一、区分大小写且精确匹配的 issuer。OIDC 客户端按 issuer 获取 Discovery 文档,确认响应中的 issuer 与配置完全一致,再读取 authorization endpoint、token endpoint、JWKS URI 和能力声明。发现结果是服务器“声明支持什么”,不是客户端“必须启用什么”;客户端只使用注册合同、库能力与安全策略共同允许的交集。
注册合同至少固定客户端类型、redirect URI、允许的 grant/response type、token endpoint 认证方法、scope、resource/audience、签名算法允许列表和退出回调。端点、算法、TTL、claim、logout、revocation、introspection、device grant、PAR、DPoP 或 mTLS 是否可用,都由目标部署的 metadata、注册记录和版本化策略决定。字段缺失不能靠猜测补成默认能力,发现文档变化也应先告警和评审,不能让运行中客户端自动扩大权限。
下面的检查只读取公开 metadata,不接触令牌。它把“HTTP 200”提升为 issuer、HTTPS 端点和授权码能力的结构证据,文件离开临时目录前不会进入仓库或构建产物。
set -eu
EXPECTED_ISSUER='https://id.example.test/tenant'
DISCOVERY_URL="$EXPECTED_ISSUER/.well-known/openid-configuration"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
curl --fail --silent --show-error --proto '=https' \
-H 'Accept: application/json' "$DISCOVERY_URL" > "$tmp/provider.json"
jq -e --arg issuer "$EXPECTED_ISSUER" '
.issuer == $issuer and
(.authorization_endpoint | startswith("https://")) and
(.token_endpoint | startswith("https://")) and
(.jwks_uri | startswith("https://")) and
(.response_types_supported | index("code") != null)
' "$tmp/provider.json"把临时副本的 issuer 改成其他值,检查必须失败。真实客户端还应绑定 issuer 与 JWKS 缓存命名空间,避免多个租户共享一个无归属的 kid 查找池。授权响应可按 RFC 9207 使用 iss 增强 mix-up 防护,但是否支持仍要从授权服务器与客户端库确认。
授权码与 PKCE 怎样绑定同一次交易
客户端为每次授权生成高熵 state、OIDC nonce 和 code_verifier。code_verifier 只保存在该事务的受控状态中;客户端计算 BASE64URL(SHA256(verifier)) 得到 code_challenge,在授权请求发送 challenge 与 S256。授权服务器把 challenge 绑定到授权码,客户端换取 token 时提交原始 verifier。截获授权码但没有 verifier 的进程无法完成兑换,这正是 RFC 7636 解决的攻击面。
PKCE 不会替代任何其他判断。state 绑定浏览器请求与回调并防止登录 CSRF;nonce 绑定 OIDC 请求与 ID Token;redirect URI 将授权响应限制到已注册回调;客户端认证证明机密客户端;issuer、签名、audience、时间声明和 azp 等判断建立 ID Token 语义;API 仍要独立验证 access token 的 audience、scope 与授权策略。缺一项时,增加 verifier 长度也补不回来。
这个公开测试向量可以在本机复制,正向输出两个 PASS。第二个判断只证明 verifier 变化会产生不同 challenge;端到端反例还必须由测试授权服务器用错误 verifier 兑换真实测试 code,并得到 invalid_grant 且无 token。
set -eu
verifier='dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
expected='E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'
challenge() {
printf '%s' "$1" | openssl dgst -sha256 -binary \
| openssl base64 -A | tr '+/' '-_' | tr -d '='
}
test "$(challenge "$verifier")" = "$expected"
echo 'PASS: S256 test vector matched'
test "$(challenge "${verifier}x")" != "$expected"
echo 'PASS: changed verifier produced another challenge'事务存储应按随机句柄索引,并记录创建时间、issuer、client、redirect URI 摘要、nonce 摘要和状态;不记录 code、verifier 或 token 原文。回调成功、失败或过期都原子消费事务,重复回调必须得到“已消费”而不是再次换取令牌。多实例部署要使用共享的一次性存储或带原子删除的会话设施,不能让每个节点各自认为同一个 state 尚未使用。
在项目回调中完成 OIDC 语义校验
成熟 OIDC 库应负责 JWS/JWE、claim 与协议错误处理,业务代码不要手写 JWT 验签。客户端先固定 issuer 和允许算法,从该 issuer 的 JWKS URI 获取公钥;按 kid 查找时只在绑定的 key set 内进行。未命中可以触发一次受限刷新,但不能跨 issuer 搜索,也不能因为轮换尚未传播就关闭签名校验。随后验证 iss、aud、必要时的 azp、exp、iat、nonce 和认证上下文策略;调用 UserInfo 时还要确认返回 sub 与 ID Token 的 sub 一致。
项目配置应表达合同而不是秘密。以下示例保留动态能力的发现入口,但把 issuer、client、redirect、resource 和允许算法交给部署配置;secret 只以外部引用注入。生产中究竟使用 client secret、private key JWT、mTLS 或其他认证方法,由注册记录和库支持决定。
identity:
issuer: ${OIDC_ISSUER}
clientId: ${OIDC_CLIENT_ID}
clientSecretRef: secret://identity/web-client
redirectUri: ${PUBLIC_BASE_URL}/auth/callback
scopes: [openid, profile]
resource: ${ORDERS_API_RESOURCE}
idTokenAlgorithmsAllowlist: ${OIDC_ID_TOKEN_ALGS}
requirePkce: true
pkceMethod: S256
validateNonce: true
discoveryCache:
maxAge: ${OIDC_DISCOVERY_MAX_AGE}
staleOnFailure: ${OIDC_STALE_POLICY}正向集成测试要从新浏览器事务开始,完成授权码交换,验证 ID Token 后创建最低权限本地会话,再持 access token 访问指定只读资源。反向矩阵分别使用错误 issuer、错误 audience、错误 nonce、过期 ID Token、篡改签名、重复 state、错误 verifier 和为另一 API 签发的 access token;每一项都必须在创建会话或进入资源前被拒绝。测试日志保留错误类别、issuer、client、kid、匿名化主体和 correlation ID,不保存 JWT 原文。
设备授权流是一台设备与另一个浏览器的协作
RFC 8628 适合输入受限或无法使用合适浏览器的设备。设备向 device authorization endpoint 请求 code,收到高熵 device_code、供人输入核对的 user_code、verification URI、有效期和可选轮询间隔。用户在另一 user-agent 打开验证页、核对设备和 code 后批准;设备只拿 device_code 轮询 token endpoint。user_code 不是普通密码,完整验证链接也不应进入工单、聊天记录或分析日志。
设备端必须把 OAuth 错误当成状态机,而不是把所有非成功响应都重试。authorization_pending 继续按间隔等待;slow_down 增加后续间隔;access_denied 与 expired_token 立即终止;网络错误采用有上限的退避,超过设备码有效期后清理状态。服务器返回的 interval 与 expires_in 是本次事务数据,不能写成产品常量。
CREATED -> PENDING -> APPROVED -> TOKEN_VALIDATED -> SESSION_READY
| |
| +-> invalid token/claims -> FAILED
+-> slow_down -> PENDING_WITH_LONGER_INTERVAL
+-> denied/expired/cancelled -> TERMINATED
TERMINATED/SESSION_READY + same device_code -> REJECTED正向实验要让用户在受控测试租户核对 code 和设备信息后批准,再验证 ID Token 与最小 scope API。反向实验先不操作用户页面,观察 authorization_pending;再故意过快轮询,确认客户端收到 slow_down 后确实延长间隔;随后分别拒绝、等待过期和重放已完成 device code,客户端必须停止且授权服务器不得再次签发有效 token。CLI 若能安全启动系统浏览器并绑定 loopback 回调,授权码加 PKCE 往往能提供更清晰的请求绑定;不能因为“CLI 没有键盘限制”就自动选择设备流。
刷新、撤销与退出要在资源端留下反证
短 access token 限制单次泄露窗口,refresh token 延续授权关系,两者不能共用同样的存储与日志策略。公共客户端使用 refresh token 时,RFC 9700 要求通过 sender constraint 或 refresh token rotation 发现重放。轮换意味着每次成功刷新都签发新 token 并使旧 token 失效;旧 token 再出现时,授权服务器应识别冲突并按策略撤销活跃 token family 或要求重新认证。客户端必须原子替换持久化值,否则并发刷新会被误判为攻击。
RFC 7009 的撤销端点可对未知 token 返回成功,以免变成 token 探测器。因此 revocation HTTP 200 不是撤销证据。可复制的验证顺序是:测试 token 先访问最小资源成功;调用撤销端点;等待已定义的传播预算;同一 access token 再访问必须失败,refresh token 再换取 token 也必须失败;审计能关联 client、grant/session、原因和 correlation ID,但不含 token 原文。若 access token 是离线验证 JWT,撤销能否即时到达资源端取决于 TTL、在线状态检查、事件推送或 denylist 设计,不能由“有撤销接口”推断。
OIDC logout、撤销 OAuth grant、销毁应用会话和清除浏览器 cookie 是四个动作。只删 cookie 会让 refresh token、上游 IdP 会话或其他设备继续有效;只调用上游 logout 也不一定清理本地 session。退出流程要按已启用的 front-channel、back-channel 或 RP-initiated 能力编排,并以旧 cookie、旧 refresh token、旧 access token 和其他设备会话的实际结果验收。能力名称和端点必须来自目标 OP metadata 与注册合同。
故障证据要能指出第一个失效边界
把登录失败统一记为 unauthorized 会让协议错误、用户拒绝和资源授权混在一起。浏览器到 authorization endpoint 失败,先看 redirect URI、state cookie、代理外部地址和用户选择;token endpoint 的 invalid_grant 看 code 是否已消费、redirect URI 是否一致、verifier 是否匹配和时钟;ID Token 失败看 issuer、audience、nonce、签名、kid 与时间声明;API 401 看 token 类型、issuer、audience 和过期,API 403 再看 scope、role 与资源策略。
日志事件可以使用 auth_request_started、authorization_callback_rejected、token_exchange_failed、id_token_rejected、resource_access_denied、refresh_replay_detected 和 grant_revoked 这样的稳定分类。每个事件带匿名化 transaction/session/family ID、client、issuer、资源、错误码、策略版本和节点;authorization code、access/refresh/ID token、device code、verifier、state、nonce、cookie 和完整个人 claim 一律不入日志、trace、metric label、URL 或工单。
JWKS 与 discovery 故障要同时保留“最近一次成功版本”和“本次刷新失败”。未知 kid 不等于攻击,也可能是轮换传播;但无限立即刷新会造成出站风暴。合理做法是单航班刷新、负缓存、速率限制、按 issuer 隔离缓存,并在受控 stale 窗口内明确选择继续使用未过期旧 key 还是拒绝新 token。该选择由风险预算决定,不能用一个全局布尔值覆盖所有资源。
用容量、敏感数据和成本约束协议实现
一次互动登录可能产生 discovery/JWKS 请求、事务状态、授权码交换、UserInfo、会话写入和审计事件;设备流还会持续轮询,refresh rotation 会写 token family 状态。容量模型至少包含每秒登录峰值、并发未完成事务、设备轮询率、JWKS 刷新率、会话与 replay 记录数量、撤销传播延迟和审计吞吐。TTL 越长,存储和失陷窗口越大;TTL 越短,刷新、签名、数据库和网络成本越高,不能脱离登录 SLO 与故障基线追求“越短越安全”。
敏感级别也决定存放位置。客户端私钥和 secret 进入 secret manager、KMS 或 HSM 边界;authorization code、access/refresh token、device code、session cookie 是可直接或间接换取权限的凭证;verifier、state、nonce 是单事务秘密;claims、UserInfo、introspection 响应和审计包含个人与授权信息。数据库优先保存不可逆摘要、加密后的必要 token 和最小 claim,备份与故障转储沿用同等级保护,调试开关不得绕过脱敏。
成本评审不能只数授权服务器实例。还要计算外部 IdP 调用、短信或强认证、数据库写放大、跨区 session/replay 一致性、审计检索、HSM 签名、客服恢复和遗留客户端兼容成本。设备端抖动或批量 token 刷新会形成同步峰值,应使用带随机抖动的提前刷新、每主体并发限制和服务端限流;限流响应不能诱导客户端无上限重试。
团队把迁移和退出做成可逆的信任变更
协议配置需要 owner、变更记录和可执行测试。平台团队维护 issuer、client registration、key、metadata 缓存与撤销设施;应用团队维护 redirect、session、scope/resource 和本地授权映射;资源团队维护 access token 合同;安全团队维护算法、TTL、日志与事件响应策略。任何人都不应凭一个 client secret 同时修改授权服务器、应用回调和资源端策略。
从旧 implicit、密码模式或无 PKCE 授权码迁移时,先盘点每个 client 的类型、redirect、grant、scope、token 使用者和 owner;新增并行的授权码加 PKCE 路径,用测试用户验证正反矩阵,再按 client 灰度切换。资源端先支持新 audience/issuer 合同并观测旧流量,客户端随后迁移,最后关闭旧 grant、redirect 和兼容解析。不能先关闭旧入口再寻找无人认领的脚本,也不能长期让新旧模式共享宽泛 audience。
退出某个 client 或 issuer 时,先禁止新授权,等待或主动终止在途事务;撤销 grant、refresh family、session 与客户端凭证;移除 redirect、logout URI、动态信任和资源端 issuer/audience;清理 discovery/JWKS 缓存、事务记录和设备码;按保留策略归档脱敏审计。最终反证是旧 secret、旧 redirect、旧 refresh token、旧 access token 和旧 issuer 签发的新 token 都持续失败,资源端不再出现该主体,外部密钥和租户对象也有明确核销记录。只有信任、凭证、会话、资源权限、数据和成本同时归零,协议迁移才真正结束。
