开源桌面应用 OpenWork 接入企业身份:SSO 与 SCIM 分工
本文基于 openwork 仓库 commit 3b41381(2026-08-03)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/different-ai/openwork 最新代码与文档为准。
把 OpenWork 接进公司身份体系,最容易翻车的地方不是 SAML 配置项填错,而是一开始就把”单点登录”和”账号自动同步”当成同一件事去做。 在这个仓库里它们是两套代码、两条路由、两份操作文档,甚至可以只上其中一件。你如果按”配完 SSO 就等于接入完了”的心态推进,会在第一个员工离职时发现名册根本没人管;反过来只配 SCIM 不配 SSO,人是同步进来了,但他们仍然拿邮箱密码登录,IT 那边一个开关也拧不动。
OpenWork 是一个开源桌面应用项目(与同名的职场点评网站、以及”开放工作”这类泛指无关)。仓库 README 这样定位自己:一个用于共享 AI 工作流的免费开源桌面应用,是 Claude Cowork 和 Codex 的开源替代,同时提供一个 OpenWork MCP,向 Codex、Claude Code、Cursor 等客户端暴露 search_capabilities 和 execute_capability 两个工具,把你被分配到的技能、插件、MCP 连接带进已有的 Agent。这句是项目自己的说法,不是本文的评价。而本文要拆的,是它面向组织那一侧——README 里叫 OpenWork Den 的控制面——如何跟 Microsoft Entra ID 对接。
一、先把两件事分开:谁能进门,与名册长什么样
单点登录(SSO)解决的是认证:用户点击登录,浏览器被送到公司 IdP,IdP 签一张断言回来,应用据此认下这个人。它是一次性的、事件驱动的、由用户行为触发的。
账号自动同步(SCIM)解决的是生命周期:谁应该在这个组织里、他属于哪个团队、他离职之后名册怎么收敛。它是持续的、由 IdP 主动推送的、跟用户在不在线无关。
这个区别在 OpenWork 仓库里体现得非常直白。SSO 那条链的入口是浏览器路由与回调,SCIM 那条链的入口是一个带 bearer token 的服务端接口,两者的配置与状态表也各是一套:SSO 侧读写连接表与 provider 表,SCIM 侧读写 provider 表、组表、组成员表、同步事件表和墓碑表。真正共用的只有底层的账号表与外部身份链接表——那是两条链最终汇合的落点,也是后面”同一个人被建成两个成员”这个坑的成因所在。仓库里的架构文档 docs/microsoft-entra-sso-scim.md 用一张表把两套表面并排列出来:SSO 侧是 /dashboard/sso、/v1/sso、/v1/sso/saml、/v1/sso/oidc,加上 /sso/<org-slug> 这个组织维度的登录入口;SCIM 侧是 /dashboard/scim、/v1/scim、/v1/scim/token,加上供 IdP 调用的 /api/auth/scim/v2。协议处理本身借助 Better Auth 的 SSO 与 SCIM 插件,OpenWork 在外面包了一层组织维度的路由与策略。
三份文档的分工也是这样切的:docs/microsoft-entra-sso-scim.md 是架构与端到端全景,packages/docs/cloud/sso-microsoft-entra.mdx 是纯 SAML 操作手册,packages/docs/cloud/scim-microsoft-entra.mdx 是纯供给操作手册。后两份互相在开头点名了对方的先后顺序:SCIM 那份写得很清楚,先完成并测通 SAML 连接,再来配供给。对照着读的正确姿势是——架构文档看”为什么”,两份 mdx 看”点哪里”。
这篇讲的是身份接入这一层的机制。站内另外三篇管的是别的事:AI 工具引入的数据安全风险讲的是数据流向与合规面的整体判断,API Key 的安全管理讲的是密钥本身怎么存怎么轮换,AI 生成代码的安全审计讲的是产出物审查。本篇不覆盖它们,只把”人怎么进来、名册怎么维护”这条链拆开。
下面这张表是全景,后面几节都在展开它。表里的仓库位置是我实际读过的文件:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| SAML/OIDC 连接注册 | 拼出组织维度的 provider 标识,算出 ACS、元数据、登录地址 | ee/apps/den-api/src/sso.ts | 在控制台保存 SSO 连接、需要把 ACS 地址回填 Entra 时 |
| SAML 安全策略常量 | 断言必须签名、要求时间戳、过时算法直接拒 | ee/apps/den-api/src/sso-saml-policy.ts | 排查 Invalid SAML response 时 |
| 连接就绪判定 | 连接状态为 enabled 且 provider 确实存在,才算就绪 | ee/apps/den-api/src/sso-readiness.ts | 控制台显示”SAML/SSO 已激活”与否时 |
| 首登即时建号的策略常量 | 钉死 JIT 角色为 member,并声明附加字段映射 | ee/apps/den-api/src/sso-jit.ts(实际建号逻辑挂在 ee/apps/den-api/src/auth.ts) | 第一个人走 SSO 进来时 |
| SCIM 令牌存储 | 只存哈希,校验走定时安全比较 | ee/apps/den-api/src/scim-token-storage.ts | 轮换令牌、Test Connection 报未授权时 |
| SCIM 组到团队映射 | 两种模式:只留元数据,或据此创建团队 | ee/apps/den-api/src/scim-groups.ts | Entra 组同步过来却没生成团队时 |
| 供给后台巡检 | 重试待处理的同步事件、对账并修复漂移 | ee/apps/den-api/src/scim-maintenance.ts | Entra 报成功但界面还是旧状态时 |
| 退出清理 | 墓碑记录,且只有当人不属于任何其它组织时才删全局用户 | ee/apps/den-api/src/scim-deprovisioning.ts | 演练离职流程时 |
| 企业能力闸门 | sso 等能力键的开关,未开启返回 402 | ee/apps/den-api/src/entitlements.ts | 表单能填但保存被拒时 |
| 控制台两个屏 | Dashboard 下的 SSO 与 SCIM 页面 | ee/apps/den-web/app/(den)/dashboard/sso、ee/apps/den-web/app/(den)/dashboard/scim | 全程 |
一眼能看出的事实:这些路径全部落在 /ee 目录下。这一点在第四节还要再说一次,因为它直接决定了你能拿这套代码干什么。
二、SSO 这一半:组织维度的 SAML 连接怎么搭起来
OpenWork 的 SAML 连接是按组织切分的,不是全局一套。ee/apps/den-api/src/sso.ts 里那几个函数把这件事讲得很清楚:
export function buildOrganizationSsoProviderId(organizationId: OrganizationId) {
return `openwork-sso-${organizationId}`
}
export function getOrganizationSsoSignInPath(organizationSlug: string) {
return `/sso/${encodeURIComponent(organizationSlug)}`
}
export function getSsoAcsUrl(providerId: string) {
return `${env.betterAuthUrl}/api/auth/sso/saml2/sp/acs/${encodeURIComponent(providerId)}`
}
provider 标识由组织 ID 拼成,ACS 地址由 provider 标识拼成,登录路径由组织 slug 拼成。这带来一个必须提前想清楚的后果:如果一个人同时属于多个 OpenWork 组织,他到底进哪个组织,是由他用的那个 Entra 企业应用、那个 ACS 地址、那个登录地址共同决定的。两份文档都专门提醒了这一点。你不能指望登录后再选组织把配置错误兜回来。
配置流程本身有一个绕不开的握手顺序:OpenWork 需要 Entra 的 IdP 值才能保存连接,而 Entra 需要 OpenWork 保存后生成的 ACS 地址才能测通。所以顺序只能是——先在 Entra 的单点登录页拿到三个值,填进 OpenWork,保存,再把 OpenWork 生成的三个值填回 Entra。
Entra 侧到 OpenWork 侧的字段对应关系是这样的:Entra 的 Microsoft Entra Identifier 填 OpenWork 的 IdP Issuer URL,Entra 的 Login URL 填 SAML Entry Point,Entra 的 Certificate (Base64) 以 PEM 文本贴进 IdP Certificate。Domain 填该连接负责的邮箱域名。Audience URL 留空则用 OpenWork 的认证源地址。
反向回填时,Entra 的 Identifier (Entity ID) 要填 OpenWork 的受众地址(留空则是认证源地址),Reply URL 填 OpenWork 生成的 ACS 地址,Sign on URL 填 OpenWork 给出的登录地址。
这里有一个两份文档都单独拎出来强调的错误:不要把 Entra 那个 https://sts.windows.net/<tenant-id>/ 形式的颁发者地址填成 Entity ID。填错的表现是 Microsoft 报 AADSTS700016,说找不到这个标识对应的应用。那个地址是 IdP 的身份,Entity ID 要的是 SP 的身份,两个方向。
签名要求这一侧完全不留余地。ee/apps/den-api/src/sso-saml-policy.ts 整个文件只有四行常量:
export const ORGANIZATION_SAML_WANT_ASSERTIONS_SIGNED: true = true
export const ORGANIZATION_SAML_ALLOW_IDP_INITIATED: true = true
export const ORGANIZATION_SAML_REQUIRE_TIMESTAMPS: true = true
export const ORGANIZATION_SAML_DEPRECATED_ALGORITHM_BEHAVIOR: "reject" = "reject"
类型被写死成字面量类型,意思很明确:这不是配置项,是策略。断言必须签名——SAML 文档在这里补了对应的现象,如果 Entra 只签了响应而没签断言,回调会失败并带上 saml_error 与 Invalid SAML response。所以 Entra 的 Signing Option 要选 Sign SAML assertion,Signing Algorithm 用 SHA-256。
还有一个容易被忽略的时序坑:Entra 可能在你编辑 SAML 设置的过程中新建或激活一张新的签名证书,而 OpenWork 必须存的是当前处于激活状态的那张。文档的建议是每次证书变动后重新复制一遍。
域名验证分两种走法。自定义域名(比如 example.com)需要在 OpenWork 里申请一个 TXT 令牌,发布到 DNS 再回来点验证;而 Microsoft 租户自带的 .onmicrosoft.com 域名,OpenWork 会从匹配的 Entra 租户颁发者与登录入口来验证,你没法也不需要去 Microsoft 的域下发 DNS 记录——ee/apps/den-api/src/sso-entra-domain.ts 就是干这个的。
第一个人成功登录之后发生什么,得看两个文件配着读。ee/apps/den-api/src/sso-jit.ts 整个文件只有十来行,它不干活,只定两件事:常量 ORGANIZATION_SSO_JIT_ROLE 值固定为 member,附加字段映射表 SSO_IDENTITY_EXTRA_FIELDS 目前只有一个 department。真正的建号动作挂在 ee/apps/den-api/src/auth.ts 里 SSO 插件的注册处——那里把 defaultRole 和 getRole 都指向了上面那个常量,而 getRole 的实现无视传入的用户信息,直接返回常量。
这个写法本身就是一句声明:角色不接受 IdP 的意见。指望通过 SAML 断言把某个人直接顶成管理员,这条路在当前代码里是不通的,角色得进 OpenWork 另外给。同一处注册里还开着按每次登录重新供给用户的开关,也就是说属性变更会在后续登录里持续生效,而不是只在建号那一刻取一次。
另一半的效果同样重要:一旦某个组织的 SSO 或 SCIM 接管了某个用户,邮箱密码登录对这个人就被拒了。ee/apps/den-api/src/enterprise-auth-requirement.ts 会按邮箱域名反查到组织的 SSO 连接与登录路径,把人引导回正确的入口。这正是”接入身份体系”真正的收益所在——不是少输一次密码,而是把旁路关掉。
三、SCIM 这一半:一枚哈希存储的令牌撑起整条名册链
SCIM 侧的入口简单到有点朴素:一个基础地址加一枚 bearer 令牌,填进 Entra 的 Tenant URL 和 Secret Token,点 Test Connection。基础地址通常以 /api/auth/scim/v2 结尾。
令牌只在创建或轮换的那一刻完整展示一次,之后服务端不再持有明文。看 ee/apps/den-api/src/scim-token-storage.ts:
export const SCIM_TOKEN_STORAGE_STRATEGY = "hashed"
export function hashScimToken(scimToken: string) {
return createHash("sha256").update(scimToken).digest("base64url")
}
校验走的是 timingSafeEqual,先比长度再定时安全比较。这个设计的实际含义是:你丢了令牌就只能轮换,没有”再看一眼”的按钮;而轮换会立刻让旧令牌失效,所以 Entra 那边必须同步更新,不然下一个供给周期整条链就断了。SCIM 文档把这条写成了”像对待密码一样对待它,不要放进工单、截图或共享的安装笔记里”。
组与团队的映射有两种模式,写在 ee/apps/den-api/src/scim-groups.ts 里的类型定义中:ScimGroupMappingMode 只有 metadata_only 和 create_teams 两个取值。对应到界面上就是”从 SCIM 组创建团队”那个开关——关着的时候组信息照收,但不动团队;打开之后,Entra 的安全组会成为由 IdP 管理的团队。文档补了一条运维纪律:SCIM 管理的团队要在 Entra 里改,不要在 OpenWork 里改,OpenWork 把手工建的团队和 IdP 管的团队分开存放。
这里有一处文档之间的不一致,值得你自己回仓库确认:架构文档 docs/microsoft-entra-sso-scim.md 里写着当前不支持 SCIM Group 对象供给、建议在 Entra 侧关掉组对象映射;而 packages/docs/cloud/scim-microsoft-entra.mdx 与 scim-groups.ts 的代码都在讲组同步成团队怎么用。遇到这种情况,以代码和你实际部署的版本行为为准,别拿架构文档里的一句话当结论。
退出流程比”删人”复杂。ee/apps/den-api/src/scim-deprovisioning.ts 里有个一眼能看懂的判断:
export function shouldDeleteGlobalUser(activeMembershipCount: number) {
return activeMembershipCount === 0
}
也就是说,把人从 Entra 里取消分配之后,组织里会留下一条断开连接的成员记录,全局用户只有在这个人不属于任何其它活跃组织时才会被真正删除。同一个文件里还查询了墓碑表和已置为非活跃的外部身份,用来判断某个身份是不是”曾经被 SCIM 退出过”——这道判断的用途,是防止一个已被退出的人从别的入口重新溜回来。
与之配套的是凭据吊销。ee/apps/den-api/src/credential-revocation.ts 在成员关系变化时会删掉该用户的认证会话,以及该组织范围内的 OAuth 访问令牌与刷新令牌。代码注释直白地说明了原因:认证会话是用户维度的凭据,必须全删,否则一个还活着的会话可以重新选中那个已经变化的组织并换出新令牌。
同步不是一次性的。ee/apps/den-api/src/scim-maintenance.ts 里跑着一个后台循环,每轮先重试待处理的同步事件,再逐个组织对账漂移并尝试修复,统计里分开记了检查数、修复数和失败数。这解释了一个常见现象:Entra 那边显示成功,OpenWork 界面却还是旧的。常规供给本来就是增量的,新分配的对象可能要等下一个周期,用 Entra 的供给日志区分”还没轮到”和”真的失败了”,比反复点刷新有用。
审计这条线也在。ee/apps/den-api/src/audit-events.ts 里能查到 organization.sso.connection_registered、organization.sso.connection_deleted、organization.scim.token_rotated、organization.scim.connection_deleted、organization.scim.group_mapping_updated、organization.scim.reconciliation_run 这些动作名。谁在什么时候换了令牌、改了组映射,是有痕迹的。
四、边界与代价:这套设计明确不管什么
许可证是分层的,这一点不能含糊。 仓库根目录的 LICENSE 写明:/ee 目录下的所有内容按 ee/LICENSE 定义的许可证(根 LICENSE 称其为 Fair Source License,ee/LICENSE 本身标为 Functional Source License, Version 1.1, MIT Future License,缩写 FSL-1.1-MIT,Copyright 2026 Different AI Inc);除此之外的部分才是 MIT(Copyright 2026 Different AI)。而本文讲的 SSO 与 SCIM 实现,从 den-api 到 den-web 控制台,全部位于 /ee 之下。所以”OpenWork 是 MIT 开源”这句话用在企业身份接入这块是不成立的。能不能商用、能不能改、什么算被许可的用途,一律以许可证原文为准,本文不提供法律意见。
企业能力有闸门。 ee/apps/den-api/src/entitlements.ts 里的能力键包括 sso、desktopPolicies、orgControls、analytics,sso 的展示名是 SSO / SAML。没有对应资格时,控制台表单仍然可编辑,但保存会被拒,ee/apps/den-api/src/routes/org/sso.ts 里多处声明了 402 响应。架构文档也把这条写进了前置条件。具体的分级规则会调整,以官方最新说明为准,本文不讨论价格与套餐。
权限收得比较紧。 同一份路由文件里的 403 描述分了两档:读取 SSO 配置与元数据限于工作区所有者和管理员,创建、替换、删除连接则限于所有者和超级管理员。也就是说这不是普通成员能自助完成的动作,推进这件事必须拉上有组织所有权的人。
它不管什么。 首登即时建号只给 member 角色,权限分配不在这条链里;SCIM 侧的组映射只到团队,不负责把团队翻译成具体能力的授权;SAML 的属性映射需要 Entra 侧配合送出 email 和 displayName 以及一个邮箱形态的稳定标识,OpenWork 不会替你猜。文档还提了一句现实约束:如果你的 Entra 用户主体名用的是 onmicrosoft.com 域,而人们实际用另一个邮箱域登录 OpenWork,就得亲自确认 userName 映射到了哪个属性——SAML 的 NameID 与 SCIM 身份对不上,结果是同一个人被建成两个成员。
代价在数据面。 这类工具会在你机器上装桌面应用,并集中代管模型服务商凭据与第三方服务授权。按 README 的说法,OpenWork MCP 会把被分配的技能、插件、MCP 连接,以及 Google Workspace 和 Microsoft 365 的能力带进你正在用的 Agent。这意味着一次授权换来的是一个持续可用的通道:授权范围有多大,Agent 能碰到的邮件、文档、日历就有多大;凭据集中保管带来便利的同时也把暴露面集中了——服务端存着组织范围的 OAuth 访问令牌与刷新令牌,这就是为什么退出流程必须连带吊销它们。接上企业控制面之后,组织侧能看到成员名册、团队归属、审计动作,以及被下发的桌面策略。这些事在推广给团队之前要说清楚,而不是等有人问起再解释。授权边界怎么划,可以对照最小权限的 Agent 设计与MCP 授权加固一起想。
部署形态也要提前定。 仓库 packaging/ 下有 aur、docker、helm 三种分发方式,docs/ 里另有针对几家云上托管 Kubernetes 的部署文档。架构文档里那条前置条件不是形式主义:公开的 web 与认证地址必须已经是最终的 HTTPS 地址,SAML 与浏览器认证 Cookie 不该拿临时的 HTTP 源去验证。你如果先用临时域名跑通一遍再换正式域名,SAML 那套地址全部要重配。
五、上手与避坑清单
先做只读盘点,别先动配置。 会踩是因为这套东西的改动面比看起来大——它同时影响登录方式、成员名册和会话有效性。先确认三件事:你的组织有没有对应的企业资格、你有没有所有者级别的权限、公开地址是不是最终的 HTTPS 地址。三件事缺一件,后面每一步都会返工。
SSO 强制开关留到最后再拧。 SCIM 文档专门写了这条:在 SAML 登录和 SCIM 供给都测通之前不要开启强制。会踩是因为一旦强制生效而 SAML 又恰好有问题,所有人包括你自己都进不去。避法是先确保至少有一个组织所有者能真实地走完一遍 SAML 登录,再考虑把邮箱密码这条旁路关掉。
证书要复制”当前激活的那一张”。 会踩是因为 Entra 可能在你编辑期间自动新建或激活证书,你手上那份就过期了,表现是登录失败并提示 SAML 响应无效。避法是每次改完 SAML 设置回头再复制一次证书,粘进 OpenWork 重新保存。
Entity ID 别填成 IdP 颁发者。 会踩是因为两个地址长得都像”身份标识”,而 Entra 页面上 sts.windows.net 那个更显眼。表现是 AADSTS700016。避法是记住方向:填给 Entra 的是 SP(也就是 OpenWork)的身份,填给 OpenWork 的 IdP Issuer URL 才是 Entra 的身份。
ACS 地址必须逐字符相同。 会踩是因为它是由组织 ID 拼出来的长串,手敲必错,多一个尾斜杠也算不匹配。避法是保存 SSO 连接之后直接从 OpenWork 界面复制,粘贴到 Entra 的 Reply URL。
SCIM 令牌复制完立刻用掉。 会踩是因为它只展示一次,而人往往先复制、再去开另一个浏览器标签、中途被打断。避法是先把 Entra 的供给页面开好停在 Admin Credentials 那一步,再回 OpenWork 创建令牌。Test Connection 报未授权时,先怀疑复制时带了空白字符或地址后面多了路径,其次才怀疑令牌被轮换过。
按需供给测试要选中具体成员。 会踩是因为 Entra 的按需供给对空组,或者没有勾选成员的组,可能直接返回一个笼统的内部服务器错误,而这个错误在 OpenWork 侧根本看不到对应请求——它压根没发过来。避法是先确保测试组里有已分配的用户,在 Selected users 里显式勾选(文档说最多五个),再执行。
统一 SAML NameID 与 SCIM 的 userName。 会踩是因为这两条链各自都能跑通,问题要到”同一个人出现了两个成员”时才暴露。避法是在配供给之前就确认这两个标识指向同一个邮箱。
别把 Entra 报成功当成同步完成。 会踩是因为常规供给是增量的,加上服务端还有一轮后台对账在跑。避法是看 Entra 的供给日志判断是”待下一周期”还是”操作失败”,再回 OpenWork 看成员与团队状态。
灰度范围一开始就收窄。 把供给范围设成只同步已分配的用户和组,先用一两个测试账号和一个测试组跑完整生命周期:加人、加进组、从组里移除、取消分配,每一步都回 OpenWork 核对。跳过这一步的代价是你在真人身上做实验。
收束:接下来读哪个文件
如果你打算真的推这件事,读文件的顺序建议是:先 docs/microsoft-entra-sso-scim.md 把两套表面和策略看一遍,再按 packages/docs/cloud/sso-microsoft-entra.mdx 配完 SAML 并测通,最后按 packages/docs/cloud/scim-microsoft-entra.mdx 配供给。中途每遇到一个说不通的行为,回 ee/apps/den-api/src/ 下找对应文件——sso-saml-policy.ts 解释了为什么某些 SAML 响应会被直接拒,scim-token-storage.ts 解释了令牌为什么找不回来,scim-maintenance.ts 解释了状态为什么会延迟,scim-deprovisioning.ts 解释了人为什么删不干净。
上线前的自检清单可以就这四条:组织所有者能不能走 SSO 登录进来;被 SSO 或 SCIM 接管的用户用邮箱密码登录是不是确实被拒;取消分配一个测试用户之后,他的会话和组织范围内的令牌是不是都失效了;以及 —— 你团队里除你之外,有没有第二个人知道 SCIM 令牌怎么轮换。最后一条不是技术问题,但它决定了这套东西在你休假时会不会变成事故。
本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 开源桌面应用 OpenWork 的角色与权限模型:谁能发能力、谁能指派、谁只能用 和 OpenWork 开源桌面应用策略:能锁住什么、锁不住什么、链路在哪断。