---
title: "Entra、Okta 的 SCIM 方言 — FoxAuth"
description: "严格遵循互操作规范的 SCIM 服务器，会拒绝 Entra 和 Okta 实际发送的请求。是哪些请求，为什么一个设置就够。"
canonical: "https://foxauth.dev/zh-cn/blog/what-entra-and-okta-send-to-scim/"
lastmod: "2026-10-08T21:45:57+03:00"
---
博客
Entra、Okta 的 SCIM 方言
严格遵循互操作规范的 SCIM 服务器，会拒绝 Entra 和 Okta 实际发送的请求。是哪些请求，为什么一个设置就够。
作者 FoxAuth · 2026-10-08

公司把员工信息放在目录服务里，比如 Okta 或 Microsoft Entra ID。目录服务会自动在公司用到的每个应用里，为每个人创建、更新和停用账号。这叫预配（provisioning），SCIM 就是目录服务做这件事时用的协议。

下面是 Okta 通过 SCIM 停用用户的请求，格式与 Okta 自己的协议参考文档完全一致。它发往我们的一个用户桶，即路径里的 acme。用户桶是本服务器对一组终端用户的叫法，这组用户有自己的登录规则。用户桶有了自己的地址后，会以自己的名义签发令牌，也就是成为一个独立的签发者（issuer）。

PATCH /acme/scim/v2/Users/2819c223-7f76-453a-919d-413861904646 HTTP/1.1

Host: auth.example.com

Authorization: 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 告诉你什么是允许的。那张表告诉你，凌晨三点客户的目录服务第一次运行完整同步时，真正会到达的是什么。

全部文章亲自试试
