状态:草稿(2026-10-26),已反映实测结果。 实测共有三项。① 读取了 TT Lab 集群 Keycloak 中任何人都可以读取的公开元数据(
/.well-known/openid-configuration,JWKS)。已隐藏主机名、Realm 名称和版本号。② 在笔记本电脑上 新建了用于实测的 Keycloak(dev 模式,与生产环境隔离的练习环境) ,用随机数创建了实测 Realm、客户端和用户,并仅使用 Python 标准库运行了 Authorization Code + PKCE。未使用真实的 用户账号登录。③ 将使用实测密钥创建的 JWT 发送到本地网关(agentgateway)进行判定。文章中未包含实测密钥的私钥部分。规范在同一天打开原文进行了确认。 编写规则:以实测数据为基础,未能测出的记为“未实测”,无法确认的记为“无法确认”,猜测的记为“推测”。
目录
- 1. 通行证在一个地方颁发,在哪里核验?
- 2. OAuth 2.0、OIDC、SAML:各自解决什么问题
- 3. Authorization Code + PKCE:防止令牌流经浏览器
- 4. 三种令牌与 JWT 签名验证
- 5. 代理(SDK)方式:应用内库所做的事情
- 6. 网关方式:在应用前代为核验
- 7. 注销与会话同步
- 8. 常见陷阱
- 9. 该如何选择
- 10. 总结
- 参考资料
1. 通行证在一个地方颁发,在哪里核验?
想象一下公司大楼。通行证由安保部门统一签发。这就是 IdP(Identity Provider,例如 Keycloak)。核验通行证的方法有两种:
- 每个办公室的门上都安装读卡器。 办公室(应用程序)自行读取通行证,直接了解谁进来了、拥有什么权限。这就是 代理(SDK)方式。应用进程中的库负责处理登录重定向、回调、会话、令牌刷新和注销。
- 在大楼入口处安排安保人员。 入口(网关)核验通行证,并给通过的人贴上一张写有“此人是谁”的便签放行。办公室只需读取便签。这就是 网关(代理)方式。oauth2-proxy、Envoy 的 JWT 过滤器、agentgateway 等工具在应用前处理这项工作。
这两种方式使用 相同的协议(OIDC) 。不同的仅在于由 哪个进程来执行该协议。因此,我们将首先查看协议(第 2–4 节),然后比较在应用内执行(第 5 节)与在应用前执行(第 6 节)的区别,接着探讨注销(第 7 节)和陷阱(第 8 节)。
2. OAuth 2.0、OIDC、SAML:各自解决什么问题
| OAuth 2.0 | OpenID Connect (OIDC) | SAML 2.0 | |
|---|---|---|---|
| 解决的问题 | 授权委托:应用代表用户调用 API 的权限 | 认证:告知应用“此人是谁” | 认证(及属性传递),主要用于企业 Web SSO |
| 产出物 | 访问令牌(格式自由) | ID 令牌(必须是 JWT)+ OAuth 令牌 | 签名的 XML 断言 (Assertion) |
| 位置 | 基础 | 构建于 OAuth 2.0 之上的层 | 独立协议(2005) |
| 发现 | — | /.well-known/openid-configuration(Discovery) |
元数据 XML |
| 传递 | 重定向 + 后端信道 /token |
相同 | HTTP-Redirect(请求)·HTTP-POST(响应)绑定 |
重要的一点是,仅仅依靠 OAuth 2.0 是无法实现“登录”的。因为访问令牌是为 API 准备的,而不是向应用证明“谁登录了”的凭证。OIDC 通过 ID 令牌填补了这一空白。OIDC Core §2 要求在 ID 令牌中必须包含 iss·sub·aud·exp·iat 。
SAML 的 SP 发起流程外观与之相似。SP(应用)将压测后的 AuthnRequest 通过重定向发送,IdP 在让用户登录后,将 已签名的 Response 通过 HTML 表单的 POST 传递给应用的 ACS 地址(OASIS SAML 2.0 技术概述 §5.1.2、§4.6)。本文主要聚焦于在新建集成中最常使用的 OIDC。SAML 的规范文档(core 与 bindings PDF)未能直接引用正文,而是通过概述文档进行确认。
3. Authorization Code + PKCE:防止令牌流经浏览器
流程的核心是:通过浏览器传递的只有生命周期很短的 code,而令牌则由应用和 IdP 通过后端信道直接收发。因为浏览器的地址栏、历史记录、Referer 以及扩展程序都是容易泄露的地方。早期的隐式(implicit)流程将令牌直接放在地址的 #fragment 中交给了浏览器,而 OAuth 安全 BCP(RFC 9700,2025-01)§2.1.2 指出现在不应再使用隐式流程。同篇文档还禁止了由应用接收密码并传递给 IdP 的密码凭证授权(password grant)(§2.4)。
三个随机数:state、nonce、PKCE
一次登录请求会附加三个随机数,它们各自承担不同的职责。
| 随机数 | 在哪里核验 | 防范的问题 |
|---|---|---|
state |
回调中的应用 | 将他人创建的回调强行注入我方浏览器的 CSRF |
nonce |
ID 令牌中的应用 | 重放其他登录的 ID 令牌 |
PKCE code_verifier |
/token 中的 IdP |
其他人将劫持的 code 兑换为令牌 |
PKCE(RFC 7636)的计算非常简单。应用生成一个 43–128 字符的随机数 code_verifier (§4.1),并在登录请求中仅发送其哈希值 code_challenge = BASE64URL(SHA256(code_verifier)) (§4.2,S256)。由于在将 code 兑换为令牌时必须提供原始的 verifier,因此仅窃取了 code 的一方无法进行任何操作。
import base64, hashlib, secrets
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
verifier = b64u(secrets.token_bytes(32)) # 43자
challenge = b64u(hashlib.sha256(verifier.encode()).digest())
print(b64u(hashlib.sha256(b"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk").digest()))
最后一行是 RFC 7636 附录 B 中的示例 verifier。在本地运行后得出 E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM ,与附录中的期望值一致(2026-10-26)。RFC 9700 §2.1.1 要求公开客户端(无法保密机密的 SPA 及移动应用)必须使用 PKCE,同时也建议包含机密的服务器端应用使用。 必须 使用,同时也建议包含机密的服务器端应用使用。
实测:用本地 Keycloak 跑通整个流程
在实测用的 Keycloak 中创建一个机密(confidential)客户端,强制使用 S256 的 PKCE,并仅注册一个准确的重定向 URI。然后用 Python urllib 模拟浏览器角色,完整执行了上述图中的 ③–⑨ 步骤。
| 步骤 | 结果(2026-10-26,笔记本电脑,本地 IdP) |
|---|---|
| ③–⑥ 登录界面 → 提交密码 → 回调 | 117–122ms。回调包含 code·state·session_state·iss 四个值 |
| code 长度 | 110 字符 |
⑦–⑧ /token 兑换 |
200,9ms。收到的内容:包含 access_token·id_token·refresh_token·expires_in 等 9 个键 |
| 再次使用相同的 code | 400 invalid_grant “Code not valid” |
| 提供错误的 verifier | 400 invalid_grant “PKCE verification failed: Code mismatch” |
| 如果在登录请求中去掉 PKCE | IdP 通过回调返回 invalid_request “Missing parameter: code_challenge_method” |
如果在 redirect_uri 末尾 / 追加一个 |
,则显示 400 错误页面“Invalid parameter: redirect_uri”,甚至无法返回回调。 |
回调中附加 iss 是因为 IdP 开启了 authorization_response_iss_parameter_supported 。这可以防止混淆攻击(mix-up),即当应用使用多个 IdP 时,让人分不清“这个 code 是哪个 IdP 发送的”。
有一点超出了预期:实力很强。 当我尝试再次使用相同的 code 时,连该 code 之前已经获取的令牌的会话也被终止了。 在尝试重用后的紧接着对该会话的访问令牌进行 introspection 时返回 active: false,使用刷新令牌进行刷新时则返回 400 “Session doesn't have required client”。因为 code 被使用两次是某人劫持了 code 的信号,所以这种行为表现为不再信任已经签发出去的令牌。这正是 RFC 6749 §4.1.2 所建议的处理方式。不过,对于那些 仅进行签名验证的一方来说,该访问令牌仍然有效(这与第 7 节中的问题相同)。
4. 三种令牌与 JWT 签名验证
| 令牌 | 接收方 | 用于什么 | 实测寿命(实测 Realm 设置) | 实测大小 |
|---|---|---|---|---|
| ID 令牌 | 应用(客户端) | “谁登录了”。在创建应用会话时使用一次 | 300 秒 | 1,145 字节 |
| 访问令牌 | API(资源服务器) | API 调用的 Authorization: Bearer |
300 秒 | 1,403 字节 |
| 刷新令牌 | 仅 IdP | 获取新访问令牌 | 1,800 秒(与 SSO 会话空闲限制相同) | — |
访问令牌和 ID 令牌占据了单个 Cookie(通常为 4KB 限制)三分之一以上的空间。这就是如果网关采用将令牌放入 Cookie 的方式,就需要将其拆分为多个 Cookie 或使用服务器端会话存储的原因。
JWT 分为三个部分
JWT(RFC 7519)是 JWS(RFC 7515)的压缩格式,即 BASE64URL(헤더).BASE64URL(페이로드).BASE64URL(서명) 。 base64url 不是加密。 任何人都可以解码并读取它,因此绝不能在令牌中放入机密信息。实测得到的令牌头部是 {"alg":"RS256","typ":"JWT","kid":"…"} ,其中 kid 是用于从 IdP 的公钥列表(JWKS,RFC 7517)中选择哪个密钥进行验证的标识符。
RS256 验证本质上是一次幂模运算。如果对签名 s 用公钥 (n, e) 进行 s^e mod n 计算,应当得出 PKCS#1 v1.5 的形态(00 01 FF … FF 00 + DigestInfo + SHA-256(헤더.페이로드))。为了便于理解,我用标准库亲自动手编写了代码(在生产代码中请使用经过验证的库)。
def rs256_ok(token, jwk):
h64, p64, s64 = token.split(".")
n = int.from_bytes(ub64(jwk["n"]), "big"); e = int.from_bytes(ub64(jwk["e"]), "big")
k = (n.bit_length() + 7) // 8
em = pow(int.from_bytes(ub64(s64), "big"), e, n).to_bytes(k, "big")
di = bytes.fromhex("3031300d060960864801650304020105000420") + hashlib.sha256(f"{h64}.{p64}".encode()).digest()
return em == b"\x00\x01" + b"\xff" * (k - 3 - len(di)) + b"\x00" + di
实测:实操 IdP 给出的访问令牌、ID 令牌和登出令牌均通过了 JWKS 的 2048 位 RSA 密钥验证该函数,而修改负载中的一个比特就会导致验证失败。
签名正确并不意味着结束
签名仅能证明“由 IdP 创建”。“是否是发给我、当前时刻、通过此次登录发来的令牌”必须通过声明(claim)单独验证。OIDC Core §3.1.3.7 将 ID 令牌验证分为 13 个步骤,核心是图中的第 4–7 步。 iss 的字面值必须完全一致, aud 中必须包含我的 client_id, exp 必须尚未过期, nonce 必须是我发送的值。JWT 最佳实践(RFC 8725)指出:应当由调用方决定库所接受的 算法列表 (§3.1),并且 aud 必须进行检查(§3.9)。 alg: none 则是“除非明确请求,否则切勿接收”(SHOULD NOT,§3.2)。
在实测令牌中发现了两个陷阱。
- Keycloak 访问令牌的
aud默认是account。 这并不是我的 API 的名称。如果 API 端要正确检查aud,必须在 IdP 中附加一个 audience 映射器,以便将 API 名称写入其中。如果在 API 中偷懒关闭了aud检查,那么其他应用从同一个 IdP 获得的令牌将能够调用我的 API。 - JWT 访问令牌标准(RFC 9068)要求将头部
typ设置为at+jwt,但实测令牌是JWT。(声明typ为Bearer)。如果某个资源服务器严格按照 RFC 9068 §4 的规定检查typ,它将会拒绝此令牌。必须在集成前确定适配哪一方。
exp 包含时钟偏差容差(leeway)。RFC 7519 §4.1.4 允许“通常不超过几分钟的微小余量”。如果使用 过期后 1 秒的时钟检查实测 ID 令牌,在没有容差的情况下会验证 exp 失败,而 60 秒的容差则可通过。Spring Security 资源服务器的默认容差为 60 秒(官方文档),第 6 节中的网关实测值也约为 60 秒。
集群 IdP 的公开元数据
读取了 TT Lab 集群中 Keycloak 的 Discovery 文档和 JWKS(2026-10-26)。两者都是无需认证即可访问的公开信息,以下是 Keycloak 针对每个 Realm 默认提供的列表。
| 项目 | 值 |
|---|---|
| Discovery 文档密钥数 | 57 |
| 端点 | authorization·token·userinfo·end_session·jwks·introspection·revocation·device_authorization·PAR·CIBA·registration |
grant_types_supported |
10个:authorization_code·refresh_token·client_credentials·device_code·token-exchange·jwt-bearer·uma-ticket·ciba, 以及 implicit·password |
code_challenge_methods_supported |
plain, S256 |
id_token_signing_alg_values_supported |
13个(RS、PS、ES、HS 系列及 EdDSA) |
| 登出 | 同时支持 backchannel 和 frontchannel,包含会话(sid) |
| JWKS | 2个密钥:用于签名的 RS256(2048 位),用于加密的 RSA-OAEP |
这里需要注意的是, Discovery 文档是服务器“能够做的事情”的列表,而不是客户端“被允许的事情”。即使 implicit、password、plain 存在于列表中,也可以针对每个客户端进行关闭,并且应该关闭。默认做法是:为每个客户端仅开启标准流程(code),并关闭 implicit 和直接访问(password)流程。
5. 代理(SDK)方式:应用内库所做的事情
应用内的 OIDC 库可以替代上述第 3–4 节的全部内容。列出来比想象中要长:
- 启动时读取 Discovery 文档并获取并缓存 JWKS。如果遇到未知的
kid则重新获取(受次数限制)。 - 如果无会话访问受保护路径,则生成 state、nonce、verifier 并将其存储在 服务器端会话中,然后重定向到 IdP。
- 在回调中比对 state,使用 code 和 verifier 获取令牌,并验证 ID 令牌。
- 签发应用会话 Cookie(HttpOnly·Secure·SameSite)。令牌保留在服务器端。
- 在访问令牌过期前使用刷新令牌进行刷新。如果失败则重新引导登录。
- 登出:清除应用会话,并将其发送到 IdP 的
end_session_endpoint(RP-Initiated Logout)。同时开放接收 IdP 发送的 后端登出 请求的端点(第 7 节)。 - 如果是提供 API 的一方(资源服务器),则检查传入的 Bearer 令牌的签名、iss、aud 和 exp。
在本地实测中,用 Python 模拟这一过程的代码大约有 150 行,即便如此,它也缺少会话存储、密钥轮换和并发处理。这就是不要自己编写、而要使用经过验证的库的原因。
有哪些库可用,以及 Keycloak 适配器的退场
过去,Keycloak 曾直接提供各种语言的 适配器。如今它们大多已不复存在。回顾官方博客,其时间线如下:
| 日期 | 日 |
|---|---|
| 2022-02 | 预告废弃 OIDC Java 适配器(WildFly、Spring Boot 等)以及 Node.js 适配器。建议替代方案为各平台的标准 OIDC 支持(WildFly 原生 OIDC、Spring Security、Quarkus OIDC) |
| 2023-03 | 发布延长支持期限的通知。替代方案示例包括 Spring Security、Elytron OIDC、Quarkus OIDC |
| 2023-11, 23.0.0 | 移除适用于 WildFly 和 EAP 的 OIDC 适配器 |
| 2024-06, 25.0.0 | 移除 Tomcat、Servlet 过滤器、Spring、Spring Boot 适配器等 |
| 2025-02 | 保留用于浏览器的 keycloak-js,并将其拆分为独立仓库和独立的发布周期 |
这一趋势的意义显而易见: OIDC 已经标准化到 IdP 厂商无需再为每种语言维护 SDK 的程度了。如果是 Java,Spring Security 就是典型代表。
# 로그인(oauth2Login) — 앱이 OIDC 클라이언트가 된다
spring.security.oauth2.client.provider.idp.issuer-uri: https://<IdP>/realms/<렐름>
spring.security.oauth2.client.registration.idp: { client-id: <앱>, client-secret: <비밀>, scope: [openid, profile] }
# 토큰 검사(oauth2ResourceServer) — API 가 Bearer 를 검증한다
spring.security.oauth2.resourceserver.jwt.issuer-uri: https://<IdP>/realms/<렐름>
issuer-uri 仅凭 spring-boot-starter-security-oauth2-client·…-resource-server 就可以在首次请求时从 Discovery 文档中查找到 JWKS 地址,且默认时钟容差为 60 秒(Spring Security 文档)。在 Spring Boot 4.x 中,起步依赖名称将更改为 内存中,旧名称将被废弃。 OAuth2AuthorizedClientService 中。放在会话中仅限于匿名请求。如果要增加多个应用实例,则必须将此存储更换为共享存储。
像 Argo CD、Grafana、Harbor、Gitea 这样的产品, 因为内部内置了 OIDC 客户端, 所以只需在配置中填入 IdP 地址和客户端密钥即可。这也是代理方式,区别仅在于库是由我们引入的,还是产品本身自带的。
6. 网关方式:在应用前方代为验证
如果无法修改应用(例如别人制作的仪表盘),或者有多个使用不同语言的服务,最好在入口处统一处理。网关的工作分为两个方向:
- 连登录也一并代劳的代理:以 oauth2-proxy 为代表。它将浏览器重定向至 IdP,接收回调,并将结果以 Cookie 会话(默认存放在 Cookie 中,可更改为 Redis)的形式保存。如果像 Nginx 的
auth_request那样,仅用于询问「是否可以放行此请求」,/oauth2/auth则只会返回 202 或 401。对于通过的请求,会附加诸如X-Auth-Request-User·-Email·-Groups的请求头或Authorization: Bearer <토큰>并向下传递(见官方文档)。 - 仅检查令牌的代理:如 Envoy 的
jwt_authn过滤器、agentgateway 的jwtAuth,用于检查已经持有 Bearer 令牌的客户端(其他服务、CLI、代理)发出的请求。Envoy 默认会缓存远程 JWKS 10 分钟(见官方文档)。
将这两种方向组合使用的配置也很常见。登录代理通过 PKCE 进行登录,并利用 IdP 的群组声明(Group Claim)过滤掉不相关人员,然后将令牌作为 Authorization 请求头附加,随后的服务网格再对同一 JWT 进行重新检查。此时只有一个原则:设 置代理的 Cookie 刷新周期短于 IdP 访问令牌的寿命,且 Cookie 过期时间不超过 IdP SSO 会话的最大长度 。这样一来,在后端检查 JWT 的一方看到过期令牌之前,代理就会获取新令牌,并且在 IdP 中会话结束的人无法仅凭 Cookie 继续访问。在 oauth2-proxy 的官方配置表中,Cookie 刷新(cookie_refresh)默认值为空且为 0 时表示关闭,而 Cookie 过期(cookie_expire)的默认值是 168 小时(7 天)。如果是将令牌向后传递的配置,则两者都需要根据 IdP 的配置进行手动设定。
实测:网关会拦截哪些令牌
使用用于演练的 RSA 密钥生成 JWT,在本地启动了开启 jwtAuth(strict,指定 iss·aud,使用 JWKS 文件)的 agentgateway,并逐个发送了请求(2026-10-26)。
| 发送的令牌 | 结果 | 网关记录的原因 |
|---|---|---|
| 正常 | 200 | — |
| 无令牌 | 401 | no bearer token found |
| aud 不同的应用 | 401 | InvalidAudience |
| iss 不同 | 401 | InvalidIssuer |
| 有效载荷改动一个字符 | 401 | InvalidSignature |
alg: none,无签名 |
401 | 解析标头失败(未知 alg) |
alg: HS256将公钥 PEM 作为 HMAC 密钥(算法混淆) |
401 | InvalidAlgorithm |
未知的 kid |
401 | unknown key |
nbf 在 2 分钟后 |
401 | ImmatureSignature |
| 过期已过 10 秒 | 200 | — |
最后一行很有意思。当我把过期时间分别改为提前 10、30、55、58、62、65、90、120 秒时,发现 提前 58 秒以内可以通过,从 62 秒开始拒绝。同样的, nbf 也是在推迟 55 秒以内可以通过,从推迟 65 秒开始拒绝。这意味着系统默认包含大约前后 60 秒的时钟余量(文档中未确认,实测结果)。这就是为什么在设计时绝对不能相信“精确到秒”的过期时间。
我还测试了成本。分别连续 400 次交替调用了直接请求同一后端、通过无认证网关、以及通过 JWT 检查网关的情况(经过 50 次预热)。
| 路径 | p50 | p90 | p99 |
|---|---|---|---|
| 直接访问后端 | 0.60ms | 0.73ms | 1.19ms |
| 网关(无认证) | 2.51ms | 3.05ms | 4.82ms |
| 网关(JWT 检查) | 2.59ms | 3.17ms | 5.21ms |
相比于多经过一次网关的开销(约 1.9ms,包含笔记本电脑上的容器网络往返), RS256 验证本身仅耗时约 0.08ms 。完全没有必要因为验证成本而对网关望而却步。
两种方式的优缺点
| 代理(SDK) | 网关 | |
|---|---|---|
| 应用代码 | 需要修改(针对不同语言和框架) | 几乎无需修改 |
| 权限判断 | 在业务逻辑内部精细化处理(例如仅限本文作者等) | 精确到路径、方法、请求头级别 |
| 当应用通过其他路径暴露时 | 依然会被拦截 | 会被直接放行。 必须通过网络策略阻止访问应用的其它路径(直接暴露服务、端口转发) |
| 信任标头 | 不适用 | 应用只能信任网关附加的标头,若外部传入同名标头则必须由网关清除 |
| 反映注销 | 收到反向通道(Back-channel)时立即 | Cookie 和 Token 的生命周期可能会残留 |
| 运维 | 管理每个应用的库版本 | 在一处管理版本和配置,但该处为单点故障(SPOF) |
在实际应用中通常会混用。例如,浏览器登录由网关(或 BFF)负责,而 API 的细粒度权限则由应用内部的资源服务器库负责。
7. 注销与会话同步
SSO 注销之所以困难,是因为 会话分为三层。即 IdP 的 SSO 会话(IdP Cookie)、应用的会话(应用 Cookie),以及各自持有的已签发 Token。只清除其中一个,其余的仍会保留。
| 方式 | 谁向谁 | 特点 |
|---|---|---|
| RP-Initiated Logout | 应用 → IdP 的 end_session_endpoint |
id_token_hint 推荐, post_logout_redirect_uri 必须与注册值完全一致 |
| Front-Channel Logout | IdP 在注销页面中通过 iframe 嵌入各应用地址 | 如果浏览器阻止第三方 Cookie,则很容易失败 |
| Back-Channel Logout | IdP → 应用服务器直接发送 POST | 与浏览器无关。 logout_token(JWT)中的 events 和 sub 或 sid, nonce 必须不存在 |
实测:已注销但访问令牌(Access Token)还能再存活 5 分钟
我们在实测 IdP 的客户端中注册了反向通道注销地址(笔记本上接收的小型 HTTP 服务器),在重新登录后,使用刷新令牌(Refresh Token)请求了注销。
| 观察项 | 值 |
|---|---|
| 注销请求 | 204 |
| 收到反向通道通知 | 66ms 后, application/x-www-form-urlencoded 的 logout_token |
| logout_token 标头 | alg: RS256, typ: logout+jwt(规范推荐值) |
| logout_token 声明(Claim) | iss·aud·iat·exp·jti·events·sid·sub·typ. nonce 无。生命周期 120 秒 |
events |
{"http://schemas.openid.net/event/backchannel-logout": {}} |
sid |
与登录时收到的 ID Token 的 sid 相同 → 应用知道该清除哪个会话 |
| 注销后刷新 | 400 “Session not active” |
| 注销后进行内省(Introspection,RFC 7662) | active: false |
| 注销后检查同一访问令牌的签名与过期时间 | 通过,剩余生命周期 299 秒 |
最后一行是核心所在。由于 JWT 访问令牌是 自我证明的文档,因此只检查签名的 API 和网关并不知道 IdP 已经清除了会话。即使在注销后,在令牌生命周期结束前(此处为 5 分钟),仍然可以调用 API。对此有三种应对策略:缩短访问令牌的生命周期;对于重要 API,通过内省向 IdP 查询(每次请求都有往返开销);或者接收反向通道通知,将该 sid 加入黑名单。
刷新令牌也有类似的考量。在实测领域(Realm)的默认值(关闭轮换)下, 重复使用已经用过一次的刷新令牌依然返回 200 。开启领域设置中的 revokeRefreshToken 后(轮换),旧的刷新令牌被 400 “Maximum allowed refresh token reuse exceeded” 拒绝, 并且紧接着连新的刷新令牌也被 “Session doesn't have required client” 拦截了。 这是将重用视为被盗信号并切断整个会话的行为。RFC 9700 §2.2.2 要求公开客户端的刷新令牌必须进行轮换或绑定到发送方。
8. 常见陷阱
① redirect_uri 不匹配。 IdP 会将注册地址与 进行精确的字符串 比较(RFC 9700 §2.1,原生应用的 localhost 端口除外)。在实测中,末尾的一个 / 导致了 400 错误页面的出现。如果应用在代理后面却将自己的地址误认为是 http:// (例如在前端终止了 TLS),或者端口、路径前缀不一致,都会引发同样的错误。切勿使用通配符 redirect URI,因为这会给攻击者创造机会,让授权码(code)发送到其指定的路径。
② iss 字符不匹配。 令牌的 iss 与应用配置的 issuer 必须在 http/https、主机名以及末尾的 / 上完全一致。在使用内部地址调用 IdP、而浏览器使用公开地址访问的架构中,这一点尤为容易出错。Keycloak 可以根据收到的主机名动态生成 iss ,因此最好预先配置一个固定的公开主机名。
③ 时钟漂移。 如果 IdP、网关和应用服务器的时钟不一致,刚签发的令牌就会变成“尚未生效”(nbf/iat)或“已expired”。实测发现,网关和 Spring 的默认容忍误差约为 60 秒。在增加容忍时间之前,应先同步 NTP。
④ 将令牌存放在浏览器存储中。 如果 SPA 将访问令牌和刷新令牌存放在 localStorage 中,一次 XSS 攻击就会导致令牌全部泄露。2026 年 8 月成为 RFC 10017 (BCP 212) 的《面向浏览器应用的 OAuth 2.0》按安全性从高到低排列了各种模式:BFF(Backend for Frontend) → 令牌代理后端 → 纯浏览器端,并指出纯浏览器端极易受到前面讨论的所有攻击。BFF 作为持有密钥的服务端客户端,将令牌保存在服务端,只向浏览器下发 HttpOnly 会话 Cookie。也就是说, 这相当于在服务端实现了第 5 节的 SDK 方式。
⑤ 忽略 aud 校验。 正如第 4 节中 Keycloak 的默认 aud: account 那样,如果令牌的接收对象明明不是你的 API 却依然接受它,那么其他属于同一 IdP 的应用令牌也能用来调用你的 API。
⑥ Secure Cookie 与明文 http。 IdP 的登录 Cookie 默认带有 Secure 属性(实测)。浏览器将 localhost·127.0.0.1 视为安全源并发送它,但在局域网的明文 http 地址下则不会发送,从而导致“明明登录了却反复出现登录页”的死循环。实测中,Python 的 Cookie 存储就曾因为这个原因不发送 Cookie 从而收到了 400 错误。
⑦ JWKS 密钥轮换。 当 IdP 更换密钥时,会带有新的 kid 令牌。缓存时间过长的验证器(Envoy 默认 10 分钟)在此期间可能会拒绝该令牌,因此 IdP 应先公开新密钥并在很长一段时间后再用于签名,同时让验证器能够从未知的 kid 重新获取 JWKS。
⑧ 绕过网关与标头伪造。 在网关架构中,如果应用信任 X-Auth-Request-User ,则必须确保不存在从外部直接传入该标头来调用应用的路径。
9. 该如何选择
| 场景 | 推荐方案 |
|---|---|
| 需要在他人开发的 Web UI(仪表盘、笔记本等)前加上登录验证 | 网关(oauth2-proxy) |
| 产品本身已内置 OIDC(如 Argo CD、Grafana 等) | 产品内置(= SDK 方式) |
| 我们自己开发的 Web 应用,需要细粒度权限控制 | 服务端 SDK(BFF)。Spring Security、各语言的认证库 |
| 服务之间、CLI、代理通过 Bearer Token 调用的 API | 网关 JWT 检查 + 应用内资源服务器检查(双重校验) |
| 纯 SPA,无后端 | 条件允许时配置 BFF。如果较难实现,则采用 PKCE + 短生命周期 Token + 轮换 |
10. 总结
- SSO 集成归根结底是“由谁来执行相同的 OIDC”的问题。无论是应用内的库(代理、SDK)还是应用前的代理(网关),它们所做的事情都是一样的:重定向、PKCE、code 交换、签名与声明检查、会话、刷新以及注销。
- 签名只能证明来源。
iss·aud·exp·nonce必须单独检查,实测中 Keycloak 访问令牌的默认aud并不是 API 名称。 - 注销分为三层。实测表明反向通道通知在 66ms 内就到达了,但在只检查签名的那一端,访问令牌在剩余的 5 分钟内仍然有效。
- Keycloak 的各语言适配器已在 23/25 版本中移除,现在应当使用各平台标准的 OIDC 支持。
Titi 的温馨提示: 即使通行证(Token)上盖了章(签名),也必须单独检查它是“不是我们乒乓球馆的”(aud),以及是否在“今天的日期内”(exp)。此外,即使被勒令退场,手中持有的通行证在有效期内依然有效,所以颁发较短的有效期才是上策。
参考资料
- RFC 6749, OAuth 2.0. §4.1.2(建议在 code 重用时废弃已发行的令牌)。https://www.rfc-editor.org/rfc/rfc6749.html
- RFC 7636, PKCE. §4.1·§4.2·附录 B. https://www.rfc-editor.org/rfc/rfc7636.html
- RFC 9700, OAuth 2.0 Security Best Current Practice (2025-01). §2.1·§2.1.1·§2.1.2·§2.2.2·§2.4. https://www.rfc-editor.org/rfc/rfc9700.html
- RFC 10017, OAuth 2.0 for Browser-Based Applications (BCP 212, 2026-08). §5·§6. https://www.rfc-editor.org/rfc/rfc10017.html
- RFC 7519(JWT) §4.1.4·§4.1.5, RFC 7515(JWS) §3.1·§7.1, RFC 7517(JWK) §4.5·§5, RFC 8725(JWT BCP) §3.1·§3.2·§3.9, RFC 7662(introspection) §2.1·§2.2, RFC 9068(JWT 访问令牌) §2.1·§4. https://www.rfc-editor.org/
- OpenID Connect Core 1.0 §2·§3.1.2.1·§3.1.3.7 https://openid.net/specs/openid-connect-core-1_0.html · Discovery 1.0 §4 https://openid.net/specs/openid-connect-discovery-1_0.html · Back-Channel Logout 1.0 §2.4·§2.5 https://openid.net/specs/openid-connect-backchannel-1_0.html · Front-Channel Logout https://openid.net/specs/openid-connect-frontchannel-1_0.html · RP-Initiated Logout https://openid.net/specs/openid-connect-rpinitiated-1_0.html
- OASIS, SAML V2.0 Technical Overview §4.6·§5.1.2. https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html
- Keycloak 博客:适配器弃用 (2022-02) https://www.keycloak.org/2022/02/adapter-deprecation · 更新 (2023-03) https://www.keycloak.org/2023/03/adapter-deprecation-update · 23.0.0 https://www.keycloak.org/2023/11/keycloak-2300-released · 25.0.0 https://www.keycloak.org/2024/06/keycloak-2500-released.html · 分离 keycloak-js (2025-02) https://www.keycloak.org/2025/02/keycloak-js-2620-released
- Spring Security 资源服务器 JWT(时钟漂移 60 秒) https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html · OAuth2 客户端核心 https://docs.spring.io/spring-security/reference/servlet/oauth2/client/core.html · Spring Boot Starter https://docs.spring.io/spring-boot/reference/using/build-systems.html
- oauth2-proxy:端点 https://oauth2-proxy.github.io/oauth2-proxy/features/endpoints · 配置(cookie_refresh 和 cookie_expire 默认值) https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview
- Envoy jwt_authn(cache_duration 默认为 10 分钟) https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/jwt_authn/v3/config.proto
- 所有原文均于 2026-10-26 打开并核实。