博客
Entra、Okta 的 SCIM 方言
严格遵循互操作规范的 SCIM 服务器,会拒绝 Entra 和 Okta 实际发送的请求。是哪些请求,为什么一个设置就够。
作者 FoxAuth ·
公司把员工信息放在目录服务里,比如 Okta 或 Microsoft Entra ID。目录服务会自动在公司用到的每个应用里,为每个人创建、更新和停用账号。这叫预配(provisioning),SCIM 就是目录服务做这件事时用的协议。
下面是 Okta 通过 SCIM 停用用户的请求,格式与 Okta 自己的协议参考文档完全一致。它发往我们的一个用户桶,即路径里的 acme。用户桶是本服务器对一组终端用户的叫法,这组用户有自己的登录规则。用户桶有了自己的地址后,会以自己的名义签发令牌,也就是成为一个独立的签发者(issuer)。
PATCH /acme/scim/v2/Users/2819c223-7f76-453a-919d-413861904646 HTTP/1.1Host: auth.example.comAuthorization: scimst_q3TqC1lG0iJ...Content-Type: application/scim+json
{ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [{ "op": "replace", "value": { "active": false } }]}这个请求里没有 path,也就是说明要改哪个属性的那个字段。Okta 把改动本身直接放进了 value:把 active 设为 false。SCIM 标准 RFC 7644 允许这样做。
但还有一份 SCIM 2.0 互操作规范(interoperability profile)。这类规范是对标准的收窄,好让不同的实现能互相理解。这一份规定,服务器必须拒绝没有 path 的 PATCH 操作(即只修改记录一部分的操作)。我们遵循的是 OpenID IPSIE SCIM 规范(IPSIE 即 Interoperability Profiling for Secure Identity in the Enterprise),它建立在那份互操作规范之上。这条更严的规则有充分的理由:SCIM 修改规则里所有含糊不清的情况,都出在不带 path 的操作上。所以,一个照规范行事的服务器会用 400 回应这个请求。而昨天已经离职的那个人,账号仍然处于启用状态。
在写服务器的第一行代码之前,我就仔细读过这两份规范。可我仍以为,真正有意思的工作是遵循它们。事实并非如此。大多数客户用的是两个目录服务之一:Entra 或 Okta。而这两个都无法在一台严格遵循这些规范的服务器上管理用户。所以真正值得知道的是:它们各自究竟在哪里偏离了规范。
Okta 用规范所禁止的请求来停用用户
客户端偏离规范的那些做法,我称之为它的方言。Okta 的方言很小,所以容易被低估。要紧的有两种形式。第一种是上面那个不带 path 的 replace。Okta 正是用它来停用用户、再重新启用用户,而这是预配客户端最重要的工作。第二种是 Okta 创建用户时附带的 password 属性。
IPSIE 在用户 schema 中彻底禁止 password。原因在于联合登录:应用不保存自己的密码,而是让用户去目录服务登录。把密码推送到每个应用的目录服务,恰恰是联合登录本来要消除的东西。何况我们的用户本来就是通过目录服务登录的。
Okta 证明自己身份的方式也偏离了规范,而且这一点最出人意料。Okta 根本无法通过 OAuth 客户端凭据(client credentials)为 SCIM 获取令牌。客户端凭据是 OAuth 中程序在没有用户参与时自行获取访问令牌的方式。Okta 发送的是静态令牌:一个固定的字符串,每次请求都一样。在请求头模式下,Okta 把这个令牌直接作为整个 Authorization 值发送,前面不带 Bearer 一词。
这就是我们的静态令牌以 scimst_ 开头的原因。这个前缀有两个用处。服务器无需查询存储,就能把这种令牌与访问令牌区分开,并在没有 Bearer 的情况下接受它。密钥扫描工具也能凭它认出泄漏到代码仓库里的令牌。服务器只保存令牌的 SHA-256 摘要,这是一种指纹,无法从中还原出令牌本身。令牌只显示一次。
Entra 偏离规范更多,Microsoft 还用一个开关承认了这一点
Microsoft Entra ID 偏离规范的地方更多,也更古怪:
- 操作名首字母大写:
"op": "Replace",而 RFC 7644 中的操作名是小写; - 把 true 和 false 写成字符串:
"value": "False"; - 不带 path、一次修改多个属性的
replace。它的键是用点号连接的子属性名,以及扩展 schema 中属性的完整名称(URN):name.givenName与urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department并列; - 默认属性映射中包含
addresses。属性映射是决定 Entra 发送哪些用户字段的设置,而我们的用户 schema 并未声明这个属性; - path 中的过滤器什么都匹配不到的
replace。例如,对一个还没有工作邮箱的用户发送emails[type eq "work"].value(用户邮箱列表中的工作邮箱)。RFC 7644 规定服务器此时应返回noTarget,而 Entra 的意思是「添加它」; - 还有企业扩展中的
manager属性。Entra 以裸 ID 发送它:"value": "mgr-1",而 schema 规定的是{ "value": "mgr-1" }。要清除它时,Entra 发送的不是remove,而是值为空字符串的replace。
这份清单的大部分来自 Microsoft 的文档,以及其他 SCIM 服务器开发者的反馈。manager 的这两种形式不是,也正是我最不喜欢的。直到我们的服务器没通过 Microsoft 自己的 SCIM 验证器(Microsoft 为 SCIM 服务器准备的一套测试)中的 Add、Replace 和 Remove Manager 测试,我们才发现它们。这种行为没有任何文档记载,是厂商自己的一致性测试把它暴露了出来。
Microsoft 在文档中记载了一个功能开关 aadOptscim062020。Entra 管理员把它附加在租户 URL(tenant URL)后面,租户 URL 就是管理员在 Entra 中填写的应用 SCIM 端点地址。加上这个开关后,Entra 会把一部分旧行为切换为标准要求的形式,把 true 和 false 写成字符串就在其中。要说 Entra 的方言确实存在,我不知道还有比这更明确的承认。但服务器不能指望每个客户都加上了这个开关。
下面把全貌汇总在一处。严格的服务器一字不差地遵循规范。宽容的服务器接受这些形式,并按客户端的本意处理。把每一行当作一句话来读:这个客户端发送这个,严格的服务器拒绝它,而宽容的服务器改为这样处理。
左边一列并不罕见。它是两个客户端的日常流量,而这两个客户端覆盖了大部分企业市场。一台按「严格」列回应的服务器符合规范,却没有人能用。
我们扔掉了两个库:它们拒绝正常请求,却放过危险请求
我们的计划为最难的部分选了两个广泛使用的库。一个解析 SCIM 过滤器,也就是目录服务用来查找用户的条件。另一个把 PATCH 应用到已存储的记录上。两个最后都被我们扔掉了。为什么扔掉它们,比用什么替代它们更值得一说。
那个过滤器解析器用一个会回溯(backtracking)的模式来匹配带引号的字符串。遇到一串没有结束引号的换行符时,它花的时间会随这串字符的长度呈指数增长。而过滤器是目录服务通过查询字符串传来的。所以任何持有 SCIM 令牌的人,只要发一个这样的过滤器,就能长时间占住服务器。这就是拒绝服务攻击。
那个 patch 库的问题更多。它拒绝 REPLACE。遇到不带 path 的 remove 会抛出 TypeError。它把 "False" 原样存成字符串 "False",而不是 false。它区分大小写地比较属性名,而 SCIM 规定属性名不区分大小写。今年它还出了两份原型污染(prototype pollution)安全公告。原型污染是指输入中像 __proto__ 这样的键,改动了程序中所有对象共享的属性。所以这个库恰好对真实客户端的做法很严格,却对唯一真正危险的东西很宽松:外部系统写入的请求体中,像 __proto__ 这样的键。
真实客户端只用到 SCIM 过滤器和 patch 语言中的一小部分,所以我们的替代品也很小。新的过滤器解析器从头到尾只读一遍字符串,并且只接受 IPSIE 列出的那些过滤器。一个基于属性的测试(property test,用大量随机输入运行代码)确保解析时间只随输入长度按比例增长。新的 patch 代码把每一个 path 都与我们声明的属性表核对。它拒绝请求体中任何位置出现的 __proto__、constructor 和 prototype。而每一项宽容处理,也就是只因为真实客户端会发送才接受的每一种形式,都放在同一个条件 leniency.strict 之后:
const op = opText.toLowerCase();if (op !== 'add' && op !== 'replace' && op !== 'remove') { throw new ScimError( 400, 'invalidSyntax', `${opText} is not a PATCH operation` );}if (leniency.strict && opText !== op) { throw new ScimError(400, 'invalidSyntax', 'the op must be lower case');}
if (path === undefined || path === '') { if (op === 'remove') throw new ScimError(400, 'noTarget', 'remove needs a path'); /* Okta's {"op":"replace","value":{"active":false}}; Entra's multi-attribute replace. */ if (leniency.strict) { throw new ScimError(400, 'invalidSyntax', 'an operation needs a path'); } if (!isObject(value)) { throw new ScimError( 400, 'invalidValue', 'a path-less operation needs an object value' ); } for (const [key, v] of Object.entries(value)) { applyOne(working, op, key, v, leniency, resource); } continue;}这段代码里有两点是刻意为之。第一,接受一种形式是常规路径,拒绝才是例外,因为常规流量本身就是 Entra 和 Okta 的方言。第二,不带 path 的 remove 在任何模式下都会被拒绝。宽容的服务器在这里只能问一句「删除什么?」。而一旦靠猜,目录服务就会把用户的整份资料删掉。
只设一个开关:服务器要么完全遵循规范,要么接受 Entra 和 Okta
上面每一种形式本可以各有一个设置,有一段时间设计里确实有好几个。最后只剩一个:scim.strict,默认关闭。我认为只设一个是对的,理由关乎运维者(部署和配置服务器的人),而不是代码。运维者要回答的只有一个问题:我是要按这些规范认证这台服务器,还是要把 Entra 和 Okta 接到它上面?
只开启部分宽容项的服务器,并不符合规范。因此一组彼此独立的开关会带来几十种组合,而除了其中两种(全部开启或全部关闭),其余每一种对这两个目的来说都是错的。打开 scim.strict 后,表中的每一种形式都会得到 400。也只有在这时,/ServiceProviderConfig(SCIM 服务器说明自己支持哪些功能的端点)才会声明 interopProfileConformant。哪怕只接受其中一种形式,服务器也没有资格这样声明。
凭据遵循同样的规则:运维者真正要做的每一个决定,对应一个开关。这里有两个决定。Entra 凭客户端密钥,在用户桶的令牌端点获取令牌。它通过 Basic 认证(放在 Authorization 请求头里)或在请求体中发送密钥,具体由它的管理员选择。在工作负载身份联合(workload identity federation,一种程序无需保存密钥就能登录的方式)覆盖到预配之前,Entra 能发送的只有密钥。Okta 发送的是它的静态令牌。
两者都不是 IPSIE 所要求的。规范要的是客户端断言(client assertion):一个由客户端签名、用来证明自身身份的 JWT。Entra 的密钥和 Okta 的静态令牌各有一个设置,即 scim.secretCredentials 和 scim.staticTokens,默认都开启。只想采用规范方式的运维者,可以把这两个设置都关掉,然后确切地看到哪些东西停止工作。
令牌端点这条路径有一个我不愿放弃的性质。目录服务通过预配连接与我们的服务器关联,预配连接就是把一个目录服务与一个用户桶关联起来的那条记录。每次需要时,服务器都根据这个连接现场构建 OAuth 客户端 scim-<connection id>。这个客户端本身不存储在任何地方。因此,SCIM 白得了整个令牌端点,包括防止同一个客户端断言被使用两次的保护,以及常数时间的密钥比较。常数时间比较无论猜测与真值多接近,花的时间都一样,所以从响应时间里得不到任何信息。更换一个凭据只需对连接写入一次,没有任何隐藏的副本需要保持同步。
这个端点签发的令牌是不透明令牌:一个没有内容的随机字符串,只有我们的服务器能查到它代表什么。它只携带 scim 这一个 scope。它的受众是某一个用户桶的 SCIM URL,所以在别处都用不了。凭据一旦重新签发,它就立即失效。
IPSIE §4.1 要求把凭据放在请求体中,随后又禁止这样做
如果规范的要求至少彼此一致,那会方便得多,那样合规就只是不受欢迎而已。可它们并不一致。IPSIE SCIM 规范第 4.1 节要求使用客户端凭据授权,「with JWT Client Authentication as defined in RFC7523 section 2.2」(使用 RFC7523 第 2.2 节定义的 JWT 客户端认证)。也就是说,客户端用客户端断言证明自己的身份。几行之后,同一节又要求「HTTP Basic authentication in the Authorization request header」(在 Authorization 请求头中使用 HTTP Basic 认证),并补充说禁止在请求体中传送 client_assertion 或 client_secret 之类的客户端凭据。
可 RFC 7523 §2.2 正是把断言放在请求体中传送,这就是该节所定义的内容。而 Basic 传送的是共享密钥,放在请求头里。所以没有客户端能同时满足这两条要求,也没有服务器能告诉客户端该违反哪一条。我们遵循 JWT 那条要求,理由有两个:§10 重复了它,而 §4.1 带有一条编者注,说这一节应当扩充。我们在一致性说明中写明了这一点,而不是悄悄挑一个。这份规范是 00 版草案,也就是第一稿。它会被修正的。在那之前,任何告诉你他们的服务器完全符合 §4.1 的人,都没有把这一节读第二遍。
Microsoft 和 Okta 的测试发现了什么,还有哪些没测
Microsoft 的 SCIM 验证器是 Microsoft 为 SCIM 服务器准备的一套测试。我们在一致性测试部署上运行它,这是我们专门为这类测试保留的一份服务器副本。服务器通过了 24 项必需测试中的 23 项,以及全部七项预览测试,包括组相关的测试。唯一的失败项是「Patch User - Replace Attributes」。验证器报告说,用户缺少主电子邮件和电话号码。可验证器自己显示的 PATCH 响应中两者都有。随后再读取这个用户,也会返回两者。其他开发者也报告过完全相同的失败,而他们的响应同样是正确的,Microsoft 一直没有回应。我把它算作验证器的问题。我们的一致性说明给出了理由,任何人都可以提出异议。
Okta 的测试套件叫 SCIM 2.0 Spec Test。在这里,该部署通过了全部十一项必需测试和可选的组测试,53 项检查全部通过。这项测试第一次运行就发现了我们的一个真实缺陷。SCIM 基路径下有一个服务器并不提供的地址,发往它的请求得到了 404,但响应体写的却是 server_error,也就是服务器内部故障的代码。
尚未运行的部分同样重要。Okta 的 CRUD 测试(创建、读取、更新和删除用户)需要一个安装了我们集成的 Okta 组织,我们还没有运行它。我们没有 /Bulk 端点(在一个请求里执行多个操作)。Entra 和 Okta 都不调用它。它已列入后续计划,将与异步批量操作一起实现。但规范列出了它,所以缺了它就不算完全满足规范。Okta 的 Universal Logout 会调用我们的全局令牌撤销端点,用来撤销用户的令牌。这个端点也从未在真实的 Okta 组织上测试过。而且以上这些,我们都还没有申请认证。
接入 Entra 和 Okta 时关闭严格模式;认证或测试自己的客户端时再打开
如果你要接入 Entra ID 或 Okta,就让 scim.strict 保持关闭。这是对客户端现状的如实陈述,而不是放宽你的服务器。此时服务器接受的每一种形式都有边界。属性要与我们声明的属性表核对。未声明的属性会被忽略,而不是存储。危险的输入,比如 __proto__ 这样的键和不带 path 的 remove,在两种模式下都会被拒绝。
在做认证测试时打开它,或者当唯一的客户端是你按规范自己写的客户端时打开它。这时,把结果当作对那个客户端的测试。如果你自己的客户端在严格模式下失败,那是一个你能修复的 bug。而对 Entra 的 manager,谁也没法这么说。如果你正从零开始写一个 SCIM 服务器,请从上面那张表开始,而不是从 RFC 开始。RFC 告诉你什么是允许的。那张表告诉你,凌晨三点客户的目录服务第一次运行完整同步时,真正会到达的是什么。