博客
Okta 通用登出是一份过期草案
Okta 的 Universal Logout 依据的是一份没人采纳、已经过期的标准草案。Okta 发什么、nbf 时间陷阱,以及任何设置都放宽不了的校验。
作者 FoxAuth ·
需求听上去很简单。一家公司的员工通过公司的身份提供商(IdP)登录我们的授权服务器。身份提供商就是存放员工账户、员工在那里登录的服务。有时公司的安全团队会决定:某个人的访问必须终止,在所有地方,而且马上。这时,身份提供商应当能够通知我们。不是等到这个人下一次登录,那可能要到明天。而是现在:会话、刷新令牌,统统终止。
于是我去找一种标准方式,让身份提供商告诉应用「终止这个人拥有的一切」。这样的应用叫作依赖方(relying party):它把核实登录者身份这件事交给身份提供商。结果并没有这样的标准方式。有一份最终规范,但管的是更窄的事。还有一份管更宽的事,但大型提供商不会把它的消息发给其他公司的应用。另外还有一份个人 Internet-Draft。Internet-Draft 是正在走向标准的工作草案;「个人」草案指的是作者自己写的,而不是由工作组写的。这份草案始终没有被采纳,而且已经过期。Okta 发送的恰恰就是它。
大型企业身份提供商中,只有 Okta 会发出「终止一切」,而它依据的是一份过期草案
以下是截至 2026 年 10 月,每家提供商实际会向我们这样的应用发送的内容。这比营销页面暗示的要少。
后端通道登出(back-channel logout)是 OpenID 的一份最终规范,而且是好规范。按照它,提供商绕过浏览器,直接从服务器到服务器通知应用:用户登出了。但它报告的是登出,不是账户失陷。它只终止源自提供商处同一次登录(同一个上游会话)的那些会话。离线访问能让应用在用户不在场时继续工作,而按设计,离线访问应当在这样的登出之后保留下来。
Shared Signals 和 CAEP 是表达「此会话已撤销」的通用标准。但大型企业身份提供商中,没有一家会把这些信号发给任意依赖方。Entra 对其他公司的应用什么都不发送。
剩下的就是 Okta 的 Universal Logout(通用登出)。它背后的草案是 draft-parecki-oauth-global-token-revocation。第 06 版发布于 2026 年 2 月。它于 8 月 28 日过期,OAuth 工作组始终没有采纳它。也没有带 draft-ietf- 前缀的后继版本(只有工作组采纳的草案才用这个前缀)。我们考察了十六款同类产品。其中只有另外一款,也就是 Auth0,会接收这个请求。
我还是把它做出来了,而且我认为这样做是对的。但前提是三条保险措施;如今对任何建立在过期草案之上的东西,我都会坚持这三条。第一,我们按照 Okta 文档中写明的那个请求来实现,而不是把草案当成标准来读。第二,一切都放在专属开关 globalTokenRevocation.enabled 后面。它默认关闭,控制台里对它的说明写明了草案已经过期。第三,报文格式(wire format,也就是通过网络到达的具体内容和形式)放在一个小模块里。模块下面是一个对这种格式一无所知的核心。如果格式变了或者消亡了,决定谁可以终止谁的访问的那部分保持不动。
Okta 发送什么、能指名谁、我们终止什么
Okta 发送一个 POST 请求。JSON 请求体里指明了用户。Authorization 请求头里带着一个 JWT,由 Okta 用它自己的密钥签名:
POST /acme/global-token-revocation HTTP/1.1Host: auth.example.comAuthorization: Bearer eyJ0eXAiOiJnbG9iYWwtdG9rZW4tcmV2b2NhdGlvbitqd3QiLCJhbGciOiJSUzI1NiJ9...Content-Type: application/json
{ "sub_id": { "format": "iss_sub", "iss": "https://acme.okta.com", "sub": "00u1a2b3c4d5e6f7g8h9" } }把这个 JWT 解码,就能看到是谁在请求、针对的是哪个端点。下文把它称为断言(assertion),也就是一方签名后交给另一方的一份陈述。
{ "typ": "global-token-revocation+jwt", "alg": "RS256", "kid": "q8J1..." }{ "iss": "https://acme.okta.com", "sub": "0oa1b2c3d4e5f6g7h8i9", "aud": "https://auth.example.com/acme/global-token-revocation", "iat": 1759917600, "nbf": 1759917300, "exp": 1759917900, "jti": "1a8c3e0f-5b2d-4c7e-9f61-2d0b8a4e7c35"}iss 是 Okta 组织。sub 是我们在这个组织里的客户端 ID。Okta 在文档中把它写作 client_id/appInstanceId;实际到达的是哪种形式,只有真实运行才能确定。这两个字段合在一起,指向配置在某一个用户桶上的某一个上游提供商(即我们的用户借以登录的外部身份提供商)。用户桶是本服务器对一组最终用户的叫法,这组用户有自己的登录规则。用户桶一旦有了自己的地址,就会以自己的名义签发令牌,也就是成为独立的签发方(issuer)。
请求体中的 sub_id 指明这个人:用他的 subject(他在该提供商处的标识),或者按 Okta 的另一种格式,用电子邮件地址。不是谁都能被指名。提供商只能指名通过它在这里登录的用户,或者由绑定到它的 SCIM 连接创建的用户。(SCIM 是一种协议,公司的用户目录用它在其他系统中自动创建和停用账户。预配连接是把一个目录关联到一个用户桶的记录。)其他所有人都会得到同样的 404,连根本不存在的人也一样。所以没人能用这个端点探查谁拥有账户。
这个请求会终止这个人拥有的一切:会话、授权(grant,即用户给应用的许可)、刷新令牌和不透明访问令牌。不透明令牌(opaque token)是一串没有内容的随机字符串,只有服务器知道它代表什么。每个依赖方也会收到一次后端通道登出。账户本身保持启用。这个人可以立刻重新登录,但需要重新给出同意。草案的 MUST,也就是它要求的最低限度,要小一些:撤销刷新令牌,并让用户重新登录。草案允许做得更多。
终止访问有好几条路径:管理员的操作、来自 Okta 的这个请求、通过 SCIM 停用。它们都走同一个操作,这是我们有意做出的决定。我们的管理员通过「Sign out everywhere」(全部登出)按钮得到的也是同样的效果。
断言的有效期要从 iat 算起,不能从 nbf 算起,否则每个真实请求都会被拒绝
看看这个 JWT 内容里的三个时间。iat 是断言的签发时间。nbf(「not before」)是一个时刻,在它之前断言无效。exp 是断言的过期时间。nbf 比 iat 早五分钟,exp 比 iat 晚五分钟。Okta 是有意这么做的:即使接收方的时钟和 Okta 的略有偏差,请求也照样能通过。这完全正当。
我们把出示的断言的有效期上限定为 300 秒。原因是重放(replay):截获断言的人可以把它再发一次。断言活得越久,这个窗口就开得越久。衡量 JWT 有效期最直观的方式,是从它开始生效算到它失效,即 exp - nbf。对 Okta 的断言来说,这是 600 秒。按这种方式衡量,服务器会拒绝每一个真实请求。而且这个拒绝看上去就像一项正确的安全检查。
所以我们从签发时间(iat)和当前时间两头计算有效期。nbf 只按它本来的含义检查:在那个时刻之前,断言尚未生效。这项检查由 jose 库的 jwtVerify 在我们的代码运行之前完成:
const { exp, iat, jti } = claims;if (typeof exp !== 'number' || typeof iat !== 'number') throw refused('lifetime');if (typeof jti !== 'string' || !jti) throw refused('identifier');
const now = epochTime();if (iat > now + clockTolerance) throw refused('issued_in_future');if ( exp - iat > MAX_LIFETIME_SECONDS + clockTolerance || exp - now > MAX_LIFETIME_SECONDS + clockTolerance) { throw refused('lifetime');}对照当前时间的检查,是人们常常漏掉的另一半。假设有一个签发时间写着明天、有效期五分钟的断言。它完全能通过 exp - iat 的检查。如果只有这一项检查,这个断言在整整一天里都会被视为有效。所以我们拒绝来自未来的 iat,并限制从现在到 exp 还剩多少时间。这就堵上了漏洞。对提供商的时钟,我们只信任一件事:时间朝哪个方向走。
校验写死在代码里,因为能关掉的校验,总会有人关掉然后忘掉
一个能终止会话的端点就是一根杠杆:用它可以切断一整家公司员工的访问。要拉动这根杠杆,攻击者需要三样东西:提供商的 issuer(即 iss 的值)、我们在该提供商处的客户端 ID,以及某个用户在那里的 subject。这些都不是秘密。所以这个端点上的每一项校验,都按请求来自攻击者的假设来写。将来到达它的请求中,确实会有相当一部分来自攻击者。
Keycloak 8 月份的 CVE-2026-18569 是我时常放在眼前的案例。Keycloak 在接收后端通道登出时,会接受用 alg: none 签名的登出令牌,也就是根本没有签名的令牌。只要运维者为某个提供商关闭了签名校验,就会这样。我不认为有人会在生产环境中故意关闭签名校验。我猜他们是为了调试一个连不上的提供商而关掉了一次,然后就忘了。所以在我们的服务器中,可接受的签名算法集合是一个常量。它只包含非对称算法,而且只取提供商公布的那些。任何设置都无法扩大它:无论是提供商的、用户桶的,还是整个实例的。none 和所有 HS* 都会被拒绝。HS* 是对称算法:验证签名的一方必须持有生成签名的同一把密钥。而签署这些令牌的密钥,除了提供商之外谁都不应持有。
其余校验遵循同样的规则:每一项都是常量,没有一项可以配置。
提供商按 issuer 和客户端 ID 两个独立的值来查找,绝不拼成一个组合键。原因是 Keycloak #42209。在那里,代理登出(brokered logout,即针对通过外部提供商登录 Keycloak 的用户的登出)对任何别名中含有点号的提供商都会失效。
aud(断言是发给谁的)必须恰好是这个端点。exp、iat 和 jti 都是必需的。jti 是断言的唯一编号,每个 jti 只接受一次。
为了记住哪些 jti 已经见过,我们把每个 jti 记录在一个由用户桶和提供商构成的命名空间下。这个命名空间以分隔符结尾。之所以需要分隔符,是因为这条记录的 ID 是命名空间与 jti 直接拼接(中间没有任何东西)后的哈希。没有分隔符,两组不同的「命名空间 + jti」就可能拼成同一个字符串。
如果某个提供商没有启用此功能,它会收到 403。但这只发生在它完成认证之后。所以未通过认证的调用方,无法从这个拒绝中得知哪些提供商启用了此功能。此外,每次凭据错误的尝试都会计入严格的按来源(origin)限流。这和 SCIM 端点使用的是同一个计数器。
两次获取密钥之间要留出冷却期,否则每个伪造请求都会打到提供商的服务器上
还有一个陷阱,它在密钥缓存里,而不在校验里。jose 库默认在两次获取提供商密钥集之间至少等待三十秒。这段等待叫作冷却期(cooldown)。在联合登录路径上(也就是用户通过外部提供商登录我们的时候),我们的服务器关闭了冷却期。原因是 OpenID Foundation 一致性测试套件(检验服务器是否遵循标准的测试)中的密钥轮换测试。它显示,提供商每次更换密钥(轮换)之后,登录都会失败半分钟。而在这条路径上,冷却期什么也防不住。ID 令牌是我们自己从提供商的令牌端点取回的。所以未知的 kid(签署令牌所用密钥的标识)只可能来自提供商本身。
撤销请求恰恰相反。任何人都能发送撤销请求,kid 想填什么就填什么。而没有冷却期的缓存,会为每个未知的 kid 重新获取提供商的密钥。这样任何人都能借我们的服务器去冲击客户的身份提供商:每个伪造请求都会让我们的服务器从提供商那里获取一次密钥。所以同一个提供商有两个密钥缓存。用于请求中出示的 JWT 的那一个,保留了冷却期:
export function presentedKeySetFor(jwksUri: string): RemoteKeySet { const existing = presentedSets.get(jwksUri); if (existing) return existing; /* (the bound on how many providers are held is left out here) */ /* jose's default 30 s cooldown: a genuine rotation is honoured once per cooldown. */ const created = createRemoteJWKSet(new URL(jwksUri), { [customFetch]: fetchThroughBoundary }); presentedSets.set(jwksUri, created); return created;}代价是:Okta 那边真实的密钥轮换,我们每三十秒最多只采纳一次。如果在这个窗口内到达的撤销请求用的是新密钥(新的 kid),它会被拒绝。比起一个谁要求就去获取密钥的端点,我每次都宁愿接受这个取舍。
Okta 的草案只是共享核心上的一个模块,所以下一种格式直接拿到了全部校验
支撑这一切的核心做三件事,并且对 Okta 一无所知。它认证这个用户桶的一个上游提供商。它决定该提供商可以指名谁。它终止那个人的访问。全局令牌撤销模块只补充细节:哪个声明(令牌里的字段)携带我们的客户端 ID、它期望的受众(aud)、它接受的令牌类型、存放重放记录的命名空间,以及哪个提供商选项开启它。
这种拆分几乎立刻就回本了。Keycloak、Auth0 和 Ping 发给我们的后端通道登出,是同一个核心上的第二个模块。它继承了上面所有的拒绝规则,一条都不用重写。它唯一需要改的,是我们的客户端 ID 放在哪里。登出令牌把它放在 aud 中,Okta 的断言把它放在 sub 中。这个位置由格式自己声明,核心不去猜。如果核心去猜,令牌就能自己选择由哪个提供商来校验它。后端通道登出已经在真实的 Keycloak 26.8.0 上运行过。如果哪天有提供商真的发送 CAEP session-revoked 事件,那将是第三个模块。
我什么时候会开启它,以及为什么它默认关闭
如果一个部署的客户在使用带 Identity Threat Protection 的 Okta,并期望 Universal Logout 能覆盖每一个应用,我会为它开启。Identity Threat Protection 是 Okta 的一种许可,Okta 正是在这种许可下发送 Universal Logout。即便如此,我也会在文档里把这个开关注明为实验性功能,并在第一周安排专人盯着拒绝事件。其他所有地方我都会让它保持关闭,因为一个你不需要的端点,就是又多了一条进来的路。
而且我会如实说明它的现状,就像我在这里尽力做的那样。它还没有在真实的 Okta 组织上运行过,因为那需要 Identity Threat Protection。测试重放的是 Okta 文档中描述的请求,用一个桩提供商(为测试而做的假提供商)的密钥签名。还有两个问题悬而未决。第一,Okta 在文档中把 sub 写作 client_id/appInstanceId。这可能并不是提供商配置中使用的那个裸客户端 ID。第二,提供商可能配置的是某个自定义授权服务器(Okta 内部的独立授权服务器)的 issuer。这个 issuer 可能与 Okta 请求中携带的组织 issuer 不一致。这两个问题都只有真实运行才能确定。
通常与 Universal Logout 配套的 SCIM 一侧也有自己的缺口。Okta 的 CRUD 测试(检验用户能否被创建、读取、更新和删除)还没有运行,也没有 /Bulk 端点。这些都还没有提交认证。如果草案复活并发生变化,模块会随之变化。如果它就此死去,Okta 大概会转向别的东西。而核心仍然是值得写下的那一部分。