---
title: "Okta 通用登出是一份过期草案 — FoxAuth"
description: "Okta 的 Universal Logout 依据的是一份没人采纳、已经过期的标准草案。Okta 发什么、nbf 时间陷阱，以及任何设置都放宽不了的校验。"
canonical: "https://foxauth.dev/zh-cn/blog/okta-universal-logout-is-an-expired-draft/"
lastmod: "2026-10-08T21:45:57+03:00"
---
博客
Okta 通用登出是一份过期草案
Okta 的 Universal Logout 依据的是一份没人采纳、已经过期的标准草案。Okta 发什么、nbf 时间陷阱，以及任何设置都放宽不了的校验。
作者 FoxAuth · 2026-10-08

需求听上去很简单。一家公司的员工通过公司的身份提供商（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.1

Host: auth.example.com

Authorization: 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 大概会转向别的东西。而核心仍然是值得写下的那一部分。

全部文章亲自试试
