TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

SSO 集成的两种方式:应用内代理(SDK)与应用前网关

状态:草稿(2026-10-26),已反映实测结果。 实测共有三项。① 读取了 TT Lab 集群 Keycloak 中任何人都可以读取的公开元数据(/.well-known/openid-configuration,JWKS)。已隐藏主机名、Realm 名称和版本号。② 在笔记本电脑上 新建了用于实测的 Keycloak(dev 模式,与生产环境隔离的练习环境) ,用随机数创建了实测 Realm、客户端和用户,并仅使用 Python 标准库运行了 Authorization Code + PKCE。未使用真实的 用户账号登录。③ 将使用实测密钥创建的 JWT 发送到本地网关(agentgateway)进行判定。文章中未包含实测密钥的私钥部分。规范在同一天打开原文进行了确认。 编写规则:以实测数据为基础,未能测出的记为“未实测”,无法确认的记为“无法确认”,猜测的记为“推测”。

目录

1. 通行证在一个地方颁发,在哪里核验?

想象一下公司大楼。通行证由安保部门统一签发。这就是 IdP(Identity Provider,例如 Keycloak)。核验通行证的方法有两种:

这两种方式使用 相同的协议(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:防止令牌流经浏览器

Authorization Code + PKCE: 토큰은 브라우저를 지나지 않는다 브라우저 사용자 앱 (SDK) 또는 게이트웨이 IdP Keycloak 등 ① 보호된 페이지 요청 ② verifier 난수 → challenge S256(verifier). state·nonce 는 세션에 ③ 302 → IdP /auth challenge · state · nonce · redirect_uri ④ 로그인 화면 · 비밀번호 · MFA IdP 쿠키 = SSO 세션 ⑤ 302 → redirect_uri?code&state&iss code 는 한 번만, 짧게(초 단위) ⑥ /callback 도착 state 가 저장한 값과 같은가 ⑦ 뒤채널 POST /token code + verifier + 클라이언트 인증 ⑧ ID · 액세스 · 리프레시 토큰 ⑨ 서명·iss·aud·exp·nonce 확인 → 앱 세션 쿠키만 브라우저로 주황 화살표만 토큰이 오간다. 가로챈 code 만으로는 verifier 가 없어 교환이 안 된다. 실측(노트북 실습용 Keycloak): ③–⑥ 117ms, ⑦–⑧ 9ms.
图:Authorization Code + PKCE 在浏览器、应用和 IdP 之间交互的九个步骤

流程的核心是:通过浏览器传递的只有生命周期很短的 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 세 조각과, 받는 쪽이 지켜야 할 검사 순서 헤더 페이로드(클레임) 서명 . . alg · typ · kid iss · sub · aud · exp · iat · nonce RS256: 개인키로 서명 base64url 로 감싼 JSON — 암호화가 아니라 그냥 보인다 1 alg 를 내가 정한 목록으로 고정 none · HS256 바꿔치기 거절 2 kid 로 JWKS 의 공개키 고르기 모르는 kid 면 JWKS 다시 받기(횟수 제한) 3 서명 검증 s^e mod n 이 SHA-256 과 맞나 4 iss 가 글자까지 같은가 끝의 / 하나도 다르면 실패 5 aud 에 내 이름이 있는가 다른 앱에 준 토큰 재사용 막기 6 exp · nbf (시계 오차 허용) 보통 60초 안팎 7 ID 토큰이면 nonce 로그인 요청과 묶였나 1–3 은 「누가 만들었나」, 4–7 은 「나에게, 지금, 이 로그인으로 온 것인가」.
图:JWT 的头部、负载、签名三个部分,以及接收方需要遵守的七个检查步骤

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)。

在实测令牌中发现了两个陷阱。

  1. Keycloak 访问令牌的 aud 默认是 account 。 这并不是我的 API 的名称。如果 API 端要正确检查 aud ,必须在 IdP 中附加一个 audience 映射器,以便将 API 名称写入其中。如果在 API 中偷懒关闭了 aud 检查,那么其他应用从同一个 IdP 获得的令牌将能够调用我的 API。
  2. 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, 다른 자리: 앱 안에서 하나, 앱 앞에서 하나 에이전트(SDK) 방식 브라우저 앱 프로세스 OIDC 라이브러리 리디렉션·콜백·세션·갱신·로그아웃 업무 코드가 사용자·역할을 직접 본다 IdP 뒤채널 /token · 로그아웃 게이트웨이(프록시) 방식 브라우저 게이트웨이 oauth2-proxy: 로그인·쿠키 Envoy·agentgateway: JWT 서명·aud·exp 검사 통과하면 헤더를 붙여 넘김 앱 헤더만 읽음 IdP 뒤채널 SDK 게이트웨이 앱 코드 변경 필요(언어마다) 거의 없음 세분화된 권한 쉽다(코드 안) 헤더·경로 단위 앱이 직접 노출되면 그래도 막힌다 뚫린다(우회 경로 금지) 로그아웃 반영 뒤채널 받으면 즉시 쿠키·토큰 수명까지
图:代理(SDK)方式与网关方式的架构,以及五项对比

应用内的 OIDC 库可以替代上述第 3–4 节的全部内容。列出来比想象中要长:

  1. 启动时读取 Discovery 文档并获取并缓存 JWKS。如果遇到未知的 kid 则重新获取(受次数限制)。
  2. 如果无会话访问受保护路径,则生成 state、nonce、verifier 并将其存储在 服务器端会话中,然后重定向到 IdP。
  3. 在回调中比对 state,使用 code 和 verifier 获取令牌,并验证 ID 令牌。
  4. 签发应用会话 Cookie(HttpOnly·Secure·SameSite)。令牌保留在服务器端。
  5. 在访问令牌过期前使用刷新令牌进行刷新。如果失败则重新引导登录。
  6. 登出:清除应用会话,并将其发送到 IdP 的 end_session_endpoint (RP-Initiated Logout)。同时开放接收 IdP 发送的 后端登出 请求的端点(第 7 节)。
  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. 网关方式:在应用前方代为验证

如果无法修改应用(例如别人制作的仪表盘),或者有多个使用不同语言的服务,最好在入口处统一处理。网关的工作分为两个方向:

将这两种方向组合使用的配置也很常见。登录代理通过 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. 总结

Titi 的温馨提示: 即使通行证(Token)上盖了章(签名),也必须单独检查它是“不是我们乒乓球馆的”(aud),以及是否在“今天的日期内”(exp)。此外,即使被勒令退场,手中持有的通行证在有效期内依然有效,所以颁发较短的有效期才是上策。

参考资料

登录后即可点赞

评论

还没有评论。

登录后即可发表评论