fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run

Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw
"sign-in failed / add API key" on DSH Desktop because the remote.mux opens
anonymously at boot and every early call was rejected terminally.

- gateway: anonymous workspace/follow reaches dsh-acl's empty baseline;
  anonymous streams are parked until the carrier aborts instead of failing;
  anonymous workspace/initializeDefault answers "nothing created"
- request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket
  or bridge (sync + async variants used by all callers)
- dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup
  instead of 403, so the Desktop welcome read no longer fails
- desktop-bootstrap: key/account projection is best-effort per reference
- client: emit connection/reset after login so boot-time caches (settings
  describe mirror -> Settings > Models) re-read under the real principal
- sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min);
  bridge expiry slides with its session; MemoryStore persisted to
  sessions.json (bearer hash only, userData AES-256-GCM sealed)
- README: session lifetime/persistence and DSH compatibility notes

Also includes previously uncommitted uds-auth 0.3.x work in this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 02:11:09 +08:00
parent fa4d5d6d97
commit df97c5e042
72 changed files with 8436 additions and 1192 deletions

3
uds-auth/.gitignore vendored
View file

@ -19,3 +19,6 @@ scripts/merge-sessions-into-workspace.ps1
session-bridge.json
roles.json
sessions.json
local-admin.box
.uds-auth-secret-key

18
uds-auth/.npmignore Normal file
View file

@ -0,0 +1,18 @@
# SEC-24: keep secrets, caches, and local state out of the published tarball
node_modules/
test/
*.tgz
**/__pycache__/
**/*.pyc
**/*.pyo
.uds-auth-secret-key
skill-credentials.json
session-bridge.json
session-owners.json
session-owners.json.bak*
user-workspaces.json
roles.json
config.runtime.json
*.log
.DS_Store
pnpm-lock.yaml

21
uds-auth/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 uds-auth contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,53 +1,83 @@
# uds-auth
UDS authentication plugin for DeepSeek Harness: fixed sidebar login badge + host session/role APIs.
0.3.10: [升级与协议变更说明](docs/upgrade-0.3.10.zh.md) · [SBOM](docs/sbom.cdx.json) · [第三方许可证](docs/THIRD-PARTY-NOTICES.txt)
UDS authentication plugin for DeepSeek Harness (**trusted single-instance** access control — **not** strong multi-tenant isolation).
See `package.json` for the current 0.3.x version (breaking auth protocol vs 0.2.x — re-login required).
## Support boundary
| Item | Notes |
|------|--------|
| Runtime | Node `^22.19.0 \|\| >=24` (aligned with DSH Host) |
| Mode | Single Host, trusted enterprise users, controlled skills |
| Out of scope | Untrusted multi-tenant execution isolation; multi-instance without shared session store |
## Install
```bash
dsh plugin --profile web add -w "D:/project/chatgpt/oclaw/uds-auth"
dsh plugin --profile web add -w "<abs>/oclaw/uds-auth"
# or clean tarball
dsh plugin add -w "<abs>/uds-auth-0.3.9.tgz"
```
Restart Harness after install. The badge mounts on `sidebar.footer.action` (root scope).
Empty roles table requires `UDS_AUTH_INITIAL_ADMIN` or `initialAdminEmpNo`. Restart Harness after install.
Version `0.2.21` provides `ctx.get('udsAuth').runAsHost(callback)` for trusted Host plugins. It isolates UDS browser identity during synchronous and asynchronous background operations and restores the caller afterward. Use with `dsh-im-ops >= 4.9.1-ops.38` to fix WhatsApp background Session creation rejected as signed out; update both plugins and restart the Host. The callback adds no remote endpoint and preserves browser login and workspace ACL checks.
`ctx.get('udsAuth').runAsHost(cb)` — Host background ALS.
`ctx.get('udsAuth').mintTaskCapability({ empNo, dshSessionId, scopes, ttlSeconds })` — inject `UDS_TASK_CAPABILITY` for cron/skill.
## Config
```yaml
uacBaseUrl: https://uac.zte.com.cn
userSearchUrl: https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search
uacBaseUrl: https://uac.zte.com.cn # HTTPS required at startup/merge
userSearchUrl: https://icenterapi.zte.com.cn/...
loginSystemCode: '100000455558'
originSystemCode: ''
initialAdminEmpNo: ''
allowOpenRegistration: true
allowRawAgentToken: false
outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn
trustedHosts: '' # empty = loopback Host only; proxies must set allowlist
retainSkillCredentialsOnLogout: true
skillCredentialTtlSeconds: 604800
outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn
```
Skill auth standard (Chinese): [docs/skill-auth-standard.zh.md](docs/skill-auth-standard.zh.md).
Durable state defaults to `~/.uds-auth/` (roles / owners / credentials / bridge / sessions / `config.runtime.json`; override with `UDS_AUTH_DATA_DIR`). Security toggles take effect on the next request after Settings save. Empty outbound allowlist denies all.
Bundled skill: [skills/uds-skill-auth](skills/uds-skill-auth). Handoff notes: [docs/uds-skill-auth.zh.md](docs/uds-skill-auth.zh.md).
## Sessions
Loopback agent APIs: `GET|POST /uds-auth/agent-credentials`, `POST /uds-auth/outbound`.
- **Lifetime**: 30-minute idle timeout that slides on activity, 7-day absolute cap. The Desktop bridge token slides with its session.
- **Survives restarts**: the default in-memory store is persisted to `~/.uds-auth/sessions.json` and restored on Host start (emergency admins have no SSO re-login, so they depend on this).
- Only the SHA-256 hash of each session token is stored; user data (may include the UAC token) is AES-256-GCM encrypted with the plugin key `.uds-auth-secret-key`.
- Expired, undecryptable or tampered rows are dropped on load; disabled-account and emergency-password rotation checks still run on every request.
- **Delete `sessions.json` and restart to sign everyone out.** If the key file is unavailable, sessions are simply not persisted.
- Not used when a Redis session store is configured.
### Local admin unlock (optional, decrypt-to-login)
## Admin / emergency
1. `node scripts/seal-local-admin.mjs "your-passphrase"`
2. Set printed `UDS_AUTH_LOCAL_ADMIN_BOX=...` on the Harness process (ciphertext only)
3. Login panel → “Unlock with local key” → enter passphrase
- `super_admin` / `admin` / `fallback_admin` share the same admin-class permissions (identity labels, not stronger isolation).
- No shared default password. Set emergency password in Settings (≥10 chars; `Admin@123` rejected). Legacy default hashes are cleared on upgrade.
- Local unlock: `node scripts/seal-local-admin.mjs` (interactive / `--stdin` — **no argv passphrase**) → `UDS_AUTH_LOCAL_ADMIN_BOX`.
- Removing a user disables the account by default (open registration cannot auto-revive).
Env alone does **not** grant admin. Legacy `UDS_AUTH_LOCAL_ADMIN_KEY` is ignored.
## DSH compatibility
## Layout
Verified on **DeepSeek Harness Desktop 0.2.0-rc.2**. The plugin relies on DSH internals that are not a public API; after a DSH upgrade run `npm test` and check login, workspaces and Settings → Models on a real install:
| Piece | Path | Role |
|-------|------|------|
| Host | `lib/index.js` | Cordis `apply`: settings, RPC, `/uds-auth/*` |
| Client | `lib/client.js` | ModuleLoader + React footer login card |
| Bundle | `cordis.patch.yml` | layer `insert` only |
| Meta | `package.json` `dsh.client` | `./client` + slots inject |
- Gateway `prepareInvocation` / `resolveDescriptor` / `openRemoteEvents` / `receiveRemoteEventResult` (if missing, the ACL refuses to install instead of failing open);
- the client `connection/reset` event (emitted after login so boot-time caches such as the settings mirror re-read);
- endpoint names `workspace/follow`, `workspace/initializeDefault`, and the Desktop welcome reads `settings/describe`, `llm/listConfigurableProviders`, `credentials/describe`, `account/getState`.
The login panel uses `position: fixed` with a measured trigger anchor (same pattern as CordisPanel) so the sidebar overflow clip cannot hide it.
Desktop opens its connection anonymously at boot and reconnects after login. The plugin therefore:
- parks anonymous stream calls (e.g. account state) without data instead of failing them (a failure is terminal for DSH and shows "sign-in failed / add API key");
- answers anonymous `workspace/follow` with an empty list and anonymous `workspace/initializeDefault` with "nothing created";
- holds `/api/*` requests for up to 15 s while the gateway ACL is still installing, instead of returning 403.
## Skill / cron
Prefer Host-minted `UDS_TASK_CAPABILITY` + `DSH_SESSION_ID` with `/uds-auth/outbound`. Raw `/agent-credentials` stays off unless explicitly enabled. Python helper: `skill-helpers/python/uds_skill_auth.py`.
Chinese docs: [README.zh.md](README.zh.md), [docs/skill-auth-standard.zh.md](docs/skill-auth-standard.zh.md).
## License

View file

@ -1,71 +1,95 @@
# uds-auth
DeepSeek Harness 的 UDS 统一认证插件:右上角登录徽章 + 设置页用户管理。
0.3.10: [升级与协议变更说明](docs/upgrade-0.3.10.zh.md) · [SBOM](docs/sbom.cdx.json) · [第三方许可证](docs/THIRD-PARTY-NOTICES.txt)
DeepSeek Harness 的 UDS 统一认证插件(**受信任单实例**访问控制增强,**不支持**互不信任多租户 / 任意代码执行隔离)。
当前包版本见 `package.json`(0.3.x 起为安全协议破坏性变更:须重新登录)。
## 支持范围
| 项 | 说明 |
|----|------|
| 运行时 | Node `^22.19.0 \|\| >=24`(与 DSH Host engines 对齐) |
| 部署 | 单 Host、受信任企业用户、受控 skill |
| 不支持 | 不可信多租户、用户自上传任意代码的强隔离、多实例未共享 session store 时的会话一致性 |
## 安装
```bash
dsh plugin --profile web add -w "D:/project/chatgpt/oclaw/uds-auth"
# 开发树
dsh plugin --profile web add -w "<绝对路径>/oclaw/uds-auth"
# 或干净 tgz
dsh plugin add -w "<绝对路径>/uds-auth-0.3.9.tgz"
```
安装后重启 Harness。登录徽章在 `shell.overlay`(右上角);用户管理/部署配置在 **设置 → UDS 认证**(`settings.section`)。
首次空角色表须设置初始超管:环境变量 `UDS_AUTH_INITIAL_ADMIN=<工号>` 或配置 `initialAdminEmpNo`。
`0.2.21` 提供仅供 Host 插件使用的 `ctx.get('udsAuth').runAsHost(callback)`:在回调及其异步操作内使用 Host 后台上下文,并恢复调用者的网页身份。配合 `dsh-im-ops >= 4.9.1-ops.38` 修复 WhatsApp 后台建会话误报“登录后才能创建会话”;更新两者后重启 Host。该方法不增加远程端点,网页登录及工作区 ACL 保持生效。
安装后重启 Harness。登录徽章在 `shell.overlay`;用户管理在 **设置 → UDS 认证**。
`ctx.get('udsAuth').runAsHost(callback)`:Host 后台上下文。`mintTaskCapability({ empNo, dshSessionId, scopes, ttlSeconds })` 为 cron/skill 签发短期能力令牌(注入 `UDS_TASK_CAPABILITY`)。
## 配置
在 **设置 → UDS 认证** 或 `cordis.patch.yml` 中修改:
```yaml
uacBaseUrl: https://uac.zte.com.cn
userSearchUrl: https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search
uacBaseUrl: https://uac.zte.com.cn # 启动/合并时强制 HTTPS
userSearchUrl: https://icenterapi.zte.com.cn/...
loginSystemCode: '100000455558'
originSystemCode: ''
initialAdminEmpNo: '' # 或环境变量 UDS_AUTH_INITIAL_ADMIN
allowOpenRegistration: true # false 时禁止未知用户自动注册
allowRawAgentToken: false # 原始 agent-credentials 默认关闭
outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn
trustedHosts: '' # 精确 Host 白名单;空=仅 loopback Host
retainSkillCredentialsOnLogout: true
skillCredentialTtlSeconds: 604800
```
## 标准 DSH 插件结构
状态文件默认在 `~/.uds-auth/`(roles / owners / credentials / bridge / sessions / `config.runtime.json`)。可用环境变量 `UDS_AUTH_DATA_DIR` 覆盖。从安装目录升级时,若新数据目录为空且安装根仍有旧 `roles.json` 等,启动会一次性迁移(幂等、不覆盖已有目标文件)。公网或反代必须配置 `trustedHosts`。默认目录为**用户级**共享路径,不是按 DSH profile 隔离;单实例部署须知悉该边界。
| 部分 | 路径 | 说明 |
|------|------|------|
| Host | `lib/index.js` | Cordis `apply`:HTTP `/uds-auth/*`、可选 schema 设置 |
| Client | `lib/client.js` | 右上角登录 + 设置页用户管理 |
| Bundle | `cordis.patch.yml` | `insert` 插件层(不含 client.entry) |
| Meta | `package.json` → `dsh.client` | `exports["./client"]` + slots inject |
安全开关经设置保存后**立即生效**(无需重启)。空 `outboundAllowedHosts` 表示拒绝全部出站。
## API
## 会话
- `POST /uds-auth/qr-start` — 服务端生成扫码挑战
- `GET /uds-auth/qr?data=` — 二维码 SVG
- `POST /uds-auth/qr-proxy` — 代理 UAC 扫码校验
- `GET /uds-auth/api/me` — 当前用户
- `POST /uds-auth/api/logout` — 登出(UI 会话;skill 凭证是否保留见配置)
- `GET|POST /uds-auth/agent-credentials` — **loopback**:按 `DSH_SESSION_ID` 取 skill 用 empNo+token
- `POST /uds-auth/outbound` — **loopback**:白名单出站并注入鉴权头
- 用户管理 / 兜底管理员:见 `/uds-auth/api/users*`、`/uds-auth/api/fallback/*`
- **默认兜底账号**(扫码不可用时):用户名 `administrator`,密码 `Admin@123`(首次启动自动启用;可在设置中改密或关闭)
- **本机密钥解锁(可选,解密才登录)**:
1. 生成密封盒:`node scripts/seal-local-admin.mjs "你的口令"`
2. 把输出的 `UDS_AUTH_LOCAL_ADMIN_BOX=...` 设到 **Harness 进程环境**(这是密文,不是口令)
3. 登录面板 →「本机密钥解锁」→ 输入口令;**必须解密成功才有 admin**
仅设置环境变量、不知道口令 → **无法登录**。旧变量 `UDS_AUTH_LOCAL_ADMIN_KEY` 已忽略。
- **ACL / 侧栏**:未登录与普通用户看不到设置齿轮;**仅超管/应急**可见设置。`admin` 无设置齿轮,但可看渠道/系统会话。`super_admin` / 兜底默认可见全部会话(可关)。布局:设置在左、登录在右。
- **有效期**:30 分钟无操作过期,有操作自动续期;从登录起最长 7 天。Desktop 的 bridge 令牌随会话一起续期。
- **重启不掉线**:默认内存会话会写入 `~/.uds-auth/sessions.json`,Host / Harness 重启后自动恢复(应急账号没有 SSO 自动重登,尤其依赖这一点)。
- 文件只保存会话令牌的 SHA-256 哈希,不保存令牌本身;用户信息(可能含 UAC token)用插件密钥 `.uds-auth-secret-key` 做 AES-256-GCM 加密。
- 加载时丢弃已过期、解密失败或被篡改的记录;停用账号、应急口令轮换等检查仍在每次请求时执行。
- **删除 `sessions.json` 并重启 = 让所有人重新登录**。密钥文件丢失时会话不再持久化(不影响插件启动)。
- 配置 Redis 会话存储时不使用该文件。
## Desktop 离线 tgz(无 npm 外网)
## 管理员与应急
**≥0.2.20**:`qrcode` 已打进 `lib/qrcode-bundled.cjs`,`dependencies` 为空。
打包机:`npm pack`(会跑 `prepack` 重打 bundle)。现场 Desktop「添加插件」填 **绝对路径** `D:\…\uds-auth-0.2.20.tgz`,装包日志不应再出现 `registry.npmjs.org/qrcode`。
- **三类管理员同权**(`super_admin` / `admin` / `fallback_admin`):用户管理、设置、建工作区;标签不是更强隔离。
- **无共享默认密码**。须在设置中显式设置应急口令(≥10 位,禁止 `Admin@123`)。旧安装若仍存该默认 hash,升级后自动禁用直至重置。
- **本机密钥解锁**:`node scripts/seal-local-admin.mjs`(交互输入,勿把口令放 argv)→ 设置 `UDS_AUTH_LOCAL_ADMIN_BOX` → 登录面板解密。
- **停用账号**:删除用户默认写入 disabled 状态,不会因开放注册自动复活。
## Skill 认证(给他人改造 skill 时)
## Skill / cron
发整个 **uds-auth 插件** 即可,内含:
1. 标准说明:`docs/skill-auth-standard.zh.md`、`docs/uds-skill-auth.zh.md`
2. Python helper:`skill-helpers/python/uds_skill_auth.py`(与 `skills/...` 同源)
3. 推荐:Host 在任务创建时 `mintTaskCapability`,注入 `UDS_TASK_CAPABILITY` + `DSH_SESSION_ID`,调用 `outbound`;不要依赖浏览器长会话。
4. `resolve(apply_env_aliases=False)` 默认不写环境变量。
1. **标准**:[docs/skill-auth-standard.zh.md](docs/skill-auth-standard.zh.md)
2. **公共 skill 说明**:[docs/uds-skill-auth.zh.md](docs/uds-skill-auth.zh.md)
3. **公共 skill 本体**:[skills/uds-skill-auth/](skills/uds-skill-auth/)(随插件提交)
## DSH 兼容性
现场:装插件 → 把 `skills/uds-skill-auth` 配进 skills 路径 → 扫码 → 再装业务 skill。
已在 **DeepSeek Harness Desktop 0.2.0-rc.2** 上验证。插件依赖以下 DSH 内部行为(非公开 API),升级 DSH 后请先跑 `npm test` 并实机检查登录、工作区、设置 → 模型:
可配置:`retainSkillCredentialsOnLogout`(默认 true)、`skillCredentialTtlSeconds`、`outboundAllowedHosts`。
- Gateway 的 `prepareInvocation` / `resolveDescriptor` / `openRemoteEvents` / `receiveRemoteEventResult`(缺失时插件拒绝启动 ACL,不会静默放行);
- 客户端 `connection/reset` 事件(登录后用它让设置缓存等重新读取);
- 端点名 `workspace/follow`、`workspace/initializeDefault`,以及 Desktop 欢迎页使用的 `settings/describe`、`llm/listConfigurableProviders`、`credentials/describe`、`account/getState`。
Desktop 启动时连接会先以匿名身份建立,登录后再重连。插件对此的处理:
- 匿名的数据流请求(如账号状态)挂起等待,不返回错误(否则 DSH 会把账号永久标记为失败,弹出「登录失败 / 添加 API Key」);
- 匿名的工作区订阅返回空列表,匿名的「创建默认工作区」返回空结果(不报「无法创建默认工作区」,也不会真的创建);
- Gateway ACL 尚未就绪时,`/api/*` 请求最多等待 15 秒,而不是直接 403。
## 升级注意
- 旧 USER/UI Cookie、旧 bridge 失效,须重新扫码/登录。
- 回滚到 0.2.x **不能**作为安全方案。
- `npm run pack:check` / `npm test` 发布前必跑。
## License

View file

@ -1,28 +1,19 @@
# uds-auth 部署配置
# 在 DSH 设置面板中可修改以下字段
# UAC 基础 URL (生产: https://uac.zte.com.cn, 测试: http://uactest.zte.com.cn:8080)
# uds-auth defaults — trusted single-instance (not strong multi-tenant)
uacBaseUrl: https://uac.zte.com.cn
# icenterapi 用户搜索接口 (完整 URL;须内网直连,禁止走 HTTP(S)_PROXY)
userSearchUrl: https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search
# 登录时传给 UAC 的业务系统 Code
loginSystemCode: '100000455558'
# 登录时传给 UAC 的来源系统 Code (留空则不传)
originSystemCode: ''
# Per-user workspaces under workspaceRoot/<empNo>; empty => $DSH_HOME/user-workspaces
workspaceRoot: ""
# Skill 凭证:UI 退出时是否保留(默认 true,供 cron/skill 继续用)
workspaceRoot: ''
# Explicit first super_admin when roles.json is empty (also: UDS_AUTH_INITIAL_ADMIN)
initialAdminEmpNo: ''
# When roles already exist, auto-register unknown UAC users as role=user
allowOpenRegistration: true
# Raw /agent-credentials token response (loopback) — off by default; prefer outbound + task capability
allowRawAgentToken: false
retainSkillCredentialsOnLogout: true
# Skill 凭证 TTL(秒),默认 7 天
skillCredentialTtlSeconds: 604800
# outbound 白名单 hosts(逗号分隔)
outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn
# 兜底管理员默认在首次启动启用:administrator / Admin@123(roles.json 可改密或关闭)
# Optional exact Host allowlist for /uds-auth writes; empty = Origin must match Host only
trustedHosts: ''
# Emergency fallback password is NOT enabled by default. Set via Settings (min 10 chars).
# Legacy Admin@123 hashes are refused on upgrade.

View file

@ -0,0 +1,35 @@
dijkstrajs 1.0.3 (MIT)
```
Dijkstra path-finding functions. Adapted from the Dijkstar Python project.
Copyright (C) 2008
Wyatt Baldwin <self@wyattbaldwin.com>
All rights reserved
Licensed under the MIT license.
http://www.opensource.org/licenses/mit-license.php
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
---
qrcode 1.5.4 (MIT)
The MIT License (MIT)
Copyright (c) 2012 Ryan Day
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

109
uds-auth/docs/sbom.cdx.json Normal file
View file

@ -0,0 +1,109 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"metadata": {
"component": {
"type": "application",
"name": "uds-auth",
"version": "0.3.11",
"bom-ref": "pkg:npm/uds-auth@0.3.11",
"licenses": [
{
"license": {
"id": "MIT"
}
}
]
},
"properties": [
{
"name": "uds-auth:bundle-sha256",
"value": "1ca76135efb5f93975793da04024409ef215d8b595d5b5194707e3acc693a15f"
},
{
"name": "uds-auth:external-peer",
"value": "React supplied by DSH; audit its exact version in the Host SBOM"
},
{
"name": "uds-auth:scope",
"value": "Actual vendored runtime bundle; development dependency audit is a separate npm audit --include=dev gate"
}
]
},
"components": [
{
"type": "library",
"name": "dijkstrajs",
"version": "1.0.3",
"purl": "pkg:npm/dijkstrajs@1.0.3",
"bom-ref": "pkg:npm/dijkstrajs@1.0.3",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"properties": [
{
"name": "uds-auth:delivery",
"value": "bundled in lib/qrcode-bundled.cjs"
},
{
"name": "npm:integrity",
"value": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
{
"name": "uds-auth:source-files",
"value": "[{\"file\":\"dijkstrajs/dijkstra.js\",\"sha256\":\"7f5721e77332370a71314f1536b8c552f4f9dbd99bbc6c5aa10df3253eacfd5c\"}]"
}
]
},
{
"type": "library",
"name": "qrcode",
"version": "1.5.4",
"purl": "pkg:npm/qrcode@1.5.4",
"bom-ref": "pkg:npm/qrcode@1.5.4",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"properties": [
{
"name": "uds-auth:delivery",
"value": "bundled in lib/qrcode-bundled.cjs"
},
{
"name": "npm:integrity",
"value": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="
},
{
"name": "uds-auth:source-files",
"value": "[{\"file\":\"qrcode/lib/can-promise.js\",\"sha256\":\"fd18906803bd4cfe4f23801a7032c5a1e10be31274ba25dc34541932cdd8f253\"},{\"file\":\"qrcode/lib/core/utils.js\",\"sha256\":\"1b66145cb9afddadf46ea7314b168e0b8369b2ab4938256fe64bd1c465a473bd\"},{\"file\":\"qrcode/lib/core/error-correction-level.js\",\"sha256\":\"7f31b2250223cb7549bf3c305cce948ad9b0ee67cb9ee93b6c08352b7fa2b7f6\"},{\"file\":\"qrcode/lib/core/bit-buffer.js\",\"sha256\":\"c6f982814aa373da6e44183a61f75762c8559909087fff76603885b180bac0ad\"},{\"file\":\"qrcode/lib/core/bit-matrix.js\",\"sha256\":\"77e051929fbc9d3b3ba91bd9b190f9a1ab3ccea162d627696ad2c01bf41e51bd\"},{\"file\":\"qrcode/lib/core/alignment-pattern.js\",\"sha256\":\"7e642a801c5c3d114bcceeeb08a3930369ec16fe874bdb617f02efb43aaea9ba\"},{\"file\":\"qrcode/lib/core/finder-pattern.js\",\"sha256\":\"e5808e9b8ed06fd7c3ed201a03f7e7341a5c80f6a8b4cc78c9a94c3ee0ddd6e2\"},{\"file\":\"qrcode/lib/core/mask-pattern.js\",\"sha256\":\"c8cfdab7b8d8276f9c1128ab8e5d930759df7594f03211f72b80c3002af91aa9\"},{\"file\":\"qrcode/lib/core/error-correction-code.js\",\"sha256\":\"03683cae6cc410b83f8ad0e87fa394df5102b52ca4ce843e507aa9f6acce13e5\"},{\"file\":\"qrcode/lib/core/galois-field.js\",\"sha256\":\"d487207ce5040b49f95441bedafb6dbc9c010b245a0f0f47b9e3f10f8d9530a6\"},{\"file\":\"qrcode/lib/core/polynomial.js\",\"sha256\":\"34cb4e4216fc51fe75ac1c4bb00c4f4fc118a51da50c8250aec951062ff3a3c4\"},{\"file\":\"qrcode/lib/core/reed-solomon-encoder.js\",\"sha256\":\"080273fcf5a73c2348330f3edb55f3968c78853e298dc55a074becd7cee3faa2\"},{\"file\":\"qrcode/lib/core/version-check.js\",\"sha256\":\"9cffe3a6f9cbb978022c0d0b1652fb2f3d381b9e48b7e01dfc744ded28736da2\"},{\"file\":\"qrcode/lib/core/regex.js\",\"sha256\":\"974d9358148d11c8e48713ef2da8fc12e597b76f4e6fc6bde00cfe7c8094efa1\"},{\"file\":\"qrcode/lib/core/mode.js\",\"sha256\":\"25d3574cf4b0cdbd62363ff2c57eb46da41d21124e6d37eb0e84f979799bd530\"},{\"file\":\"qrcode/lib/core/version.js\",\"sha256\":\"1221665a29e6927d3823912bde8bd711c114c61188d7ce862997a33d47fcfba9\"},{\"file\":\"qrcode/lib/core/format-info.js\",\"sha256\":\"889b133cd6a86f4079a3a5d1dfd94c495ca71d4fde4ec88ce9e9a5de358629d0\"},{\"file\":\"qrcode/lib/core/numeric-data.js\",\"sha256\":\"3e506376e4c84b88162024c1d840b5bc3721da3b0d02f18784161adea5e3b2e8\"},{\"file\":\"qrcode/lib/core/alphanumeric-data.js\",\"sha256\":\"cf8f81197e02c0db069155a2e82c098d26329019cfd06cc0e4479124636fa1ea\"},{\"file\":\"qrcode/lib/core/byte-data.js\",\"sha256\":\"24614cb942165dcc5378117a951cc1d68fbfc2e60043830111ae99283cd9aa94\"},{\"file\":\"qrcode/lib/core/kanji-data.js\",\"sha256\":\"2dcd7d5aee492392e0c47976ae718e9783c5b13234487804ed21558fbd4a62de\"},{\"file\":\"qrcode/lib/core/segments.js\",\"sha256\":\"abd08a6ddd778c92e36e2a303d2b4a3b4ad1d9089d2a7605bee8b9d06da7846a\"},{\"file\":\"qrcode/lib/core/qrcode.js\",\"sha256\":\"216e490908ed95df74992a05d44efa35ea7bc8dcab27b49b446725964704e833\"},{\"file\":\"qrcode/lib/renderer/utils.js\",\"sha256\":\"03ffb1b5f7e629bef61a83835107b391efc0b7a71ab9329a950708b20f361b87\"},{\"file\":\"qrcode/lib/renderer/canvas.js\",\"sha256\":\"0ce857661e234786295c9b4042cad6dc1f4d7a496c4990f52870d2bc4ab1e6a9\"},{\"file\":\"qrcode/lib/renderer/svg-tag.js\",\"sha256\":\"2f733e88d0b0155fae4aa4827de0fb9ae73cbbfdfc0cae52dc9d5ed17ae0447d\"},{\"file\":\"qrcode/lib/browser.js\",\"sha256\":\"868da79917b05db1f3d4402f4875148fb06ce843cbd74c65429cd6ef960b1638\"}]"
}
]
}
],
"dependencies": [
{
"ref": "pkg:npm/uds-auth@0.3.11",
"dependsOn": [
"pkg:npm/dijkstrajs@1.0.3",
"pkg:npm/qrcode@1.5.4"
]
},
{
"ref": "pkg:npm/dijkstrajs@1.0.3",
"dependsOn": []
},
{
"ref": "pkg:npm/qrcode@1.5.4",
"dependsOn": []
}
]
}

View file

@ -35,12 +35,15 @@
## Agent 环境
Shell 已注入(非密钥):
Shell / 任务 env(非密钥除非 Host 显式注入 capability):
- `DSH_SESSION_ID` — 当前 agent 会话
- `DSH_WEB_URL` — 可选,用于拼 Host 地址
- `UDS_TASK_CAPABILITY` — **推荐**:Host/`mintTaskCapability` 签发的短期令牌(scopes: `outbound` 和/或 `credentials`)
Host 在 `session/created` 时会 `stampSessionOwner(sessionId, empNo)`。Cron 可按 `ownerEmpNo` 解析。
Host 在 `session/created` 时会 `stampSessionOwner(sessionId, empNo)`。Cron 可按 `ownerEmpNo` 解析并 mint capability;**不要**依赖浏览器 Cookie。
账号停用/删除会撤销该用户全部 task capability。
## 两种合法模式
@ -49,13 +52,15 @@ Host 在 `session/created` 时会 `stampSessionOwner(sessionId, empNo)`。Cron
```http
POST /uds-auth/outbound
X-DSH-Session-Id: <DSH_SESSION_ID>
X-UDS-Task-Capability: <UDS_TASK_CAPABILITY> # scope 须含 outbound
{ "url": "https://icenterapi.zte.com.cn/...", "method": "POST", "headers": {...}, "body": {...} }
```
- 仅 **loopback**
- Host 注入 `X-Emp-No` / `X-Auth-Value`
- 剥离客户端自带鉴权头
- host 必须在白名单内
- host 必须在白名单内;URL 必须 **HTTPS**
- capability 仅 `credentials` scope 时 **不能** 调 outbound
Python(先把 `uds-skill-auth/scripts` 加入 `sys.path`):
@ -67,16 +72,20 @@ out = request("POST", url, headers={...}, body={...})
### Mode Creds(必须自管 HTTP 时)
默认 `allowRawAgentToken=false`。开启后:
```http
POST /uds-auth/agent-credentials
X-DSH-Session-Id: <DSH_SESSION_ID>
X-UDS-Task-Capability: <UDS_TASK_CAPABILITY> # scope 须含 credentials
```
返回 `{ empNo, token }`。可设进程内 `EMP_NO` / `AUTH_VALUE` 别名,**禁止**写进 SKILL.md 教模型 `printenv`。
返回 `{ empNo, token }`。可设进程内 `EMP_NO` / `AUTH_VALUE` 别名,**禁止**写进 SKILL.md 教模型 `printenv`。仅 `outbound` scope 的 capability **不能**取 raw token。
```python
from uds_skill_auth import resolve
creds = resolve() # empNo + token;默认写入 EMP_NO/AUTH_VALUE
# 优先用 request()/outbound;仅在 allowRawAgentToken=true 时使用 resolve
creds = resolve(apply_env_aliases=False)
```
Helpers 来源:插件内 skill `uds-auth/skills/uds-skill-auth/scripts/`(装到 skills 路径后,或环境变量 `UDS_AUTH_HELPERS`)。

View file

@ -12,9 +12,11 @@ uds-auth/skills/uds-skill-auth/
1. 安装 uds-auth 插件
2. 把 `uds-auth/skills/uds-skill-auth` 配进 Harness 的 skills 目录(或复制到工作区 `skills/uds-skill-auth`)
3. 用户扫码登录
3. 用户扫码登录(或由 Host 为 cron 注入 `UDS_TASK_CAPABILITY`)
4. 业务 skill 通过 sibling / skills 根找到本 skill 的 `scripts/`
推荐:**优先** `request()` / outbound;原始 `resolve()` 依赖 `allowRawAgentToken`(默认关闭)。后台任务使用 `UDS_TASK_CAPABILITY`,不要依赖浏览器 Cookie。
## 业务 skill 引用
```python
@ -33,8 +35,10 @@ def load_uds_skill_auth():
| 函数 | 用途 |
|------|------|
| `resolve()` | `POST /uds-auth/agent-credentials` → empNo+token |
| `request(method, url, ...)` | `POST /uds-auth/outbound` 出站代贴鉴权 |
| `resolve()` | 原始凭证(需显式开启 raw;可带 `UDS_TASK_CAPABILITY`) |
| `request(method, url, ...)` | `POST /uds-auth/outbound`(推荐;支持 task capability) |
| `UdsAuthError` | 未登录 / 连不上 Host 等 |
环境变量:`DSH_SESSION_ID`、`UDS_TASK_CAPABILITY`(Host `mintTaskCapability` 签发)。
完整契约:[skill-auth-standard.zh.md](./skill-auth-standard.zh.md)

View file

@ -0,0 +1,41 @@
# 升级到 0.3.10
本版修复密码并发写入、Desktop 凭据附带范围、Gateway 全局接口遗漏、扫码浏览器绑定、任务授权注入和安全状态迁移。先备份 profile 中的安全状态及密钥,再替换插件并重启 Host;不要删除 roles/owners 文件,也不要恢复历史共享默认密码。
## 登录和 Desktop
应急密码入口始终显示;未设置密码时保持禁用。已登录的授权管理员可在「账号 / 权限」设置至少 10 位的新密码。Local Admin 密封盒解锁保持独立,轮换或关闭应急密码不撤销密封盒会话。
Desktop 只把 bridge 附到 `dsh-app://app` 或 Host 启动时提供的 `__DSH_TRANSPORT__.streamBaseUrl` 对应 authority。启动信息缺失时绝不向其他本机端口外发凭据;需要支持提供这个正式 transport 字段的 DSH 版本。带 bridge 的 fetch 拒绝重定向,保留原 Request headers。一次性 WS ticket 只用于 GET `/api/remote.mux` 的真正 WebSocket upgrade;普通 HTTP、其他路径和重放均无效。构造 WebSocket 仍采用同步 XHR 取票,需真实 Desktop 验证该协议桥。
## 扫码协议变更
`qr-start` 返回 `browserBinding`,浏览器仅在内存中保存,轮询 `qr-proxy` 时通过 `X-UDS-QR-Binding` 发送。该秘密不出现在二维码、URL 或转发给 UAC 的 body 中。服务端固定发送挑战绑定的系统代码并计算 verifyCode。
当前明确支持的 UAC 契约是 outer `code.code=0000`、`bo.code=0000`,凭据来自 `other.account/empNo` 和 `other.token/authValue`;`4002` 保持等待,`1002` 表示过期。成功结果还必须通过严格工号匹配的用户资料校验。服务端在异步校验前原子预留成功兑换,直接返回 `auth` 本地会话;前端不再保存 SSO cookie,也不再依次调用 user-info 和 bridge/bind。无法识别的响应拒绝登录,不返回上游原文。外部 QR 客户端须同步适配此变更;真实 UAC 响应仍需现场核验。
## Skill、cron 和后台 shell
Host 加载 `shellEnv` 时自动注册 uds-auth contributor。每次 shell 执行根据真实 `execution.agent.session.header.id` 和服务端 owner 签发仅有 `outbound` scope 的 capability,默认 10 分钟,注入受保护的 `DSH_UDS_TASK_CAPABILITY` / `DSH_UDS_AUTH_BASE`。Python helper 优先读取这两个变量;兼容的手工 `UDS_TASK_CAPABILITY` / `UDS_AUTH_BASE` 仅在没有 Host 管理变量时使用。所有 base,包括旧 `DSH_WEB_URL`,都必须是 loopback HTTP(S)。不再猜测 8787 端口,也不允许 remote override 携带凭据。
普通执行结束或取消即撤销。官方 shell 交给 jobs 的 background/promoted 执行绑定该 job,停止、结算、移除或 TTL 到期撤销;不跨任务复用。账号停用、owner 改变和 scope 不符均拒绝代理调用。长于 10 分钟的后台进程不能自动续期;应拆为新的受控调用。前台/后台都不会向 shell 注入 UAC 原始 token。需要 raw credentials 的受信 Host 调用必须显式申请 scope,且仍受 `allowRawAgentToken` 开关控制。
配套 `dsh-ops-cron` 已把 owner 校验和盖章前移到 agents.create/followup 之前,使用 uds-auth 时 owner 缺失/停用/冲突会拒绝执行。单独无 uds-auth 的 cron 使用方式保持可用。请同时部署本次 cron 变更。
## Gateway、持久化与权限
统一 Gateway 检查覆盖实际 `prepareInvocation` 路径:包括 unary、HTTP RPC 和 WS stream;每帧复核会话有效性、账号状态及 owner。现有资源接口继续调用各自 ACL;带 agent/workspaceFileScope/sessionId 的接口校验目标会话;其余全局接口默认要求管理员(包括账户、插件管理和未知新接口)。session/control baseline 和增量按可见会话投影。普通用户需要新增全局功能时,应先审查接口,再显式加入策略。
Gateway 内置 `$events` 同样校验登录和每帧有效性,按 owner 过滤会话事件、goal、Agent 审批和问答;普通用户只接收显式允许的公共目录变更,其他全局事件要求管理员。`$events/result` 必须来自创建该事件流的同一个本地登录 session,且仍能访问目标 Agent,避免跨用户代答审批。
该统一检查依赖当前 DSH `0.2.0-rc.2` 的 Gateway 内部方法;不兼容时拒绝加载,不能静默略过。升级 Host 前须重跑真实 Loader/Gateway 集成检查和完整 inventory。
roles 全部运行时写入串行化,未提交的权限不对读者生效。密码 generation 落盘并在重启时恢复;失败事务恢复角色、状态、偏好及口令,不让后台计时器写回失败状态。运行时 scrypt 使用异步线程池,最多 2 个并发、8 个等待,超载返回错误。
迁移使用排他复制、不覆盖冲突状态;相同的中断副本可继续,失败清理本次已创建且仍与源相同的文件,成功 marker 原子落盘。原子写入失败清理临时文件。
Unix profile 目录/文件为 0700/0600。Windows profile 及已知安全状态文件 DACL 仅允许当前 Host 账号和 SYSTEM,关闭继承,并实际读取 ACL 验证;失败拒绝初始化。服务账号部署须使用该账号执行初始化;换账号前由授权运维迁移所有权和 ACL。此机制不限制管理员提升权限后的访问。临时目录 DACL 测试不等于完整的第二个 Windows 普通账号读取测试。
## 交付检查
仓库提供 `npm test`(自然退出)、`npm run pack:check`、`npm run sbom`;CI 覆盖 Windows/Linux、Node 22.19/24、重建二维码、包检查及含开发依赖的 npm audit。`docs/sbom.cdx.json` 精确记录实际 bundled qrcode/dijkstrajs 和源文件哈希;`THIRD-PARTY-NOTICES.txt` 保留其许可证。DSH/React/Electron 属于 Host 的独立供应链,须结合正式 Host SBOM 检查。CI 配置落地不代表远端任务已经运行。

View file

@ -0,0 +1,18 @@
# 0.3.11:原版 Desktop 启动兼容修复
无需修改、重新打包或替换 DSH。修复全部在 uds-auth npm 包内。
0.3.10 的 Gateway 默认权限策略阻止了 Desktop 原生欢迎页在 UDS 登录前读取 `settings/describe`,宿主将业务拒绝当作启动失败,显示 `desktop welcome: Web RPC failed`。
0.3.11 对四个已有的、非流式 RPC 提供最小启动投影:
- `settings/describe`:仅语言偏好和虚拟启动命名空间,声明不可写且无本地文档。
- `llm/listConfigurableProviders`:仅虚拟启动条目,不枚举实际供应商配置。
- `credentials/describe`:仅接受固定虚拟引用 `UDS_AUTH_DESKTOP_PROVIDER_CONFIGURED`,返回“是否配置过供应商密钥”的合并布尔值,永远不可写。其他引用拒绝。
- `account/getState`:仅 Host 平台凭据是否存在的状态,登录尝试为 null,链接固定为公开平台页面。
这些投影不构造 UDS 身份,不赋予角色,不开放设置写入、凭据读写、会话或 WS 流。经过登录的管理员和可信 Host 调用仍获得原始权限对应的结果;普通用户只有投影,已撤销的登录身份仍拒绝。
如果 Host 还没有任何供应商凭据,原版 Desktop 可能仍显示自身的欢迎页;点击 Skip/跳过进入工作区后由 UDS 登录界面接管。原生欢迎页的账户登录和密钥写入仍须管理权限;请通过 UDS 管理员登录后的设置配置供应商。
安装 0.3.11 后重启 Desktop。请保持 0.3.10 的安全配置和角色数据,不要通过停用 uds-auth 或放行整个设置命名空间绕过此问题。

View file

@ -1,9 +1,15 @@
/**
* Loopback agent APIs: credentials + outbound proxy.
* SEC-05: no XFF trust; no arbitrary empNo credential fetch.
* Caller must prove a local session bearer whose empNo owns the DSH sessionId
* (or matches an explicit capability in a later phase).
*/
import { directRequest } from './uds/user-search.js'
import { requestIsLoopback } from './skill-credentials.js'
import { apiError, resolveLocale } from './i18n.js'
import { extractSessionBearer } from './session/request-auth.js'
import { readJsonBodyLimited, DEFAULT_MAX_BODY } from './utils/read-body.js'
import { audit } from './utils/audit-log.js'
const DEFAULT_OUTBOUND_HOSTS = [
'icenterapi.zte.com.cn',
@ -33,68 +39,171 @@ function readSessionId(req, body) {
}
async function readJsonBody(req) {
let raw = ''
for await (const chunk of req) raw += chunk
if (!raw) return {}
try { return JSON.parse(raw) } catch { return {} }
try {
return await readJsonBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY })
} catch (err) {
if (err?.code === 'payload_too_large' || err?.code === 'invalid_json') throw err
return {}
}
}
/**
* @param {{
* resolveCredentialsForSession: (sessionId: string) => Promise<{empNo:string,token:string}|null>,
* resolveCredentialsForEmpNo: (empNo: string) => Promise<{empNo:string,token:string}|null>,
* resolveCallerSession: (req: any) => Promise<{empNo:string,sessionId?:string}|null>,
* getSessionOwner: (sessionId: string) => string|null,
* empNoHeader?: string,
* authValueHeader?: string,
* outboundHosts?: string[],
* sessionBridge?: any,
* allowRawTokenResponse?: boolean|(() => boolean),
* taskCapabilities?: import('./task-capability.js').TaskCapabilityStore,
* }} deps
*/
export function createAgentAuthHandlers(deps) {
const empNoHeader = deps.empNoHeader || 'X-Emp-No'
const authValueHeader = deps.authValueHeader || 'X-Auth-Value'
/** R05: live flag — closing allowRawAgentToken must take effect without restart. */
function allowRawToken() {
if (typeof deps.allowRawTokenResponse === 'function') {
return deps.allowRawTokenResponse() === true
}
return deps.allowRawTokenResponse === true
}
function allowedHosts() {
const list = typeof deps.outboundHosts === 'function'
? deps.outboundHosts()
: deps.outboundHosts
if (Array.isArray(list) && list.length) return list.map(String)
return DEFAULT_OUTBOUND_HOSTS.slice()
// Empty whitelist = deny all (SEC-18); do not fall back to defaults when explicitly [].
if (Array.isArray(list)) return list.map(String).map((s) => s.trim().toLowerCase()).filter(Boolean)
// Explicit empty string also denies all; only undefined/null uses packaged defaults.
if (list === '') return []
if (list == null) return DEFAULT_OUTBOUND_HOSTS.map((s) => s.toLowerCase())
return String(list).split(/[,;\s]+/).map((s) => s.trim().toLowerCase()).filter(Boolean)
}
/** Exact hostname match only — no automatic subdomain wildcard (SEC-18). */
function hostAllowed(hostname) {
const host = String(hostname || '').toLowerCase()
return allowedHosts().some((h) => host === h.toLowerCase() || host.endsWith('.' + h.toLowerCase()))
if (!host) return false
const list = allowedHosts()
if (!list.length) return false
return list.includes(host)
}
async function resolveFromRequest(req, body = {}) {
async function resolveCaller(req, body = {}) {
// R06: task capability (skill/cron) — preferred over requiring a browser session.
const caps = deps.taskCapabilities
if (caps) {
const raw = caps.extractFromRequest?.(req)
|| body?.taskCapability
|| body?.capability
|| null
if (raw) {
const sessionId = readSessionId(req, body)
const verified = caps.verify(raw, {
audience: 'uds-auth-agent',
dshSessionId: sessionId || undefined,
})
if (verified?.empNo) {
return {
empNo: verified.empNo,
sessionId: verified.dshSessionId || sessionId || undefined,
via: 'task_capability',
capabilityId: verified.id,
scopes: verified.scopes,
}
}
return null
}
}
if (typeof deps.resolveCallerSession === 'function') {
return deps.resolveCallerSession(req)
}
return null
}
/**
* Authorize: loopback + (local session bearer OR task capability)
* + sessionId owned by caller. body.empNo is ignored as an auth input.
* @param {string} [requiredScope] A06: exact scope for the endpoint ('credentials'|'outbound')
*/
async function resolveAuthorizedCreds(req, body = {}, requiredScope) {
const caller = await resolveCaller(req, body)
if (!caller?.empNo) return { error: 'caller_unauthenticated', creds: null, sessionId: null }
const sessionId = readSessionId(req, body)
if (sessionId) {
const creds = await deps.resolveCredentialsForSession(String(sessionId))
if (creds) return { creds, sessionId: String(sessionId) }
if (!sessionId) {
return { error: 'session_id_required', creds: null, sessionId: null }
}
const empNo = body.empNo || req.headers['x-uds-emp-no']
if (empNo) {
const creds = await deps.resolveCredentialsForEmpNo(String(empNo))
if (creds) return { creds, sessionId: sessionId ? String(sessionId) : null }
const owner = deps.getSessionOwner?.(String(sessionId)) || null
if (!owner || String(owner) !== String(caller.empNo)) {
return { error: 'session_owner_mismatch', creds: null, sessionId: String(sessionId) }
}
return { creds: null, sessionId: sessionId ? String(sessionId) : null }
// A06: credentials vs outbound require their own scope — not either-or.
if (caller.via === 'task_capability' && requiredScope) {
if (!Array.isArray(caller.scopes) || !caller.scopes.includes(requiredScope)) {
return { error: 'capability_scope_denied', creds: null, sessionId: String(sessionId) }
}
}
// Live account check when roles store is wired.
if (typeof deps.isAccountActive === 'function' && !deps.isAccountActive(caller.empNo)) {
return { error: 'account_disabled', creds: null, sessionId: String(sessionId) }
}
const creds = await deps.resolveCredentialsForSession(String(sessionId))
if (!creds || String(creds.empNo) !== String(caller.empNo)) {
return { error: 'no_skill_credentials', creds: null, sessionId: String(sessionId) }
}
return { error: null, creds, sessionId: String(sessionId), caller }
}
async function handleAgentCredentials(req, res) {
if (!requestIsLoopback(req)) {
audit({ action: 'agent_credentials', decision: 'deny', reasonCode: 'loopback_only' })
return sendErr(req, res, 403, 'loopback_only_credentials')
}
if (!allowRawToken()) {
audit({ action: 'agent_credentials', decision: 'deny', reasonCode: 'raw_token_disabled' })
return sendJSON(res, 403, {
...apiError('raw_token_disabled', resolveLocale(req)),
hint: 'use /uds-auth/api/agent-outbound with a verified local session',
})
}
const method = (req.method || 'GET').toUpperCase()
if (method !== 'GET' && method !== 'POST') {
return sendErr(req, res, 405, 'method_not_allowed')
}
const body = method === 'POST' ? await readJsonBody(req) : {}
const { creds, sessionId } = await resolveFromRequest(req, body)
if (!creds) {
let body = {}
try {
body = method === 'POST' ? await readJsonBody(req) : {}
} catch (err) {
return sendErr(req, res, err.statusCode || 400, err.code || 'invalid_json')
}
const { error, creds, sessionId } = await resolveAuthorizedCreds(req, body, 'credentials')
if (error || !creds) {
audit({
action: 'agent_credentials',
decision: 'deny',
reasonCode: error || 'no_skill_credentials',
meta: { sessionId },
})
return sendJSON(res, 401, {
...apiError('no_skill_credentials', resolveLocale(req)),
...apiError(error || 'no_skill_credentials', resolveLocale(req)),
sessionId: sessionId || null,
})
}
audit({
action: 'agent_credentials',
decision: 'allow',
actor: creds.empNo,
resource: sessionId,
})
return sendJSON(res, 200, {
empNo: creds.empNo,
token: creds.token,
@ -111,9 +220,9 @@ export function createAgentAuthHandlers(deps) {
return sendErr(req, res, 405, 'method_not_allowed')
}
const body = await readJsonBody(req)
const { creds } = await resolveFromRequest(req, body)
if (!creds) {
return sendErr(req, res, 401, 'no_skill_credentials')
const { error, creds } = await resolveAuthorizedCreds(req, body, 'outbound')
if (error || !creds) {
return sendErr(req, res, 401, error || 'no_skill_credentials')
}
const targetUrl = body.url
@ -126,19 +235,31 @@ export function createAgentAuthHandlers(deps) {
} catch {
return sendErr(req, res, 400, 'invalid_url')
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return sendErr(req, res, 400, 'unsupported_protocol')
// Credentials must never travel over cleartext HTTP (SEC-18).
if (parsed.protocol !== 'https:') {
return sendErr(req, res, 400, 'https_required')
}
if (parsed.username || parsed.password) {
return sendErr(req, res, 400, 'invalid_url')
}
if (!hostAllowed(parsed.hostname)) {
return sendJSON(res, 403, apiError('host_not_allowed', resolveLocale(req), { host: parsed.hostname }))
}
const upstreamMethod = String(body.method || 'POST').toUpperCase()
const headers = { ...(body.headers && typeof body.headers === 'object' ? body.headers : {}) }
for (const k of Object.keys(headers)) {
const lower = k.toLowerCase()
if (lower === 'x-auth-value' || lower === 'x-emp-no' || lower === 'host' || lower === 'content-length') {
delete headers[k]
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(upstreamMethod)) {
return sendErr(req, res, 400, 'method_not_allowed')
}
// Rebuild headers from whitelist only — never forward caller Authorization/Cookie.
const allowedHeaderNames = new Set([
'content-type', 'accept', 'accept-language', 'x-lang-id', 'x-requested-with',
])
const headers = {}
if (body.headers && typeof body.headers === 'object') {
for (const [k, v] of Object.entries(body.headers)) {
if (allowedHeaderNames.has(String(k).toLowerCase()) && v != null) {
headers[k] = String(v).slice(0, 1024)
}
}
}
headers[empNoHeader] = creds.empNo
@ -157,13 +278,12 @@ export function createAgentAuthHandlers(deps) {
method: upstreamMethod,
headers,
body: upstreamBody,
timeoutMs: Number(body.timeoutMs) || 30000,
timeoutMs: Math.min(Number(body.timeoutMs) || 30000, 60000),
})
res.statusCode = out.statusCode || 502
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Uds-Outbound-Status', String(out.statusCode || 0))
// Wrap so skill gets status + body without leaking injected auth headers
res.end(JSON.stringify({
statusCode: out.statusCode,
headers: sanitizeUpstreamHeaders(out.headers),
@ -177,7 +297,7 @@ export function createAgentAuthHandlers(deps) {
}
}
return { handleAgentCredentials, handleOutbound, DEFAULT_OUTBOUND_HOSTS }
return { handleAgentCredentials, handleOutbound, DEFAULT_OUTBOUND_HOSTS, extractSessionBearer }
}
function sanitizeUpstreamHeaders(headers) {

View file

@ -2,6 +2,11 @@ import { ROLES, ROLE_LABELS } from './roles.js'
import { requirePermission } from './middleware/auth-middleware.js'
import { apiError, apiOk, resolveLocale, roleLabel } from './i18n.js'
import { clearBrowserIdentity } from './identity-cache.js'
import { SESSION_COOKIE, clearSessionCookie } from './session/request-auth.js'
import { extractSessionBearerAsync } from './session/request-auth.js'
import { readBodyLimited, DEFAULT_MAX_BODY } from './utils/read-body.js'
import { audit } from './utils/audit-log.js'
import { identityFromSessionRecord } from './dsh-acl.js'
/**
* API handlers — permission guards + localized messages via stable error codes.
@ -13,6 +18,17 @@ import { clearBrowserIdentity } from './identity-cache.js'
* @param {{ skillCredentials?: { delete: (empNo: string) => void }, retainSkillCredentialsOnLogout?: () => boolean }} [extra]
*/
export function createApiHandlers(config, sessionStore, rolesStore, extra = {}) {
// Read again inside the RolesStore mutation queue, after a slow body or a
// concurrent demotion/logout; the role captured at request admission is stale.
const liveActorRole = ctx => () => {
if (rolesStore.isDisabled?.(ctx.empNo)) return ROLES.USER
const sid = ctx.sessionId || ctx.userContext?._sessionId
if (sid && typeof sessionStore.getBySessionIdSync === 'function') {
const row = sessionStore.getBySessionIdSync(sid)
return identityFromSessionRecord(row, rolesStore)?.role || ROLES.USER
}
return rolesStore.getRole?.(ctx.empNo) || ctx.role
}
async function sendRes(res, code, data) {
res.statusCode = code >= 200 && code < 300 ? 200 : code
res.setHeader('Content-Type', 'application/json; charset=utf-8')
@ -44,8 +60,27 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
async function logout(ctx) {
const empNo = ctx.empNo
if (empNo) await sessionStore.delete(empNo)
try { extra.sessionBridge?.revokeEmpNo?.(empNo) } catch { /* ignore */ }
const sessionId = ctx.sessionId || ctx.userContext?._sessionId
// Prefer revoke current device; fall back to all devices for legacy callers.
if (sessionId && typeof sessionStore.revokeSession === 'function') {
await sessionStore.revokeSession(sessionId)
} else if (empNo) {
await sessionStore.delete(empNo)
}
try {
const extracted = await extractSessionBearerAsync(ctx.req, {
sessionBridge: extra.sessionBridge,
isLiveBearer: async (bearer) => !!(await sessionStore.getByBearer?.(bearer)),
})
if (extracted?.bearer) {
extra.sessionBridge?.revokeToken?.(extracted.bearer)
if (typeof sessionStore.revokeBearer === 'function') {
await sessionStore.revokeBearer(extracted.bearer)
}
} else if (empNo) {
extra.sessionBridge?.revokeEmpNo?.(empNo)
}
} catch { /* ignore */ }
try { clearBrowserIdentity() } catch { /* ignore */ }
const retain = extra.retainSkillCredentialsOnLogout
? extra.retainSkillCredentialsOnLogout() !== false
@ -53,7 +88,15 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
if (empNo && !retain) {
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
}
const clear = []
// Task capabilities outlive UI logout only if Host minted them for cron;
// revoke current-user caps when logout is full-account style (no retain path).
if (empNo && typeof extra.revokeTaskCapabilitiesForEmpNo === 'function' && !retain) {
try { extra.revokeTaskCapabilitiesForEmpNo(empNo) } catch { /* ignore */ }
}
const clear = [
clearSessionCookie({ secure: false }),
clearSessionCookie({ secure: true }),
]
for (const name of [
'UDS_FALLBACK_USER',
'UDS_FALLBACK_UI',
@ -63,7 +106,7 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
'ZTEDPGSSOUser',
'ZTEDPGSSOCookie',
]) {
const httpOnly = name === 'UDS_FALLBACK_USER' || name === 'UDS_LOCAL_ADMIN'
const httpOnly = name === 'UDS_FALLBACK_USER' || name === 'UDS_LOCAL_ADMIN' || name === SESSION_COOKIE
const base = httpOnly
? (name + '=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax')
: (name + '=; Max-Age=0; Path=/; SameSite=Lax')
@ -71,21 +114,41 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
clear.push(base + '; Secure')
}
ctx.res.setHeader('Set-Cookie', clear)
ctx.res.setHeader('Cache-Control', 'no-store')
audit({
action: 'logout',
decision: 'allow',
actor: empNo || null,
resource: sessionId || null,
})
await ok(ctx, 'logged_out')
}
async function getCurrentUser(ctx) {
const ws = ctx.provisionedWorkspace || null
ctx.res.setHeader('Cache-Control', 'no-store')
// SEC-07: field whitelist — never return upstream token / authValue
const uc = ctx.userContext
const safe = uc ? {
empNo: ctx.empNo,
userId: uc.userId || ctx.empNo,
username: uc.username || null,
displayName: uc.displayName || uc.username || null,
department: uc.department || null,
organization: uc.organization || null,
lang: uc.lang || null,
isAuthenticated: true,
authMode: uc.authMode || null,
role: ctx.role,
permissions: ctx.permissions,
workspaceId: ws?.workspaceId || null,
workspacePath: ws?.path || null,
authenticatedAt: uc.authenticatedAt || null,
lastActiveAt: uc.lastActiveAt || null,
} : null
await sendRes(ctx.res, 200, {
data: ctx.userContext ? {
...ctx.userContext,
empNo: ctx.empNo,
role: ctx.role,
permissions: ctx.permissions,
workspaceId: ws?.workspaceId || null,
workspacePath: ws?.path || null,
} : null,
authenticated: !!ctx.userContext,
data: safe,
authenticated: !!safe,
})
}
@ -118,7 +181,14 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
return fail(ctx, 'invalid_role_params', 400)
}
try {
await rolesStore.setRole(empNo, role, ctx.role)
await rolesStore.setRole(empNo, role, liveActorRole(ctx))
audit({
action: 'set_role',
decision: 'allow',
actor: ctx.empNo,
target: empNo,
meta: { role },
})
await ok(ctx, 'role_updated', { empNo, role: roleLabel(role, locale(ctx)) })
} catch (err) {
await mapThrown(ctx, err)
@ -136,8 +206,8 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
}
const targetRole = role && Object.values(ROLES).includes(role) ? role : ROLES.USER
try {
await rolesStore.ensureUser(empNo, ctx.role)
if (role) await rolesStore.setRole(empNo, role, ctx.role)
await rolesStore.ensureUser(empNo, liveActorRole(ctx))
if (role) await rolesStore.setRole(empNo, role, liveActorRole(ctx))
await ok(ctx, 'user_added', { empNo, role: roleLabel(targetRole, locale(ctx)) })
} catch (err) {
await mapThrown(ctx, err)
@ -154,15 +224,42 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
return fail(ctx, 'emp_no_required', 400)
}
try {
await rolesStore.removeUser(empNo, ctx.role)
await rolesStore.removeUser(empNo, liveActorRole(ctx))
await sessionStore.delete(empNo)
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
// A06: revoke task capabilities when account is disabled/removed.
try { extra.revokeTaskCapabilitiesForEmpNo?.(empNo) } catch { /* ignore */ }
await ok(ctx, 'user_removed', { empNo })
} catch (err) {
await mapThrown(ctx, err)
}
}
function isPasswordModeSession(row) {
const mode = String(row?.userData?.authMode || row?.authMode || row?.kind || '')
if (mode.includes('local-admin') || mode === 'sealed_box' || mode === 'local_admin') {
return false
}
return mode.includes('fallback') || mode === 'fallback' || row?.kind === 'fallback'
}
async function revokePasswordFallbackSessions() {
// D04/F02: revoke password-mode only — keep independent sealed-box sessions/bridges.
const empNo = 'administrator'
try {
if (typeof sessionStore.revokeEmpNoMatching === 'function') {
await sessionStore.revokeEmpNoMatching(empNo, isPasswordModeSession)
} else if (typeof sessionStore.revokeEmpNo === 'function') {
// Stores without matching API: do not wipe sealed-box with a blanket delete.
}
} catch { /* ignore */ }
try {
extra.sessionBridge?.revokeEmpNoKinds?.(empNo, [
'fallback', 'fallback_password', 'fallback-password', 'fallback-login',
])
} catch { /* ignore */ }
}
async function setFallbackPassword(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return fail(ctx, 'forbidden_set_fallback', 403)
@ -170,7 +267,8 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
const body = await readBody(ctx.req)
const { password } = body || {}
try {
await rolesStore.setFallbackPassword(password, ctx.role)
await rolesStore.setFallbackPassword(password, liveActorRole(ctx))
await revokePasswordFallbackSessions()
await ok(ctx, 'fallback_password_set')
} catch (err) {
await mapThrown(ctx, err)
@ -181,8 +279,13 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
if (!requirePermission(ctx, 'super_admin')) {
return fail(ctx, 'forbidden_clear_fallback', 403)
}
await rolesStore.clearFallbackPassword(ctx.role)
await ok(ctx, 'fallback_password_cleared')
try {
await rolesStore.clearFallbackPassword(liveActorRole(ctx))
await revokePasswordFallbackSessions()
await ok(ctx, 'fallback_password_cleared')
} catch (err) {
await mapThrown(ctx, err)
}
}
async function fallbackStatus(ctx) {
@ -198,10 +301,7 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
const body = await readBody(ctx.req)
const enabled = !!(body && body.enabled)
try {
rolesStore.setViewAllSessions(ctx.empNo, enabled)
if (typeof rolesStore.flush === 'function') {
await rolesStore.flush()
}
await rolesStore.setViewAllSessionsDurable(ctx.empNo, enabled)
ctx.permissions = rolesStore.resolvePermissions(ctx.empNo, ctx.role)
await ok(ctx, enabled ? 'view_all_sessions_on' : 'view_all_sessions_off', null, {
permissions: ctx.permissions,
@ -227,7 +327,11 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
}
async function readBody(req) {
let body = ''
for await (const chunk of req) body += chunk
try { return JSON.parse(body) } catch { return null }
try {
const raw = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY })
if (!raw) return null
return JSON.parse(raw)
} catch {
return null
}
}

View file

@ -33,7 +33,7 @@ window.__ModuleLoader__.load({
"ui.settingsTitle": "UAC 认证",
"ui.settingsIntro": "工号+token 双校验;UAC 挂死时用应急账号 administrator 密码登录。",
"ui.loginRequiredPage": "请先登录后查看此页",
"ui.roleHint": "当前角色:{role}。超管只是身份标签,与管理员权限相同;首位扫码登录且 roles.json 为空时自动获得超管身份,也可在「用户管理」设为管理员。应急账号 administrator 需先设密码,再在登录面板用账密登录。",
"ui.roleHint": "当前角色:{role}。超管只是身份标签,与管理员权限相同;显式配置的初始管理员在首次登录时获得超管身份,也可在「用户管理」设为管理员。应急账号 administrator 需先设密码,再在登录面板用账密登录。",
"ui.deployConfig": "部署配置",
"ui.userSearchUrl": "用户搜索 URL(token 校验)",
"ui.workspaceRoot": "工作区根目录(空=$DSH_HOME/user-workspaces)",
@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
"ui.fallbackStatus": "状态:{status}。可在此改密或关闭。仅在扫码不可用时从登录面板切换。",
"ui.enabled": "已启用",
"ui.disabled": "未启用",
"ui.fallbackPassword": "应急密码(至少 6 位)",
"ui.fallbackPassword": "应急密码(至少 10 位)",
"ui.saveFallbackPassword": "保存应急密码",
"ui.fallbackPasswordSet": "应急密码已设置",
"ui.confirmClearFallback": "确认清除应急密码?",
@ -75,6 +75,9 @@ window.__ModuleLoader__.load({
"ui.pleaseScan": "请使用 iCenter 扫码登录",
"ui.refreshQr": "刷新二维码",
"ui.fallbackLink": "UAC 不可用?应急账号登录",
"ui.fallbackDisabledLink": "应急登录(未启用)",
"ui.fallbackDisabledHint": "应急密码未配置或已停用。请用已登录的管理员在「账户 / 权限」中设置新的独立强密码后重试。旧共享默认密码不可用。",
"ui.fallbackDisabledDetail": "当前未启用应急密码。用已授权管理员登录后,在设置中配置新密码即可恢复此入口。",
"ui.fallbackLogin": "应急登录",
"ui.fallbackDetail": "UAC / 扫码不可用时使用",
"ui.localKeyLink": "本机密钥解锁",
@ -118,7 +121,9 @@ window.__ModuleLoader__.load({
"err.invalid_credentials": "用户名或密码错误",
"err.last_super_admin_demote": "系统至少需要 1 个超级管理员,不能降级最后一个",
"err.last_super_admin_delete": "系统至少需要 1 个超级管理员,不能删除最后一个",
"err.password_too_short": "密码至少 6 位",
"err.password_too_short": "密码至少 10 位",
"err.password_too_common": "不能使用已知默认口令",
"err.account_disabled": "账号已停用",
"err.local_admin_not_configured": "未配置本机管理员密封盒",
"err.key_required": "请输入解密密钥",
"err.decrypt_failed": "密钥无法解密,登录失败",
@ -173,7 +178,7 @@ window.__ModuleLoader__.load({
"ui.settingsTitle": "UAC Auth",
"ui.settingsIntro": "EmpNo + token verification; when UAC is down, sign in with emergency account administrator.",
"ui.loginRequiredPage": "Sign in to view this page",
"ui.roleHint": "Current role: {role}. Super admin is only an identity label with the same permissions as admin; the first QR login with an empty roles.json gets that identity, or grant admin in User management. Set the emergency password before using administrator on the login panel.",
"ui.roleHint": "Current role: {role}. Super admin is only an identity label with the same permissions as admin; the explicitly configured initial administrator receives that identity on first login, or grant admin in User management. Set the emergency password before using administrator on the login panel.",
"ui.deployConfig": "Deploy config",
"ui.userSearchUrl": "User search URL (token verify)",
"ui.workspaceRoot": "Workspace root (empty=$DSH_HOME/user-workspaces)",
@ -185,7 +190,7 @@ window.__ModuleLoader__.load({
"ui.fallbackStatus": "Status: {status}. Change or disable here. Switch from the login panel only when QR is unavailable.",
"ui.enabled": "Enabled",
"ui.disabled": "Disabled",
"ui.fallbackPassword": "Emergency password (min 6 chars)",
"ui.fallbackPassword": "Emergency password (min 10 chars)",
"ui.saveFallbackPassword": "Save emergency password",
"ui.fallbackPasswordSet": "Emergency password set",
"ui.confirmClearFallback": "Clear emergency password?",
@ -215,6 +220,9 @@ window.__ModuleLoader__.load({
"ui.pleaseScan": "Scan with iCenter to sign in",
"ui.refreshQr": "Refresh QR",
"ui.fallbackLink": "UAC down? Emergency account",
"ui.fallbackDisabledLink": "Emergency login (disabled)",
"ui.fallbackDisabledHint": "Emergency password is not configured or was disabled. Ask a signed-in admin to set a new strong password under Account / Permissions. The old shared default password cannot be used.",
"ui.fallbackDisabledDetail": "Emergency password is off. After an authorized admin sets a new password in settings, this entry works again.",
"ui.fallbackLogin": "Emergency login",
"ui.fallbackDetail": "Use when UAC / QR is unavailable",
"ui.localKeyLink": "Unlock with local key",
@ -258,7 +266,9 @@ window.__ModuleLoader__.load({
"err.invalid_credentials": "Invalid username or password",
"err.last_super_admin_demote": "At least one super admin is required; cannot demote the last one",
"err.last_super_admin_delete": "At least one super admin is required; cannot delete the last one",
"err.password_too_short": "Password must be at least 6 characters",
"err.password_too_short": "Password must be at least 10 characters",
"err.password_too_common": "Cannot use the known default password",
"err.account_disabled": "Account is disabled",
"err.local_admin_not_configured": "Local admin sealed box is not configured",
"err.key_required": "Decryption key required",
"err.decrypt_failed": "Key could not decrypt — sign-in failed",
@ -421,8 +431,10 @@ window.__ModuleLoader__.load({
'html[data-uds-can-settings="0"] [data-uds-foot-slot="settings"],html[data-uds-logged-in="0"] [data-uds-foot-slot="settings"]{display:none!important}',
'html[data-uds-can-settings="0"] button[aria-label="设置"],html[data-uds-can-settings="0"] button[aria-label="Settings"],html[data-uds-logged-in="0"] button[aria-label="设置"],html[data-uds-logged-in="0"] button[aria-label="Settings"]{display:none!important}',
'html[data-uds-can-create-ws="0"] button[aria-label="添加工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Add workspace"]{display:none!important}html[data-uds-logged-in="0"] [role="tree"][aria-label="会话"],html[data-uds-logged-in="0"] [role="tree"][aria-label="Sessions"],html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] .dsh-ct-entry,html[data-uds-logged-in="0"] .dsh-ct-region,html[data-uds-logged-in="0"] .dsh-ct-main,html[data-uds-logged-in="0"] [data-dsh-ct-mode="on"] .dsh-ct-region{display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="选择工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Choose workspace"],html[data-uds-can-create-ws="0"] [aria-label="选择工作区"],html[data-uds-can-create-ws="0"] [aria-label="Choose workspace"]{display:none!important}html[data-uds-logged-in="0"] [class*="cardWorkspaceTrigger"],html[data-uds-logged-in="0"] [data-composer-card][class*="cardWorkspaceTrigger"]{pointer-events:none!important;opacity:.45!important;cursor:not-allowed!important}/* uds-anon-hide-workspaces *//* uds-anon-hide-conversation:removed */html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceRow"],html[data-uds-logged-in="0"] [class*="WorkspaceRow"]{display:none!important}',
/* uds-session-only-sidebar */
'html[data-uds-can-create-ws="0"][data-uds-logged-in="1"] [class*="projectRow"]:not([class*="dsh-ct-project"]),html[data-uds-can-create-ws="0"][data-uds-logged-in="1"] [class*="ProjectRow"]:not([class*="dsh-ct-project"]){display:none!important}',
/* Session-only sidebar: hide workspace project rows only after we force flat
(data-uds-session-only). Do not key on can-create-ws alone — a race to 0
would hide named workspaces and leave only「未分组」visible. */
'html[data-uds-session-only="1"][data-uds-logged-in="1"] [class*="projectRow"]:not([class*="dsh-ct-project"]),html[data-uds-session-only="1"][data-uds-logged-in="1"] [class*="ProjectRow"]:not([class*="dsh-ct-project"]){display:none!important}',
'.uds-auth-badge{display:inline-flex;align-items:center;justify-content:flex-start;gap:0;max-width:min(160px,42vw);min-width:0;height:32px;padding:0 8px;box-sizing:border-box;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;font-weight:400;line-height:20px;cursor:pointer;overflow:hidden}',
'.uds-auth-badge:hover{background:var(--dsw-alias-interactive-bg-hover)}',
'.uds-auth-host.is-rail .uds-auth-badge{width:auto;max-width:100%;height:32px;padding:0 6px;border-radius:8px}',
@ -558,14 +570,12 @@ window.__ModuleLoader__.load({
if (emp && kind === 'fallback') {
try { setCookie('UDS_FALLBACK_UI', emp, 7) } catch { /* Desktop may ignore */ }
}
// Ensure mux reconnects with bridge query (soft path before full reload).
// Prefetch WS tickets then soft-reconnect — sync XHR mint is unreliable on Desktop.
try {
window.__udsAuthBridgeMuxPrimed = false
if (typeof window.__udsAuthReconnect === 'function') {
setTimeout(() => {
try { window.__udsAuthReconnect() } catch { /* ignore */ }
}, 50)
}
setTimeout(() => {
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 50)
} catch { /* ignore */ }
}
@ -579,16 +589,302 @@ window.__ModuleLoader__.load({
}
}
/** Append bridge to WS URL — browsers cannot set custom WS headers; Desktop has no cookies. */
/** Resolve fetch/WS input to absolute URL (R01). */
function resolveTransportUrl(input) {
try {
if (typeof Request !== 'undefined' && input instanceof Request) {
return new URL(input.url)
}
const base = typeof location !== 'undefined' ? location.href : 'http://localhost/'
return new URL(String(input), base)
} catch {
return null
}
}
function defaultPort(protocol) {
if (protocol === 'https:' || protocol === 'wss:') return '443'
if (protocol === 'http:' || protocol === 'ws:') return '80'
return ''
}
function hostnameIsLoopback(hostname) {
const h = String(hostname || '').toLowerCase()
const bare = h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h
return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1'
}
/** Desktop boot exposes the real Host HTTP origin for mux/WS. */
function getDesktopTransportBase() {
try {
const base = globalThis.__DSH_TRANSPORT__?.streamBaseUrl
if (!base) return null
return new URL(base)
} catch {
return null
}
}
function sameHostAuthority(a, b) {
if (!a || !b) return false
const aSecure = a.protocol === 'https:' || a.protocol === 'wss:'
const bSecure = b.protocol === 'https:' || b.protocol === 'wss:'
if (aSecure !== bSecure) return false
return a.hostname === b.hostname
&& String(a.port || defaultPort(a.protocol)) === String(b.port || defaultPort(b.protocol))
}
/**
* A05/D01/D02: attach bridge only to the current Host authority.
* Desktop dsh-app:// maps relative Host routes + streamBaseUrl / loopback Gateway.
* HTTP↔WS and HTTPS↔WSS map as the same Host; never HTTPS→WS.
*/
function isTrustedHostUrl(input) {
if (typeof location === 'undefined') return false
const parsed = resolveTransportUrl(input)
if (!parsed) return false
const urlProto = parsed.protocol
// Desktop renderer: document is dsh-app://app — Host is a separate HTTP origin.
// 0.3.9 behavior (regression in 0.3.10): when streamBaseUrl is not ready yet,
// still trust loopback Gateway so WS tickets attach; otherwise workspace follow
// stays anonymous and every session lands under「未分组」.
if (location.protocol === 'dsh-app:') {
if (location.hostname !== 'app') return false
if (urlProto === 'dsh-app:') return parsed.hostname === 'app' && !parsed.port
if (!['http:', 'https:', 'ws:', 'wss:'].includes(urlProto)) return false
const transport = getDesktopTransportBase()
if (transport) {
return sameHostAuthority(parsed, {
protocol: transport.protocol,
hostname: transport.hostname,
port: transport.port,
})
}
return hostnameIsLoopback(parsed.hostname)
}
const locProto = location.protocol
const locSecure = locProto === 'https:' || locProto === 'wss:'
const urlSecure = urlProto === 'https:' || urlProto === 'wss:'
if (locSecure !== urlSecure) return false
if (!['http:', 'https:', 'ws:', 'wss:'].includes(urlProto)) return false
return parsed.hostname === location.hostname
&& String(parsed.port || defaultPort(urlProto))
=== String(location.port || defaultPort(locProto))
}
/**
* T01: one-time WS tickets. WebSocket ctor is sync, so we keep a small
* prefetched cache filled by async fetch (same path as login /api/me).
*
* Desktop regression: sync XHR to absolute streamBaseUrl trips CORS
* (custom bridge headers + OPTIONS→401), and relative sync XHR often
* returns an empty body on dsh-app://. Mux then upgrades anonymously;
* workspace follow cannot “wait” its way into an identity →「未分组」.
*/
const _wsTicketCache = []
let _wsTicketPrefetch = null
function wsTicketRequestUrls() {
const urls = ['/uds-auth/api/ws-ticket']
try {
if (typeof location !== 'undefined' && location.protocol === 'dsh-app:') {
const transport = getDesktopTransportBase()
// Absolute only as async-fetch fallback (never sync XHR — CORS).
if (transport) urls.push(new URL('/uds-auth/api/ws-ticket', transport).toString())
}
} catch { /* ignore */ }
return urls
}
async function prefetchWsTickets(count = 2) {
const b = readBridge()
if (!b?.empNo || !b?.token) return 0
const need = Math.max(0, Number(count) || 0)
if (need <= 0) return 0
if (_wsTicketPrefetch) {
try { await _wsTicketPrefetch } catch { /* ignore */ }
}
let minted = 0
const run = (async () => {
for (let i = 0; i < need; i++) {
let got = null
for (const ticketUrl of wsTicketRequestUrls()) {
try {
const res = await fetch(ticketUrl, {
method: 'POST',
credentials: 'include',
redirect: 'error',
headers: {
'Content-Type': 'application/json',
'X-UDS-Bridge-EmpNo': b.empNo,
'X-UDS-Bridge-Token': b.token,
...(b.kind ? { 'X-UDS-Bridge-Kind': b.kind } : {}),
},
body: '{}',
})
if (!res.ok) continue
const data = await res.json().catch(() => ({}))
if (data?.ticket) {
got = String(data.ticket)
break
}
} catch {
/* try next URL */
}
}
if (!got) break
_wsTicketCache.push(got)
minted++
}
})()
_wsTicketPrefetch = run.finally(() => {
if (_wsTicketPrefetch === run) _wsTicketPrefetch = null
})
try { await _wsTicketPrefetch } catch { /* ignore */ }
return minted
}
function mintWsTicketSync() {
if (_wsTicketCache.length) {
const ticket = _wsTicketCache.shift()
if (_wsTicketCache.length < 1) {
try { void prefetchWsTickets(2) } catch { /* ignore */ }
}
return ticket
}
const b = readBridge()
if (!b?.empNo || !b?.token) return null
// Last resort: same-origin sync XHR only (unit harness / http Host UI).
// Skip absolute Host URLs here — custom headers require CORS preflight.
try {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/uds-auth/api/ws-ticket', false)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.setRequestHeader('X-UDS-Bridge-EmpNo', b.empNo)
xhr.setRequestHeader('X-UDS-Bridge-Token', b.token)
if (b.kind) xhr.setRequestHeader('X-UDS-Bridge-Kind', b.kind || 'fallback')
xhr.send('{}')
if (xhr.status >= 200 && xhr.status < 300) {
const data = JSON.parse(xhr.responseText || '{}')
if (data.ticket) return String(data.ticket)
}
} catch { /* ignore */ }
try {
console.warn('[uds-auth] WS ticket mint failed — workspace sidebar may stay empty until reconnect')
} catch { /* ignore */ }
return null
}
let _wsAuthRetryTimer = null
let _wsAuthRetryCount = 0
let _wsAuthReconnectShared = null
let _softReconnectTimer = null
let _softReconnectQuietUntil = 0
/**
* Soft mux reconnect with debounce + quiet period.
*
* Desktop boot used to fire two ticketed reconnects (~200ms AuthBadge +
* ~250ms connection-inject). WorkspaceStateStream accepts the anonymous
* empty baseline, then dies terminally when a second CarrierError lands
* before the authenticated baseline is accepted (attempt>1 while
* connection.generation is live). Sidebar stays「未分组」forever even
* though a fresh follow with a ticket returns every workspace.
*/
/**
* Ask every client cache to re-read after the principal changes (login,
* identity attach after reconnect, logout). Debounced: one reset per burst.
*/
let _authResetTimer = null
function scheduleAuthReset(delay = 1500) {
try {
if (_authResetTimer) clearTimeout(_authResetTimer)
_authResetTimer = setTimeout(() => {
_authResetTimer = null
try { window.__udsAuthEmitReset?.() } catch { /* ignore */ }
}, delay)
} catch { /* ignore */ }
}
function softReconnectAuth(opts = {}) {
const force = !!(opts && opts.force)
const now = Date.now()
if (!force && now < _softReconnectQuietUntil) return
if (_softReconnectTimer) {
clearTimeout(_softReconnectTimer)
_softReconnectTimer = null
}
const delay = force ? 0 : 450
_softReconnectTimer = setTimeout(() => {
_softReconnectTimer = null
if (!force && Date.now() < _softReconnectQuietUntil) return
try {
if (typeof window.__udsAuthReconnect === 'function') {
window.__udsAuthReconnect()
} else {
window.location.reload()
return
}
// Keep a quiet window so the authenticated workspace baseline can land.
_softReconnectQuietUntil = Date.now() + 2800
scheduleAuthReset(1500)
} catch {
try { window.location.reload() } catch { /* ignore */ }
}
}, delay)
}
async function reconnectWithWsTicket() {
if (_wsAuthReconnectShared) return _wsAuthReconnectShared
_wsAuthReconnectShared = (async () => {
// One ticket is enough for the coalesced reconnect; spare is filled after quiet.
let minted = 0
try { minted = await prefetchWsTickets(1) } catch { minted = 0 }
// Even with 0 tickets, reconnect — withBridgeQuery falls back to loopback
// bridge query params so workspace follow still gets an identity.
softReconnectAuth()
setTimeout(() => {
try { void prefetchWsTickets(1) } catch { /* ignore */ }
}, 3000)
if (minted <= 0 && readBridge() && _wsAuthRetryCount < 5) {
_wsAuthRetryCount += 1
if (_wsAuthRetryTimer) clearTimeout(_wsAuthRetryTimer)
_wsAuthRetryTimer = setTimeout(() => {
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 800 * _wsAuthRetryCount)
} else if (minted > 0) {
_wsAuthRetryCount = 0
}
})().finally(() => {
_wsAuthReconnectShared = null
})
return _wsAuthReconnectShared
}
/** Append one-time WS ticket (preferred) or loopback bridge query (Desktop fallback). */
function withBridgeQuery(url) {
const b = readBridge()
if (!b) return url
if (!isTrustedHostUrl(url)) return url
try {
const parsed = new URL(String(url), typeof location !== 'undefined' ? location.href : 'http://localhost/')
if (parsed.searchParams.has('udsBridgeToken')) return parsed.toString()
parsed.searchParams.set('udsBridgeEmpNo', b.empNo)
parsed.searchParams.set('udsBridgeToken', b.token)
parsed.searchParams.set('udsBridgeKind', b.kind || 'fallback')
const parsed = resolveTransportUrl(url)
if (!parsed) return url
if (!['ws:', 'wss:'].includes(parsed.protocol) || parsed.pathname !== '/api/remote.mux') return url
if (parsed.searchParams.has('udsWsTicket') || parsed.searchParams.has('udsBridgeToken')) {
return parsed.toString()
}
const ticket = mintWsTicketSync()
if (ticket) {
parsed.searchParams.set('udsWsTicket', ticket)
return parsed.toString()
}
// 0.3.9 Desktop path: ticket mint often fails on dsh-app:// (empty XHR /
// CORS). Loopback Host accepts udsBridge* on upgrade only.
parsed.searchParams.set('udsBridgeEmpNo', String(b.empNo))
parsed.searchParams.set('udsBridgeToken', String(b.token))
if (b.kind) parsed.searchParams.set('udsBridgeKind', String(b.kind))
return parsed.toString()
} catch {
return url
@ -615,11 +911,11 @@ window.__ModuleLoader__.load({
const origFetch = window.fetch.bind(window)
window.fetch = async function udsAuthBridgeFetch(input, init) {
const bHeaders = bridgeAuthHeaders()
if (!bHeaders['X-UDS-Bridge-Token']) {
if (!bHeaders['X-UDS-Bridge-Token'] || !isTrustedHostUrl(input)) {
return origFetch(input, init)
}
const opts = init ? { ...init } : {}
const prev = opts.headers
const prev = opts.headers || (typeof input === 'object' ? input.headers : null)
if (prev && typeof prev.forEach === 'function') {
const h = { ...bHeaders }
prev.forEach((v, k) => { h[k] = v })
@ -628,6 +924,9 @@ window.__ModuleLoader__.load({
opts.headers = { ...bHeaders, ...(prev || {}) }
}
if (opts.credentials == null) opts.credentials = 'include'
// Custom bridge headers are not stripped like Authorization on redirects.
// Refuse redirects while attaching a credential to the owned Host.
opts.redirect = 'error'
return origFetch(input, opts)
}
} catch { /* ignore */ }
@ -706,6 +1005,7 @@ window.__ModuleLoader__.load({
if (root.getAttribute('data-uds-logged-in') !== '1') return false
// Must be explicit 0 — missing/default must not force flat for supers.
if (root.getAttribute('data-uds-can-create-ws') !== '0') return false
try { root.setAttribute('data-uds-session-only', '1') } catch { /* ignore */ }
const state = readViewState()
if (state.groupBy === 'flat') {
try { window.localStorage.setItem(FORCED_FLAT_KEY, '1') } catch { /* ignore */ }
@ -727,6 +1027,7 @@ window.__ModuleLoader__.load({
if (root.getAttribute('data-uds-auth-ready') !== '1') return false
if (root.getAttribute('data-uds-logged-in') !== '1') return false
if (root.getAttribute('data-uds-can-create-ws') !== '1') return false
try { root.setAttribute('data-uds-session-only', '0') } catch { /* ignore */ }
const state = readViewState()
if (state.groupBy === 'workspace') {
try { window.localStorage.removeItem(FORCED_FLAT_KEY) } catch { /* ignore */ }
@ -753,19 +1054,16 @@ window.__ModuleLoader__.load({
}
function reloadAfterLogin() {
// Login Set-Cookie must land before WS upgrade — hard reload is required.
try { ensureFlatSessionSidebar() } catch { /* ignore */ }
try { window.location.reload() } catch { /* ignore */ }
}
/** Soft WS reconnect (logout / auth flip). Prefer this over full reload. */
function softReconnectAuth() {
// Login Set-Cookie / bridge must land before WS upgrade — hard reload is required.
// Admin-class (incl. sealed_box) must restore workspace grouping; only
// non-creators are forced into the flat session list.
try {
if (typeof window.__udsAuthReconnect === 'function') {
window.__udsAuthReconnect()
return
if (document.documentElement.getAttribute('data-uds-can-create-ws') === '1') {
ensureWorkspaceGroupedSidebar()
} else {
ensureFlatSessionSidebar()
}
} catch { /* fall through */ }
} catch { /* ignore */ }
try { window.location.reload() } catch { /* ignore */ }
}
@ -1064,7 +1362,7 @@ function reloadAfterLogin() {
},
} : prev)
setViewAllMsg(res.message || (enabled ? t('ui.viewAllSessionsOn') : t('ui.viewAllSessionsOff')))
try { softReconnectAuth() } catch { /* ignore */ }
try { softReconnectAuth({ force: true }) } catch { /* ignore */ }
} catch (err) {
setViewAllMsg(apiMessage(err) || t('ui.saveFailed'))
} finally {
@ -1245,10 +1543,18 @@ function reloadAfterLogin() {
document.documentElement.setAttribute('data-uds-logged-in', user ? '1' : '0')
document.documentElement.setAttribute('data-uds-can-settings', canSettings ? '1' : '0')
document.documentElement.setAttribute('data-uds-can-create-ws', canCreateWs ? '1' : '0')
// Admin-class must not keep session-only CSS (would hide named workspaces).
if (canCreateWs) {
document.documentElement.setAttribute('data-uds-session-only', '0')
} else if (user) {
document.documentElement.setAttribute('data-uds-session-only', '1')
} else {
document.documentElement.setAttribute('data-uds-session-only', '0')
}
// Drop stale remote.mux identity after logout so workspace names disappear.
if (prev === '1' && !user) {
clearSessionIfAnonymous(window.__udsAuthSessions)
try { softReconnectAuth() } catch { /* ignore */ }
try { softReconnectAuth({ force: true }) } catch { /* ignore */ }
}
// Do not reset auth attrs in the effect cleanup: React remount/strict-mode
// would briefly look logged-out and trigger flat↔workspace reload loops.
@ -1259,6 +1565,7 @@ function reloadAfterLogin() {
if (qrRef.current.timeout) { clearTimeout(qrRef.current.timeout); qrRef.current.timeout = null }
qrRef.current.key = null
qrRef.current.value = null
qrRef.current.binding = null
qrRef.current.deadline = 0
}, [])
@ -1281,6 +1588,8 @@ function reloadAfterLogin() {
try {
const me = await fetchJson('/uds-auth/api/me')
if (!me?.authenticated || !me?.data) {
try { window.__udsAuthWsAuthedOnce = false } catch { /* ignore */ }
try { window.__udsAuthResetEmp = null } catch { /* ignore */ }
setUser(null)
window.dispatchEvent(new Event('uds-auth-changed'))
return false
@ -1300,6 +1609,24 @@ function reloadAfterLogin() {
permissions,
})
window.dispatchEvent(new Event('uds-auth-changed'))
try {
const resetEmp = String(d.empNo || d.userId || '')
if (resetEmp && window.__udsAuthResetEmp !== resetEmp) {
window.__udsAuthResetEmp = resetEmp
scheduleAuthReset(1500)
}
} catch { /* ignore */ }
// Desktop: first mux open often races ahead of bridge/ticket. Prefetch
// then one coalesced soft-reconnect — never on every /api/me refresh.
try {
if (readBridge() && !window.__udsAuthWsAuthedOnce) {
_wsAuthRetryCount = 0
window.__udsAuthWsAuthedOnce = true
setTimeout(() => {
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 200)
}
} catch { /* ignore */ }
return true
} catch {
setUser(null)
@ -1334,9 +1661,10 @@ function reloadAfterLogin() {
setQrImg('')
return
}
const { qrCodeStr, qrCodeKey, qrCodeValue, loginSystemCode, originSystemCode } = started
const { qrCodeStr, qrCodeKey, qrCodeValue, browserBinding, loginSystemCode, originSystemCode } = started
qrRef.current.key = qrCodeKey
qrRef.current.value = qrCodeValue
qrRef.current.binding = browserBinding
qrRef.current.deadline = Date.now() + QR_TIMEOUT_MS
setQrImg('/uds-auth/qr?data=' + encodeURIComponent(qrCodeStr))
setQrStatus(t('ui.qrScanPrompt'))
@ -1345,81 +1673,39 @@ function reloadAfterLogin() {
markQrExpired()
}, QR_TIMEOUT_MS)
qrRef.current.timer = setInterval(async () => {
if (!qrRef.current.key) return
if (!qrRef.current.key || qrRef.current.polling) return
if (qrRef.current.deadline && Date.now() >= qrRef.current.deadline) {
markQrExpired()
return
}
try {
const verify = await fetchJson(
'/uds-auth/verify-code?qrCodeKey=' + encodeURIComponent(qrRef.current.key)
+ '&qrCodeValue=' + encodeURIComponent(qrRef.current.value)
+ '&loginClientIp=' + encodeURIComponent('127.0.0.1')
+ '&loginSystemCode=' + encodeURIComponent(loginSystemCode || config.loginSystemCode)
+ '&originSystemCode=' + encodeURIComponent(originSystemCode || config.originSystemCode || ''),
)
qrRef.current.polling = true
const result = await fetchJson('/uds-auth/qr-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
qrCodeKey: qrRef.current.key,
qrCodeValue: qrRef.current.value,
loginClientIp: '127.0.0.1',
originSystemCode: originSystemCode || config.originSystemCode || '',
loginSystemCode: loginSystemCode || config.loginSystemCode,
verifyCode: verify.verifyCode,
}),
headers: { 'Content-Type': 'application/json', 'X-UDS-QR-Binding': browserBinding },
body: JSON.stringify({ qrCodeKey, qrCodeValue }),
})
if (qrRef.current.key !== qrCodeKey) return
const codeCode = result.code?.code || ''
const boCode = result.bo?.code || ''
if (codeCode === '0000' && boCode === '0000') {
stopQr()
setQrExpired(false)
setQrFailed(false)
const other = result.other || {}
const empNo = other.account || other.empNo || ''
const token = other.token || other.authValue || ''
if (empNo && token) {
// Cookie path (http/web). Desktop dsh-app:// ignores these — bridge/bind below.
setCookie('PORTALSSOUser', empNo, 7)
setCookie('PORTALSSOCookie', token, 7)
try {
const info = await fetchJson(
'/uds-auth/user-info?empNo=' + encodeURIComponent(empNo)
+ '&token=' + encodeURIComponent(token),
)
const ic = info?.code?.code ?? info?.code
const list = Array.isArray(info?.bo) ? info.bo
: Array.isArray(info?.bo?.rows) ? info.bo.rows
: Array.isArray(info?.bo?.list) ? info.bo.list
: []
if ((ic !== '0000' && ic !== 0 && ic !== '0') || !list.length) {
setQrStatus(t('ui.userInfoFailed'))
console.warn('[uds-auth] user-info after QR failed', info)
return
}
} catch (err) {
setQrStatus(t('ui.userInfoFailed') + ': ' + (err.message || err))
return
}
try {
const bound = await fetchJson('/uds-auth/api/bridge/bind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ empNo, token }),
})
adoptAuthSession(bound)
} catch (err) {
console.warn('[uds-auth] bridge/bind after QR failed', err)
// http hosts may still have cookies; Desktop will stay logged out without bridge.
}
setQrStatus(t('ui.loginSuccess'))
await refreshUser()
setOpen(false)
reconnectAfterLogin()
} else {
setQrStatus(t('ui.missingToken'))
const auth = result.auth
if (!auth?.success || !auth?.bridge?.token) {
setQrStatus(t('ui.loginFailed'))
return
}
adoptAuthSession(auth)
setQrStatus(t('ui.loginSuccess'))
await refreshUser()
if (document.documentElement.getAttribute('data-uds-logged-in') !== '1') {
setQrStatus(t('ui.loginFailed'))
return
}
setOpen(false)
reconnectAfterLogin()
return
}
if (codeCode === '0000' && boCode === '4002') {
@ -1435,7 +1721,9 @@ function reloadAfterLogin() {
setQrFailed(false)
setQrStatus(result.bo?.msg || boCode || t('ui.loginFailed'))
} catch {
setQrStatus(t('ui.networkError'))
if (qrRef.current.key === qrCodeKey) setQrStatus(t('ui.networkError'))
} finally {
qrRef.current.polling = false
}
}, 2000)
} catch (err) {
@ -1508,6 +1796,9 @@ function reloadAfterLogin() {
fetchJson('/uds-auth/api/fallback/status')
.then((st) => { if (!cancelled) setFallbackEnabled(!!st.enabled) })
.catch(() => {})
fetchJson('/uds-auth/api/local-admin/status')
.then((st) => { if (!cancelled) setLocalKeyEnabled(!!st.enabled) })
.catch(() => { if (!cancelled) setLocalKeyEnabled(false) })
return () => { cancelled = true }
}, [open, user])
@ -1613,12 +1904,17 @@ function reloadAfterLogin() {
),
!qrOverlay && h('div', { className: 'uds-auth-qr-status' }, qrStatus || t('ui.loading')),
),
// Always offer emergency login when QR failed/expired; otherwise only if enabled.
(fallbackEnabled || qrFailed || qrExpired) && h('button', {
// U01: emergency password always discoverable — disabled state is shown, not hidden.
// Local sealed-box unlock stays hidden unless Host actually has a box configured.
h('button', {
type: 'button',
className: 'uds-auth-btn-link',
onClick: () => { stopQr(); setLoginMode('fallback'); setFbErr('') },
}, t('ui.fallbackLink')),
onClick: () => {
stopQr()
setLoginMode('fallback')
setFbErr(fallbackEnabled ? '' : t('ui.fallbackDisabledHint'))
},
}, fallbackEnabled ? t('ui.fallbackLink') : t('ui.fallbackDisabledLink')),
localKeyEnabled && h('button', {
type: 'button',
className: 'uds-auth-btn-link',
@ -1659,7 +1955,8 @@ function reloadAfterLogin() {
: h(React.Fragment, null,
h('div', { className: 'uds-auth-info' },
h('div', { className: 'uds-auth-info-name' }, t('ui.fallbackLogin')),
h('div', { className: 'uds-auth-info-detail' }, t('ui.fallbackDetail')),
h('div', { className: 'uds-auth-info-detail' },
fallbackEnabled ? t('ui.fallbackDetail') : t('ui.fallbackDisabledDetail')),
),
h('div', { className: 'uds-auth-fallback' },
h('label', { htmlFor: 'uds-fb-user' }, t('ui.username')),
@ -1668,6 +1965,7 @@ function reloadAfterLogin() {
value: fbUser,
onChange: (e) => setFbUser(e.target.value),
autoComplete: 'username',
disabled: !fallbackEnabled,
}),
h('label', { htmlFor: 'uds-fb-pass' }, t('ui.password')),
h('input', {
@ -1676,14 +1974,15 @@ function reloadAfterLogin() {
value: fbPass,
onChange: (e) => setFbPass(e.target.value),
autoComplete: 'current-password',
onKeyDown: (e) => { if (e.key === 'Enter') submitFallback() },
disabled: !fallbackEnabled,
onKeyDown: (e) => { if (e.key === 'Enter' && fallbackEnabled) submitFallback() },
}),
fbErr && h('div', { className: 'uds-auth-settings-msg err' }, fbErr),
h('button', {
type: 'button',
className: 'uds-auth-btn uds-auth-btn-primary',
style: { width: '100%', margin: '12px 0 0' },
disabled: fbBusy,
disabled: fbBusy || !fallbackEnabled,
onClick: submitFallback,
}, fbBusy ? t('ui.loggingIn') : t('ui.login')),
),
@ -1706,6 +2005,8 @@ function reloadAfterLogin() {
try { await fetchJson('/uds-auth/api/logout', { method: 'POST' }) } catch { /* ignore */ }
clearAuthCookies()
clearBridge()
try { window.__udsAuthWsAuthedOnce = false } catch { /* ignore */ }
try { window.__udsAuthResetEmp = null } catch { /* ignore */ }
setUser(null)
setOpen(false)
document.documentElement.setAttribute('data-uds-logged-in', '0')
@ -1713,7 +2014,7 @@ function reloadAfterLogin() {
document.documentElement.setAttribute('data-uds-can-create-ws', '0')
clearSessionIfAnonymous(window.__udsAuthSessions)
window.dispatchEvent(new Event('uds-auth-changed'))
try { softReconnectAuth() } catch { /* ignore */ }
try { softReconnectAuth({ force: true }) } catch { /* ignore */ }
},
}, t('ui.logout')),
),
@ -1785,17 +2086,24 @@ function reloadAfterLogin() {
window.__udsAuthReconnect = () => {
try { cctx.connection.reconnect() } catch { window.location.reload() }
}
// Desktop ownsHost: mux WS often connects before this client patches
// WebSocket. If a bridge already exists, reconnect once so upgrade
// carries ?udsBridge* (history/follow ACL depends on it).
// connection.reconnect() only re-opens streams; it does not emit
// connection/reset, so boot-time caches (settings describe mirror →
// Settings › Models "key missing") keep the anonymous projection.
window.__udsAuthEmitReset = () => {
try { cctx.emit('connection/reset') } catch { /* ignore */ }
}
// Desktop ownsHost: mux WS often connects before tickets are cached.
// Claim the once-flag so AuthBadge refreshUser does not fire a second
// ticketed reconnect (that kills WorkspaceStateStream — see softReconnectAuth).
if (
readBridge()
&& window.__DSH_TRANSPORT__?.ownsHost
&& !window.__udsAuthBridgeMuxPrimed
) {
window.__udsAuthBridgeMuxPrimed = true
window.__udsAuthWsAuthedOnce = true
setTimeout(() => {
try { cctx.connection.reconnect() } catch { /* ignore */ }
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 250)
}
})

View file

@ -24,6 +24,11 @@ const DEFAULT_CONFIG = {
retainSkillCredentialsOnLogout: true,
skillCredentialTtlSeconds: 7 * 24 * 60 * 60,
outboundAllowedHosts: 'icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn',
initialAdminEmpNo: '',
allowOpenRegistration: true,
allowRawAgentToken: false,
/** Empty = loopback Host only; reverse proxies must set explicit authorities. */
trustedHosts: '',
}
// 内部常量(不暴露)
@ -56,5 +61,13 @@ export function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) {
export function validateConfig(config) {
if (!config?.uacBaseUrl) throw new Error('uacBaseUrl is required')
const checkHttps = (v, name) => {
if (v == null || v === '') return
let u
try { u = new URL(String(v)) } catch { throw new Error(`invalid_${name}`) }
if (u.protocol !== 'https:') throw new Error(`${name}_must_be_https`)
}
checkHttps(config.uacBaseUrl, 'uacBaseUrl')
checkHttps(config.userSearchUrl, 'userSearchUrl')
return true
}

View file

@ -0,0 +1,69 @@
// Compatibility with the unmodified DSH Desktop welcome RPC sequence. These
// projections contain no account attempt, real credential reference, or config.
export const DESKTOP_BOOTSTRAP_REF = 'UDS_AUTH_DESKTOP_PROVIDER_CONFIGURED'
const namespace = 'uds-auth-desktop-bootstrap'
const endpoints = new Set(['settings/describe', 'llm/listConfigurableProviders', 'credentials/describe', 'account/getState'])
export function isDesktopBootstrapEndpoint(descriptor) {
return !descriptor.mode && endpoints.has(`${descriptor.namespace}/${descriptor.method}`)
}
function view(ns, value) {
return { ns, value, autoGenerate: false, schema: { type: 'object', dict: {} },
applies: 'live', secrets: [], revision: 0 }
}
/** Read only Host-owned metadata; request arguments can never select a secret. */
export function createDesktopBootstrap({ getService, deny }) {
const settings = () => getService('settings')?.describe?.({ redactSecrets: true }) || []
// Every read is best-effort: one failing provider or reference must not fail the
// whole welcome read (Desktop treats a failed read as "no API key").
const configured = async () => {
let rows = []
try { rows = settings() } catch { rows = [] }
const refs = new Set()
const official = rows.find(row => row.ns === 'llm-deepseek')?.value?.apiKeyEnv
if (typeof official === 'string') refs.add(official)
let providers = []
try { providers = getService('llm')?.listConfigurableProviders?.() || [] } catch { providers = [] }
for (const provider of providers) {
let value = rows.find(row => row.ns === provider?.settingsNs)?.value
for (const key of provider?.settingsPath || []) value = value && Object.hasOwn(value, key) ? value[key] : undefined
if (typeof value?.apiKeyEnv === 'string') refs.add(value.apiKeyEnv)
}
const credentials = getService('credentials')
for (const ref of refs) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) continue
try {
if ((await credentials?.describe?.(ref))?.configured === true) return true
} catch { /* try the next reference */ }
}
return false
}
return async request => {
const endpoint = `${request.namespace}/${request.method}`
switch (endpoint) {
case 'settings/describe': {
const preference = settings().find(row => row.ns === 'locale')?.value?.preference
return { writable: false, hasDocument: false, namespaces: [
view('locale', { ...(typeof preference === 'string' && /^[A-Za-z-]{2,16}$/.test(preference) ? { preference } : {}) }),
view(namespace, { apiKeyEnv: DESKTOP_BOOTSTRAP_REF }),
] }
}
case 'llm/listConfigurableProviders':
return [{ provider: namespace, displayName: 'Host provider', settingsNs: namespace, settingsPath: [] }]
case 'credentials/describe': {
const refs = request.args?.refs
if (!Array.isArray(refs) || refs.length > 1 || refs.some(ref => ref !== DESKTOP_BOOTSTRAP_REF)) deny('forbidden_settings')
return refs.length ? { [DESKTOP_BOOTSTRAP_REF]: { configured: await configured(), writable: false } } : {}
}
case 'account/getState': {
let state
try { state = await getService('deepseekAccount')?.getState?.() } catch { state = undefined }
return { status: state?.status === 'credential-stored' ? 'credential-stored' : 'signed-out', attempt: null,
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }
}
default: return deny('login_required')
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,55 @@
const catalogEvents = new Set(['commands/change', 'llm/adapters-updated', 'permission-presets/catalog-changed'])
/** The Gateway's $events carrier bypasses Remote method dispatch. Fence it too. */
export function installGatewayEvents(gateway, live) {
if (typeof gateway.openRemoteEvents !== 'function' || typeof gateway.receiveRemoteEventResult !== 'function') {
throw new Error('unsupported_gateway_event_contract')
}
const owners = new Map()
const originalOpen = gateway.openRemoteEvents.bind(gateway)
const originalResult = gateway.receiveRemoteEventResult.bind(gateway)
const allowedSession = sid => {
if (typeof sid !== 'string' || !sid) return false
try { live.assertSession(sid); return true } catch { return false }
}
gateway.openRemoteEvents = async function* (...args) {
const principal = live.identity()
if (principal === undefined) { yield* originalOpen(...args); return }
live.assertPrincipal(principal)
let clientId
const delivered = new Set()
try {
for await (const frame of originalOpen(...args)) {
live.assertPrincipal(principal)
if (frame.type === 'ready') {
clientId = frame.clientId
owners.set(clientId, { empNo: principal.empNo, sessionId: principal.sessionId })
yield { ...frame, host: live.permissions(principal).canAccessSettings ? frame.host : { home: '' } }
} else if (frame.type === 'waterfall') {
if (allowedSession(frame.agentId)) { delivered.add(frame.eventId); yield frame }
} else if (frame.type === 'cancel') {
if (delivered.delete(frame.eventId)) yield frame
} else if (frame.type === 'emit') {
const first = frame.args?.[0]
let sid
if (frame.event === 'api-session/added') sid = first?.id ?? first?.sessionId
else if (['api-session/activity', 'api-session/error', 'api-session/removed', 'api-session/status', 'agent-preset/selected'].includes(frame.event)) sid = first
else if (frame.event === 'goal/activation-changed') sid = first?.sessionId
if (sid !== undefined ? allowedSession(sid)
: catalogEvents.has(frame.event) || live.permissions(principal).canAccessSettings) yield frame
}
}
} finally { if (clientId) owners.delete(clientId) }
}
gateway.receiveRemoteEventResult = function(client, result, ...rest) {
const principal = live.identity()
if (principal !== undefined) {
live.assertPrincipal(principal)
const owner = owners.get(result.clientId)
if (!owner || owner.empNo !== principal.empNo || owner.sessionId !== principal.sessionId) live.deny('event_client_forbidden')
const pending = gateway.pendingRemoteEvents?.get(result.eventId)
if (pending && !allowedSession(pending.frame?.agentId)) live.deny('session_forbidden')
}
return originalResult(client, result, ...rest)
}
}

View file

@ -0,0 +1,108 @@
import { installGatewayEvents } from './gateway-events.js'
import { isDesktopBootstrapEndpoint } from './desktop-bootstrap.js'
// Endpoints whose resource boundaries/projections are owned by dsh-acl wrappers.
const delegated = new Set([
'session/list', 'session/search', 'session/create', 'session/selectModel', 'session/rename',
'session/fork', 'session/prompt', 'session/attachment', 'session/updateQueue', 'session/cancel',
'session/page', 'session/follow', 'session/projections', 'session/control', 'session/openWorkspacePath',
'workspace/follow', 'workspace/insertSessionBefore', 'workspace/archiveSession', 'workspace/unarchiveSession',
'workspace/pinSession', 'workspace/unpinSession', 'job/list', 'job/follow', 'job/kill',
'terminal/list', 'terminal/create', 'terminal/follow', 'terminal/retain', 'terminal/write',
'terminal/resize', 'terminal/close',
])
const catalogs = new Set(['session/modelCatalog', 'session/canOpenWorkspacePath', 'permissionPresets/catalog'])
// Anonymous mux must get dsh-acl's empty baseline, not a gateway error: a failed
// opening snapshot kills Desktop WorkspaceStateStream for good (sidebar never repaints
// after the authenticated reconnect).
const anonymousProjected = new Set(['workspace/follow'])
// That empty baseline makes Desktop try to create a default Workspace. Answer the
// anonymous attempt with "nothing created" (a legal result) instead of a denial,
// which surfaces as「无法创建默认工作区」; the real controller is never invoked.
const anonymousNoop = new Set(['workspace/initializeDefault'])
export function gatewayPolicy(descriptor) {
const endpoint = `${descriptor.namespace}/${descriptor.method}`
if (isDesktopBootstrapEndpoint(descriptor)) return 'desktop-bootstrap-projection'
if (catalogs.has(endpoint)) return 'authenticated-catalog'
if (delegated.has(endpoint)) return 'delegated-resource-acl'
if (descriptor.invocation?.kind === 'context' && descriptor.invocation.context === 'agent') return 'session-bound'
if ((descriptor.parameters || []).some(p => p.name === 'sessionId'
|| (p.source === 'lookup' && ['agent', 'workspaceFileScope'].includes(p.name)))) return 'session-bound'
return 'admin-required'
}
/** Guard every unary/stream dispatch, including new or previously unwrapped Remote namespaces. */
export function installGatewayPolicy(gateway, { identity, assertPrincipal, assertSession, permissions, deny, desktopBootstrap }) {
assertGatewayContract(gateway)
const live = gateway.__udsGatewayPolicy || (gateway.__udsGatewayPolicy = {})
Object.assign(live, { identity, assertPrincipal, assertSession, permissions, deny, desktopBootstrap })
if (live.installed) return
installGatewayEvents(gateway, live)
live.installed = true
const original = gateway.prepareInvocation.bind(gateway)
const check = (request, descriptor) => {
const principal = live.identity()
if (principal === undefined) return
const anonymous = !(principal?.empNo || principal?.userContext?.empNo)
const endpoint = `${descriptor.namespace}/${descriptor.method}`
if (anonymous && anonymousProjected.has(endpoint)) return
if (anonymous && anonymousNoop.has(endpoint) && descriptor.mode === undefined) return 'noop'
// Desktop opens its streams (account/watch, session lists, …) before the mux is
// re-authenticated. A RemoteError is terminal for the client's $stream, so the
// surface stays failed forever (e.g. account →「登录失败」). Park anonymous
// streams with no data until the authenticated reconnect closes this carrier;
// the client then reopens them under the real principal.
if (anonymous && descriptor.mode !== undefined) return 'hold'
if (isDesktopBootstrapEndpoint(descriptor) && typeof live.desktopBootstrap === 'function') {
if (principal !== null) live.assertPrincipal(principal)
if (!principal || !live.permissions(principal).canAccessSettings) return 'bootstrap'
}
live.assertPrincipal(principal)
const policy = gatewayPolicy(descriptor)
if (['admin-required', 'desktop-bootstrap-projection'].includes(policy)
&& !live.permissions(principal).canAccessSettings) live.deny('forbidden_settings')
if (policy === 'session-bound') {
const wires = []
if (descriptor.invocation?.kind === 'context') wires.push(descriptor.invocation.wire)
for (const p of descriptor.parameters || []) {
if (p.name === 'sessionId' || (p.source === 'lookup' && ['agent', 'workspaceFileScope'].includes(p.name))) wires.push(p.wire)
}
for (const wire of wires) {
const sid = request.args?.[wire]
if (typeof sid !== 'string' || !sid) live.deny('session_forbidden')
live.assertSession(sid)
}
}
}
gateway.prepareInvocation = async function(request, ...rest) {
const descriptor = gateway.resolveDescriptor(request.namespace, request.method, `${request.namespace}/${request.method}`)
check(request, descriptor)
const prepared = await original(request, ...rest)
const method = prepared.method
prepared.method = function(...args) {
const verdict = check(request, descriptor)
if (verdict === 'noop') return undefined
if (verdict === 'hold') return holdUntilAbort(prepared.invocation?.signal)
if (verdict === 'bootstrap') return live.desktopBootstrap(request)
const value = method.apply(this, args)
if (descriptor.mode === undefined) return value
return (async function* () {
const stream = await value
for await (const frame of stream) { check(request, descriptor); yield frame }
})()
}
return prepared
}
}
/** Empty stream that ends only when its carrier is aborted (no frames, no error). */
async function* holdUntilAbort(signal) {
if (!signal || signal.aborted) return
await new Promise((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }))
}
export function assertGatewayContract(gateway) {
for (const method of ['prepareInvocation', 'resolveDescriptor', 'openRemoteEvents', 'receiveRemoteEventResult']) {
if (typeof gateway?.[method] !== 'function') throw new Error('unsupported_gateway_acl_contract')
}
}

View file

@ -26,7 +26,7 @@ export const MESSAGES = {
'ui.settingsTitle': 'UAC 认证',
'ui.settingsIntro': '工号+token 双校验;UAC 挂死时用应急账号 administrator 密码登录。',
'ui.loginRequiredPage': '请先登录后查看此页',
'ui.roleHint': '当前角色:{role}。超管只是身份标签,与管理员权限相同;首位扫码登录且 roles.json 为空时自动获得超管身份,也可在「用户管理」设为管理员。应急账号 administrator 需先设密码,再在登录面板用账密登录。',
'ui.roleHint': '当前角色:{role}。超管只是身份标签,与管理员权限相同;显式配置的初始管理员在首次登录时获得超管身份,也可在「用户管理」设为管理员。应急账号 administrator 需先设密码,再在登录面板用账密登录。',
'ui.deployConfig': '部署配置',
'ui.userSearchUrl': '用户搜索 URL(token 校验)',
'ui.workspaceRoot': '工作区根目录(空=$DSH_HOME/user-workspaces)',
@ -38,7 +38,7 @@ export const MESSAGES = {
'ui.fallbackStatus': '状态:{status}。可在此改密或关闭。仅在扫码不可用时从登录面板切换。',
'ui.enabled': '已启用',
'ui.disabled': '未启用',
'ui.fallbackPassword': '应急密码(至少 6 位)',
'ui.fallbackPassword': '应急密码(至少 10 位)',
'ui.saveFallbackPassword': '保存应急密码',
'ui.fallbackPasswordSet': '应急密码已设置',
'ui.confirmClearFallback': '确认清除应急密码?',
@ -121,20 +121,50 @@ export const MESSAGES = {
'err.invalid_credentials': '用户名或密码错误',
'err.last_super_admin_demote': '系统至少需要 1 个超级管理员,不能降级最后一个',
'err.last_super_admin_delete': '系统至少需要 1 个超级管理员,不能删除最后一个',
'err.password_too_short': '密码至少 6 位',
'err.password_too_short': '密码至少 10 位',
'err.password_too_common': '不能使用已知默认口令',
'err.account_disabled': '账号已停用',
'err.qr_unknown': '二维码无效或已过期',
'err.qr_replay': '二维码已使用',
'err.qr_expired': '二维码已过期',
'err.qr_mismatch': '二维码校验失败',
'err.upstream_too_large': '上游响应过大',
'err.config_not_ready': '配置未初始化',
'err.request_failed': '请求失败',
'err.method_not_allowed': '方法不允许',
'err.missing_qr_params': '缺少 qrCodeKey 或 qrCodeValue',
'err.missing_emp_token': '缺少 empNo 或 token',
'err.token_in_query_forbidden': '禁止在 URL 传递 token,请改用 POST body 或请求头',
'err.user_search_failed': '用户搜索失败',
'err.not_found': '未找到',
'err.internal': '内部错误',
'err.login_required_cron': '登录后才能使用定时任务',
'err.login_required_session': '登录后才能访问会话',
'err.login_required_create_session': '登录后才能创建会话',
'err.login_required_create_workspace': '登录后才能创建工作区',
'err.login_required_workspace': '登录后才能使用工作区',
'err.workspace_provision_failed': '个人工作区初始化失败,请重试',
'err.plugin_unready': '认证插件未就绪',
'err.origin_forbidden': '请求来源不被允许',
'err.content_type': 'Content-Type 必须为 application/json',
'err.https_required': '出站代理仅允许 HTTPS',
'err.invalid_config': '配置无效',
'err.unknown_config_field': '未知配置字段',
'err.invalid_uac_url': 'uacBaseUrl 必须是 https URL',
'err.invalid_user_search_url': 'userSearchUrl 必须是 https URL',
'err.invalid_workspace_root': 'workspaceRoot 必须是绝对路径',
'err.invalid_ttl': 'TTL 超出允许范围',
'err.invalid_outbound_host': 'outbound 白名单 host 格式无效',
'err.bootstrap_required': '系统尚未初始化管理员,请配置 UDS_AUTH_INITIAL_ADMIN',
'err.registration_closed': '未开放自动注册,请联系管理员',
'err.raw_token_disabled': '原始凭证接口已关闭,请使用 outbound 代理',
'err.caller_unauthenticated': '调用方未通过本地会话认证',
'err.session_id_required': '需要 DSH sessionId',
'err.session_owner_mismatch': '会话归属与调用方不一致',
'err.session_forbidden': '无权访问该会话',
'err.session_revoked': '会话已失效,请重新登录',
'err.fallback_disabled': '应急账号已关闭',
'err.file_forbidden': '无权访问该文件',
'err.session_workspace_only': '只能在自己的工作区创建会话',
'err.workspace_path_only': '只能打开自己的工作区路径',
'err.workspace_create_forbidden': '只有管理员可以创建工作区',
@ -152,6 +182,8 @@ export const MESSAGES = {
'err.key_required': '请输入解密密钥',
'err.decrypt_failed': '密钥无法解密,登录失败',
'err.rate_limited': '尝试过多,请稍后再试',
'err.payload_too_large': '请求体过大',
'err.invalid_json': 'JSON 无效',
// API success
'ok.logged_out': '已退出登录',
@ -179,7 +211,7 @@ export const MESSAGES = {
'ui.settingsTitle': 'UAC Auth',
'ui.settingsIntro': 'EmpNo + token verification; when UAC is down, sign in with emergency account administrator.',
'ui.loginRequiredPage': 'Sign in to view this page',
'ui.roleHint': 'Current role: {role}. Super admin is only an identity label with the same permissions as admin; the first QR login with an empty roles.json gets that identity, or grant admin in User management. Set the emergency password before using administrator on the login panel.',
'ui.roleHint': 'Current role: {role}. Super admin is only an identity label with the same permissions as admin; the explicitly configured initial administrator receives that identity on first login, or grant admin in User management. Set the emergency password before using administrator on the login panel.',
'ui.deployConfig': 'Deploy config',
'ui.userSearchUrl': 'User search URL (token verify)',
'ui.workspaceRoot': 'Workspace root (empty=$DSH_HOME/user-workspaces)',
@ -191,7 +223,7 @@ export const MESSAGES = {
'ui.fallbackStatus': 'Status: {status}. Change or disable here. Switch from the login panel only when QR is unavailable.',
'ui.enabled': 'Enabled',
'ui.disabled': 'Disabled',
'ui.fallbackPassword': 'Emergency password (min 6 chars)',
'ui.fallbackPassword': 'Emergency password (min 10 chars)',
'ui.saveFallbackPassword': 'Save emergency password',
'ui.fallbackPasswordSet': 'Emergency password set',
'ui.confirmClearFallback': 'Clear emergency password?',
@ -269,20 +301,50 @@ export const MESSAGES = {
'err.invalid_credentials': 'Invalid username or password',
'err.last_super_admin_demote': 'At least one super admin is required; cannot demote the last one',
'err.last_super_admin_delete': 'At least one super admin is required; cannot delete the last one',
'err.password_too_short': 'Password must be at least 6 characters',
'err.password_too_short': 'Password must be at least 10 characters',
'err.password_too_common': 'Cannot use the known default password',
'err.account_disabled': 'Account is disabled',
'err.qr_unknown': 'QR challenge unknown or expired',
'err.qr_replay': 'QR challenge already used',
'err.qr_expired': 'QR challenge expired',
'err.qr_mismatch': 'QR challenge mismatch',
'err.upstream_too_large': 'Upstream response too large',
'err.config_not_ready': 'Config not initialized',
'err.request_failed': 'Request failed',
'err.method_not_allowed': 'Method not allowed',
'err.missing_qr_params': 'Missing qrCodeKey or qrCodeValue',
'err.missing_emp_token': 'Missing empNo or token',
'err.token_in_query_forbidden': 'Do not put token in the URL; use POST body or headers',
'err.user_search_failed': 'User search failed',
'err.not_found': 'Not found',
'err.internal': 'Internal error',
'err.login_required_cron': 'Sign in to use scheduled tasks',
'err.login_required_session': 'Sign in to access sessions',
'err.login_required_create_session': 'Sign in to create a session',
'err.login_required_create_workspace': 'Sign in to create a workspace',
'err.login_required_workspace': 'Sign in to use workspaces',
'err.workspace_provision_failed': 'Personal workspace provisioning failed; retry',
'err.plugin_unready': 'Auth plugin not ready',
'err.origin_forbidden': 'Request origin not allowed',
'err.content_type': 'Content-Type must be application/json',
'err.https_required': 'Outbound proxy requires HTTPS',
'err.invalid_config': 'Invalid config',
'err.unknown_config_field': 'Unknown config field',
'err.invalid_uac_url': 'uacBaseUrl must be an https URL',
'err.invalid_user_search_url': 'userSearchUrl must be an https URL',
'err.invalid_workspace_root': 'workspaceRoot must be an absolute path',
'err.invalid_ttl': 'TTL out of allowed range',
'err.invalid_outbound_host': 'Invalid outbound host entry',
'err.bootstrap_required': 'Admin not initialized; set UDS_AUTH_INITIAL_ADMIN',
'err.registration_closed': 'Open registration is disabled; contact an admin',
'err.raw_token_disabled': 'Raw credential API disabled; use outbound proxy',
'err.caller_unauthenticated': 'Caller lacks a verified local session',
'err.session_id_required': 'DSH sessionId required',
'err.session_owner_mismatch': 'Session owner does not match caller',
'err.session_forbidden': 'No access to this session',
'err.session_revoked': 'Session expired; please sign in again',
'err.fallback_disabled': 'Emergency account is disabled',
'err.file_forbidden': 'No access to this file',
'err.session_workspace_only': 'Sessions can only be created in your own workspace',
'err.workspace_path_only': 'You can only open your own workspace path',
'err.workspace_create_forbidden': 'Only admins can create workspaces',
@ -300,6 +362,8 @@ export const MESSAGES = {
'err.key_required': 'Decryption key required',
'err.decrypt_failed': 'Key could not decrypt — sign-in failed',
'err.rate_limited': 'Too many attempts, try later',
'err.payload_too_large': 'Payload too large',
'err.invalid_json': 'Invalid JSON',
'ok.logged_out': 'Signed out',
'ok.config_saved': 'Config saved',

View file

@ -1,22 +1,16 @@
/**
* Last verified browser identity (bridge or cookie).
* Desktop mux follow/history often runs with empty ALS; fall back here after
* a successful /uds-auth HTTP auth or WS upgrade bind.
* Deprecated global last-user cache (SEC-06).
* Kept as no-ops so older imports do not crash; never restores a principal.
*/
/** @type {object|null} */
let lastBrowserIdentity = null
export function rememberBrowserIdentity(identity) {
const emp = identity?.empNo || identity?.userContext?.empNo
if (!emp) return
lastBrowserIdentity = identity
export function rememberBrowserIdentity(_identity) {
/* intentionally no-op — connection-bound identity only */
}
export function clearBrowserIdentity() {
lastBrowserIdentity = null
/* intentionally no-op */
}
export function peekBrowserIdentity() {
return lastBrowserIdentity
return null
}

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,16 @@
/**
* Local admin via sealed box (decrypt-to-unlock).
*
* Env holds only ciphertext:
* UDS_AUTH_LOCAL_ADMIN_BOX=<base64url sealed blob>
* Ciphertext sources (first match wins):
* 1) UDS_AUTH_LOCAL_ADMIN_BOX env
* 2) $UDS_AUTH_DATA_DIR/local-admin.box or ~/.uds-auth/local-admin.box
*
* Operator keeps the passphrase offline. Unlock API tries AES-GCM open;
* success → administrator session. Setting/replacing env alone does not
* log anyone in — the key must decrypt the box.
*
* Generate a box:
* node -e "import('./lib/local-admin.js').then(m => console.log(m.sealLocalAdminBox(process.argv[1])))" -- "your-passphrase"
* node scripts/seal-local-admin.mjs
*/
import {
createCipheriv,
@ -19,15 +20,27 @@ import {
timingSafeEqual,
createHash,
} from 'node:crypto'
import { readFileSync, existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { ROLES, computePermissions, DEFAULT_FALLBACK_USERNAME } from './roles.js'
import { derivePasswordKey } from './utils/password-kdf.js'
export const LOCAL_ADMIN_BOX_ENV = 'UDS_AUTH_LOCAL_ADMIN_BOX'
/** @deprecated presence of KEY no longer grants access; use BOX + unlock */
export const LOCAL_ADMIN_ENV = 'UDS_AUTH_LOCAL_ADMIN_KEY'
export const LOCAL_ADMIN_BOX_FILENAME = 'local-admin.box'
export const LOCAL_ADMIN_COOKIE = 'UDS_LOCAL_ADMIN'
export const LOCAL_ADMIN_COOKIE_MAX_AGE = 7 * 24 * 60 * 60
/** Profile dir used when env box is missing (Desktop Host often lacks User env). */
export function resolveLocalAdminBoxFile(dataDir) {
const root = String(dataDir || process.env.UDS_AUTH_DATA_DIR || '').trim()
|| join(homedir(), '.uds-auth')
return resolve(root, LOCAL_ADMIN_BOX_FILENAME)
}
const MAGIC = 'uds-local-admin-v1'
const SCRYPT_N = 16384
const SCRYPT_R = 8
@ -116,8 +129,42 @@ export function openLocalAdminBox(box, passphrase) {
}
}
function readLocalAdminBoxFile() {
try {
const path = resolveLocalAdminBoxFile()
if (!existsSync(path)) return null
const raw = readFileSync(path, 'utf8')
const line = String(raw || '').split(/\r?\n/).map((l) => l.trim()).find((l) => l && !l.startsWith('#'))
if (!line) return null
// Allow either bare box or KEY=value lines copied from seal CLI output.
const m = line.match(/^(?:export\s+)?UDS_AUTH_LOCAL_ADMIN_BOX=(.+)$/)
return String(m ? m[1] : line).trim().replace(/^["']|["']$/g, '') || null
} catch {
return null
}
}
export function readLocalAdminBox() {
return String(process.env[LOCAL_ADMIN_BOX_ENV] || '').trim() || null
const fromEnv = String(process.env[LOCAL_ADMIN_BOX_ENV] || '').trim()
if (fromEnv) return fromEnv
return readLocalAdminBoxFile()
}
/** Runtime unlock uses the same bounded, asynchronous KDF as password login. */
export async function openLocalAdminBoxAsync(box, passphrase) {
if (!box || typeof passphrase !== 'string' || !passphrase) return { ok: false, reason: 'missing' }
const raw = b64urlDecode(box)
if (raw.length < 46 || raw.length > 256 || raw[0] !== 1) return { ok: false, reason: 'bad_box' }
const key = await derivePasswordKey(passphrase, raw.subarray(1, 17))
try {
const decipher = createDecipheriv('aes-256-gcm', key, raw.subarray(17, 29))
decipher.setAuthTag(raw.subarray(29, 45))
const plain = Buffer.concat([decipher.update(raw.subarray(45)), decipher.final()]).toString('utf8')
return plain === `${MAGIC}|${DEFAULT_FALLBACK_USERNAME}`
? { ok: true, empNo: DEFAULT_FALLBACK_USERNAME } : { ok: false, reason: 'bad_payload' }
} catch {
return { ok: false, reason: 'decrypt_failed' }
}
}
export function isLocalAdminBoxConfigured() {
@ -197,7 +244,7 @@ export function buildLocalAdminIdentity(rolesStore) {
role,
permissions,
userContext: buildLocalAdminUserContext(),
kind: 'fallback',
kind: 'sealed_box',
}
}

View file

@ -2,26 +2,30 @@ import { UdsClient } from '../uds/client.js'
import { UdsValidator } from '../uds/validator.js'
import { ROLES } from '../roles.js'
import { searchUserByEmpNoToken } from '../uds/user-search.js'
import { readBridgeFromRequest } from '../session-bridge.js'
import { rememberBrowserIdentity } from '../identity-cache.js'
import {
extractSessionBearerAsync,
extractPortalCredentials,
buildSessionCookie,
} from '../session/request-auth.js'
import { isLocalAdminBoxConfigured } from '../local-admin.js'
/**
* auth-middleware
*
* 正常登录:Cookie 中必须同时有 empNo + token,并用 userSearchUrl
* (带 X-Emp-No / X-Auth-Value)直连内网拉用户详情;成功才建会话。
* 出站请求绕过 HTTP(S)_PROXY。
* Principal only from verified local session (UDS_SESSION / bridge bearer)
* or a fresh UAC login exchange that mints a new local session.
*
* 兜底登录:仅认可已由 /api/fallback/login 或 /api/local-admin/unlock 写好的
* administrator 会话;重启后若仅有 UDS_FALLBACK_USER cookie,会重建内存会话。
*
* 可选 onSkillCredentials(empNo, token):UI 会话写入成功后并行写入 skill 凭证缓存。
* UDS_FALLBACK_USER / UDS_FALLBACK_UI are display hints only — never auth.
* empNo-keyed profile cache hits never prove authentication.
*/
export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {}) {
const onSkillCredentials = typeof hooks.onSkillCredentials === 'function'
? hooks.onSkillCredentials
: null
const sessionBridge = hooks.sessionBridge || null
const onSessionMinted = typeof hooks.onSessionMinted === 'function'
? hooks.onSessionMinted
: null
const udsClient = new UdsClient(config.udsAuth)
const validatorConfig = {
...config.udsAuth,
@ -29,35 +33,47 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
}
const udsValidator = new UdsValidator(validatorConfig)
const { cookieMaxAge, slidingExpiration } = config.session
const ttlSeconds = Math.floor(cookieMaxAge / 1000)
const userSearchUrl = config.userSearchUrl || config.udsAuth?.userSearchUrl
const uacBaseUrl = config.udsAuth?.baseUrl || ''
function extractEmpNo(cookieHeader) {
const udsMatch = cookieHeader?.match(/(?:PORTALSSOUser|ZTEDPGSSOUser)=([^;]+)/)
if (udsMatch) return { empNo: decodeURIComponent(udsMatch[1].trim()), kind: 'uds' }
const fbMatch = cookieHeader?.match(/UDS_FALLBACK_USER=([^;]+)/)
if (fbMatch) return { empNo: decodeURIComponent(fbMatch[1].trim()), kind: 'fallback' }
// Non-HttpOnly UI cookie (still valid when Set-Cookie works).
const fbUi = cookieHeader?.match(/UDS_FALLBACK_UI=([^;]+)/)
if (fbUi) return { empNo: decodeURIComponent(fbUi[1].trim()), kind: 'fallback' }
return null
}
/** Cookie first (http/web), then Desktop bridge headers. */
function extractIdentity(req) {
const fromCookie = extractEmpNo(req?.headers?.cookie || '')
if (fromCookie) return { ...fromCookie, via: 'cookie' }
if (!sessionBridge) return null
const hdr = readBridgeFromRequest(req)
if (!hdr) return null
const ok = sessionBridge.verify(hdr.empNo, hdr.token)
if (!ok) return null
return {
empNo: ok.empNo,
kind: ok.kind === 'uds' ? 'uds' : 'fallback',
via: 'bridge',
ssoToken: ok.ssoToken,
/** R05: read live — do not freeze allowOpenRegistration at middleware create time. */
const isOpenRegistration = () => {
if (typeof config.getAllowOpenRegistration === 'function') {
return config.getAllowOpenRegistration() !== false
}
if (typeof config.allowOpenRegistration === 'function') {
return config.allowOpenRegistration() !== false
}
return config.allowOpenRegistration !== false
}
const resolveInitialAdmin = () => String(
(typeof config.getInitialAdminEmpNo === 'function'
? config.getInitialAdminEmpNo()
: config.initialAdminEmpNo)
|| process.env.UDS_AUTH_INITIAL_ADMIN
|| '',
).trim()
const initialAdminEmpNo = resolveInitialAdmin()
async function verifyEmpNoAndToken(empNo, token) {
const out = await searchUserByEmpNoToken({
userSearchUrl,
empNo,
token,
empNoHeader: config.udsAuth?.empNoHeader || 'X-Emp-No',
authValueHeader: config.udsAuth?.authValueHeader || 'X-Auth-Value',
origin: uacBaseUrl || undefined,
})
if (!out.ok) {
console.warn('[uds-auth] verifyEmpNoAndToken failed:', out)
return null
}
const subject = String(out.profile?.empNo || '').trim()
if (!subject || subject !== String(empNo).trim()) {
console.warn('[uds-auth] verifyEmpNoAndToken subject mismatch')
return null
}
return out.profile
}
function applyProfile(userContext, profile, credentials) {
@ -77,150 +93,208 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
return userContext
}
async function resolveRole(empNo, kind) {
if (kind === 'fallback' || empNo === 'administrator') {
/**
* Empty roles table cannot be claimed by arbitrary UAC users unless empNo
* matches initialAdminEmpNo (explicit bootstrap).
*/
async function resolveRoleOnLogin(empNo) {
if (empNo === 'administrator') {
return rolesStore.getRole(empNo)
}
// 角色表空 → 首位登录者升为 super_admin;新用户默认 user;已有则返回原角色
// 注意:getRole() 对未知用户也会返回 'user',绝不能用 !role 判断是否已 bootstrap
const { role, bootstrapped } = await rolesStore.bootstrapFirstUser(empNo)
if (bootstrapped) {
console.info('[uds-auth] BOOTSTRAP: ' + empNo + ' is now super_admin')
// R12: disabled accounts cannot re-enter via SSO / open registration.
if (typeof rolesStore.isDisabled === 'function' && rolesStore.isDisabled(empNo)) {
const err = new Error('account_disabled')
err.code = 'account_disabled'
throw err
}
if (rolesStore.hasRole(empNo)) {
return rolesStore.getRole(empNo)
}
const adminEmp = resolveInitialAdmin()
if (rolesStore.isEmpty()) {
if (adminEmp && empNo === adminEmp) {
const { role, bootstrapped } = await rolesStore.bootstrapFirstUser(empNo)
if (bootstrapped) {
console.info('[uds-auth] BOOTSTRAP: ' + empNo + ' is now super_admin (initialAdminEmpNo)')
}
return role
}
const err = new Error('bootstrap_required')
err.code = 'bootstrap_required'
throw err
}
if (!isOpenRegistration()) {
const err = new Error('registration_closed')
err.code = 'registration_closed'
throw err
}
const { role } = await rolesStore.bootstrapFirstUser(empNo)
return role
}
async function verifyEmpNoAndToken(empNo, token) {
const out = await searchUserByEmpNoToken({
userSearchUrl,
empNo,
token,
empNoHeader: config.udsAuth?.empNoHeader || 'X-Emp-No',
authValueHeader: config.udsAuth?.authValueHeader || 'X-Auth-Value',
origin: uacBaseUrl || undefined,
})
if (!out.ok) {
console.warn('[uds-auth] verifyEmpNoAndToken failed:', out)
return null
function isHttpsRequest(req) {
if (req?.socket?.encrypted) return true
const xf = String(req?.headers?.['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase()
return xf === 'https'
}
function classifySessionKind(session) {
// Keep sealed_box independent of emergency-password enablement (A08).
const mode = String(
session?.kind
|| session?.authMode
|| session?.userData?.authMode
|| '',
)
if (mode.includes('local-admin') || mode === 'sealed_box' || mode === 'local_admin') {
return 'sealed_box'
}
return out.profile
if (
mode === 'fallback'
|| mode.includes('fallback')
|| (session?.empNo === 'administrator' && (!mode || mode === 'uds'))
) {
return 'fallback'
}
return 'uds'
}
function needsProfileUpgrade(userContext) {
if (!userContext) return true
if (userContext.authMode === 'trust') return true
if (userContext.authMode !== 'token+profile') return true
const name = userContext.displayName || userContext.username || ''
if (!name || name === userContext.empNo) return true
return false
}
async function attachUser(ctx, empNo, userContext, kind, { persist = false } = {}) {
if (persist || slidingExpiration) {
async function attachFromSession(ctx, session, { persistTouch = false, res = null, req = null } = {}) {
const empNo = session.empNo
if (typeof rolesStore.isDisabled === 'function' && rolesStore.isDisabled(empNo)) {
const err = new Error('account_disabled')
err.code = 'account_disabled'
throw err
}
const kind = classifySessionKind(session)
// A01/V25: password-fallback sessions die when emergency password is cleared.
if (kind === 'fallback'
&& typeof rolesStore.isFallbackEnabled === 'function'
&& !rolesStore.isFallbackEnabled()) {
const err = new Error('fallback_disabled')
err.code = 'fallback_disabled'
throw err
}
// F02/E11: sealed-box sessions require live box configuration.
if (kind === 'sealed_box' && !isLocalAdminBoxConfigured()) {
const err = new Error('local_admin_not_configured')
err.code = 'local_admin_not_configured'
throw err
}
// F02: reject password sessions minted before the latest rotation.
if (kind === 'fallback' && typeof rolesStore.getFallbackCredVersion === 'function') {
const liveVer = rolesStore.getFallbackCredVersion()
const sessVer = session.userData?.fallbackCredVersion ?? session.fallbackCredVersion
if (sessVer != null && Number(sessVer) !== Number(liveVer)) {
const err = new Error('fallback_rotated')
err.code = 'fallback_rotated'
throw err
}
}
const userContext = { ...session.userData, empNo, _sessionId: session.sessionId }
if (persistTouch || slidingExpiration) {
await sessionStore.touch(session.sessionId, ttlSeconds)
userContext.lastActiveAt = new Date().toISOString()
await sessionStore.setex(
empNo,
Math.floor(cookieMaxAge / 1000),
userContext,
)
}
const role = kind === 'fallback' || kind === 'sealed_box' || rolesStore.hasRole(empNo)
? rolesStore.getRole(empNo)
: ROLES.USER
if (userContext.token) {
try { onSkillCredentials?.(empNo, userContext.token) } catch { /* ignore */ }
}
const role = await resolveRole(empNo, kind)
ctx.userContext = userContext
ctx.empNo = empNo
ctx.role = role
ctx.permissions = rolesStore.resolvePermissions(empNo, role)
// Stamp for Desktop session.follow when mux ALS is empty.
rememberBrowserIdentity({
empNo,
role,
permissions: ctx.permissions,
userContext,
kind: kind === 'uds' ? 'uds' : 'fallback',
})
ctx.sessionId = session.sessionId
ctx.authVia = 'session'
ctx.authKind = kind
if (res && req && session._bearerForCookie) {
const cookie = buildSessionCookie(session._bearerForCookie, ttlSeconds, {
secure: isHttpsRequest(req),
})
const prev = res.getHeader?.('Set-Cookie')
const list = Array.isArray(prev) ? [...prev, cookie] : (prev ? [prev, cookie] : [cookie])
res.setHeader?.('Set-Cookie', list)
}
}
async function mintSession(ctx, userContext, kind, { res, req } = {}) {
const { sessionId, bearer, record } = await sessionStore.create(userContext, ttlSeconds)
if (userContext.token) {
try { onSkillCredentials?.(userContext.empNo, userContext.token) } catch { /* ignore */ }
}
try { await sessionStore.setProfile?.(userContext.empNo, userContext) } catch { /* ignore */ }
onSessionMinted?.({ sessionId, bearer, empNo: userContext.empNo, kind })
const session = { ...record, _bearerForCookie: bearer }
await attachFromSession(ctx, session, { res, req })
return { sessionId, bearer }
}
async function authMiddleware(ctx, next) {
const { req } = ctx
const extracted = extractIdentity(req)
const { req, res } = ctx
if (!extracted) return next()
let userContext = await sessionStore.get(extracted.empNo)
// 旧 trust 会话 / 无姓名部门:强制用 token 重查用户信息
if (userContext && extracted.kind === 'uds' && needsProfileUpgrade(userContext)) {
const credentials = udsValidator.extractCredentials(req)
|| (extracted.ssoToken
? { empNo: extracted.empNo, token: extracted.ssoToken, lang: 'zh-CN' }
: null)
if (credentials && credentials.empNo === extracted.empNo && udsValidator.validateCredentials(credentials)) {
const profile = await verifyEmpNoAndToken(credentials.empNo, credentials.token)
if (profile) {
userContext = applyProfile(userContext, profile, credentials)
await sessionStore.setex(
profile.empNo,
Math.floor(cookieMaxAge / 1000),
userContext,
)
try { onSkillCredentials?.(profile.empNo, credentials.token) } catch { /* ignore */ }
} else {
// 查不到资料则作废 trust 会话,避免“假登录”
await sessionStore.delete(extracted.empNo)
userContext = null
// 1) Local session bearer (cookie / header / verified bridge)
const extracted = await extractSessionBearerAsync(req, {
sessionBridge,
isLiveBearer: async (bearer) => !!(await sessionStore.getByBearer(bearer)),
})
if (extracted?.bearer) {
const session = await sessionStore.getByBearer(extracted.bearer)
if (session) {
if (
extracted.bridgeEmpNo
&& String(extracted.bridgeEmpNo) !== String(session.empNo)
) {
return next()
}
} else if (userContext.authMode === 'trust') {
await sessionStore.delete(extracted.empNo)
userContext = null
try {
await attachFromSession(ctx, session, { persistTouch: true })
} catch (err) {
// Disabled / fallback-closed sessions must not authenticate management APIs.
if (err?.code === 'account_disabled' || err?.code === 'fallback_disabled'
|| err?.code === 'fallback_rotated' || err?.code === 'local_admin_not_configured') {
return next()
}
throw err
}
// Keep the Desktop bridge alive as long as its session (same bearer).
try { sessionBridge?.touch?.(extracted.bearer, ttlSeconds * 1000) } catch { /* ignore */ }
return next()
}
// Explicit bearer presented but invalid — do not mix with portal cookies.
if (extracted.via === 'header' || extracted.via === 'bridge') {
return next()
}
}
if (userContext) {
await attachUser(ctx, extracted.empNo, userContext, extracted.kind)
// 2) Fresh UAC login exchange from portal cookies → mint local session
const portal = extractPortalCredentials(req)
if (!portal) return next()
if (!udsValidator.validateCredentials({
empNo: portal.empNo,
token: portal.token,
lang: 'zh-CN',
})) {
return next()
}
// Fallback cookie/bridge survives process restart; memory session does not — rebuild.
if (extracted.kind === 'fallback') {
const empNo = extracted.empNo || 'administrator'
userContext = {
empNo,
userId: empNo,
username: 'Fallback Administrator',
displayName: 'Fallback Administrator',
isAuthenticated: true,
role: ROLES.FALLBACK_ADMIN,
authMode: extracted.via === 'bridge' ? 'fallback-bridge' : 'fallback-cookie',
authenticatedAt: new Date().toISOString(),
lastActiveAt: new Date().toISOString(),
sessionCreatedAt: new Date().toISOString(),
}
await attachUser(ctx, empNo, userContext, 'fallback', { persist: true })
const profile = await verifyEmpNoAndToken(portal.empNo, portal.token)
if (!profile) return next()
let role
try {
role = await resolveRoleOnLogin(profile.empNo)
} catch (err) {
ctx.authError = err?.code || err?.message
return next()
}
const credentials = udsValidator.extractCredentials(req)
|| (extracted.ssoToken
? { empNo: extracted.empNo, token: extracted.ssoToken, lang: 'zh-CN' }
: null)
if (!credentials || !udsValidator.validateCredentials(credentials)) {
return next()
}
if (credentials.empNo !== extracted.empNo) {
return next()
}
const profile = await verifyEmpNoAndToken(credentials.empNo, credentials.token)
if (!profile) {
return next()
}
userContext = udsValidator.buildUserContext({
const userContext = udsValidator.buildUserContext({
empNo: profile.empNo,
token: credentials.token,
lang: credentials.lang,
token: portal.token,
lang: 'zh-CN',
username: profile.username,
displayName: profile.username,
department: profile.department,
@ -228,20 +302,11 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
email: profile.email,
phone: profile.phone,
}, false)
applyProfile(userContext, profile, credentials)
applyProfile(userContext, profile, { empNo: profile.empNo, token: portal.token, lang: 'zh-CN' })
userContext.authenticatedAt = new Date().toISOString()
userContext.sessionCreatedAt = new Date().toISOString()
await sessionStore.setex(
profile.empNo,
Math.floor(cookieMaxAge / 1000),
userContext,
)
try { onSkillCredentials?.(profile.empNo, credentials.token) } catch { /* ignore */ }
const role = await resolveRole(profile.empNo, 'uds')
ctx.userContext = userContext
ctx.empNo = profile.empNo
await mintSession(ctx, userContext, 'uds', { res, req })
ctx.role = role
ctx.permissions = rolesStore.resolvePermissions(profile.empNo, role)
return next()
@ -250,6 +315,8 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
authMiddleware.udsClient = udsClient
authMiddleware.udsValidator = udsValidator
authMiddleware.verifyEmpNoAndToken = verifyEmpNoAndToken
authMiddleware.mintSession = mintSession
authMiddleware.resolveRoleOnLogin = resolveRoleOnLogin
return authMiddleware
}

View file

@ -0,0 +1,74 @@
import { lstat, chmod } from 'node:fs/promises'
import { resolve } from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { SECURITY_STATE_FILES } from './state-migration.js'
const run = promisify(execFile)
/** Restrict the owned profile, including legacy files with explicit broad ACLs. */
export async function protectProfileDirectory(dataDir) {
const root = resolve(dataDir)
const dir = await lstat(root)
if (!dir.isDirectory() || dir.isSymbolicLink()) throw new Error('profile_directory_unsafe')
const files = []
for (const name of [...SECURITY_STATE_FILES, '.uds-auth-migrated-from-install']) {
const path = resolve(root, name)
try {
const stat = await lstat(path)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('profile_state_file_unsafe')
files.push(name)
} catch (err) { if (err.code !== 'ENOENT') throw err }
}
if (process.platform !== 'win32') {
await chmod(root, 0o700)
for (const name of files) await chmod(resolve(root, name), 0o600)
return { platform: process.platform, directoryMode: '0700', fileMode: '0600', files: files.length }
}
// LiteralPath + child-only environment avoids shell/path interpolation.
const script = `
$ErrorActionPreference = 'Stop'
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$system = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$paths = @($env:UDS_ACL_ROOT)
$names = ConvertFrom-Json $env:UDS_ACL_FILES
foreach ($name in $names) { $paths += Join-Path $env:UDS_ACL_ROOT $name }
foreach ($path in $paths) {
$isDir = [System.IO.Directory]::Exists($path)
if ($isDir) {
$acl = [System.Security.AccessControl.DirectorySecurity]::new()
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
} else {
$acl = [System.Security.AccessControl.FileSecurity]::new()
$inherit = [System.Security.AccessControl.InheritanceFlags]::None
}
$acl.SetAccessRuleProtection($true, $false)
$acl.SetOwner($sid)
foreach ($principal in @($sid, $system)) {
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($principal, 'FullControl', $inherit, 'None', 'Allow')
$acl.AddAccessRule($rule)
}
if ($isDir) {
[System.IO.Directory]::SetAccessControl($path, $acl)
$check = [System.IO.Directory]::GetAccessControl($path)
} else {
[System.IO.File]::SetAccessControl($path, $acl)
$check = [System.IO.File]::GetAccessControl($path)
}
if (!$check.AreAccessRulesProtected) { throw 'profile_acl_inheritance' }
$rules = $check.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
if ($rules.Count -ne 2) { throw 'profile_acl_rule_count' }
foreach ($rule in $rules) {
if ($rule.IdentityReference.Value -notin @($sid.Value, $system.Value) -or $rule.AccessControlType -ne 'Allow') { throw 'profile_acl_unexpected_principal' }
}
}
@{ platform = 'win32'; protected = $true; principals = @('current-user', 'SYSTEM'); files = $names.Count } | ConvertTo-Json -Compress
`
try {
const { stdout } = await run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
windowsHide: true, timeout: 30_000, env: { ...process.env, UDS_ACL_ROOT: root, UDS_ACL_FILES: JSON.stringify(files) },
})
return JSON.parse(stdout.trim())
} catch (cause) {
throw Object.assign(new Error('profile_acl_failed'), { code: 'profile_acl_failed', cause })
}
}

View file

@ -0,0 +1,48 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
const digest = value => createHash('sha256').update(String(value || '')).digest()
export class QrChallengeStore {
constructor({ ttlMs = 120_000, maxEntries = 5000 } = {}) {
this.rows = new Map()
this.ttlMs = ttlMs
this.maxEntries = maxEntries
}
create({ loginSystemCode, originSystemCode }) {
for (const [key, row] of this.rows) if (row.expiresAt <= Date.now()) this.rows.delete(key)
if (this.rows.size >= this.maxEntries) throw Object.assign(new Error('qr_capacity'), { code: 'qr_capacity' })
const qrCodeKey = randomBytes(16).toString('hex')
const qrCodeValue = randomBytes(16).toString('hex')
// The QR contains only public scan coordinates, never the polling browser proof.
const browserBinding = randomBytes(32).toString('hex')
const expiresAt = Date.now() + this.ttlMs
this.rows.set(qrCodeKey, { qrCodeValue, bindingHash: digest(browserBinding), expiresAt,
loginSystemCode, originSystemCode, consumed: false })
return { qrCodeKey, qrCodeValue, browserBinding, expiresAt, loginSystemCode, originSystemCode,
qrCodeStr: `TwoDIMAuth:${qrCodeKey}:${qrCodeValue}` }
}
peek(key, value, binding) {
const row = this.rows.get(String(key || ''))
if (!row) return { ok: false, reason: 'qr_unknown' }
if (row.expiresAt <= Date.now()) { this.rows.delete(String(key)); return { ok: false, reason: 'qr_expired' } }
if (row.consumed) return { ok: false, reason: 'qr_replay' }
if (!timingSafeEqual(digest(value), digest(row.qrCodeValue))) return { ok: false, reason: 'qr_mismatch' }
if (typeof binding !== 'string' || !/^[a-f0-9]{64}$/.test(binding)
|| !timingSafeEqual(digest(binding), row.bindingHash)) return { ok: false, reason: 'qr_binding_required' }
return { ok: true, row }
}
consume(key, value, binding) {
const result = this.peek(key, value, binding)
if (!result.ok) return result
result.row.consumed = true
return { ok: true }
}
}
/** Known UAC contract: outer code + bo.code and credentials in other. */
export function qrLoginCredentials(payload) {
if (String(payload?.code?.code ?? payload?.code) !== '0000' || String(payload?.bo?.code) !== '0000') return null
const empNo = String(payload?.other?.account || payload?.other?.empNo || '').trim()
const token = String(payload?.other?.token || payload?.other?.authValue || '').trim()
if (!empNo || !token || empNo.length > 128 || token.length > 16384) return null
return { empNo, token }
}

View file

@ -1,21 +1,22 @@
/**
* uds-auth 角色存储 + 权限管理
*
* 角色(身份标签;admin 级权限相同):
* super_admin 身份:首位扫码 bootstrap / 显式提权;权限 = admin 级
* fallback_admin 身份:应急账号 administrator;权限 = admin 级
* 角色(身份标签;admin 级权限相同 — SEC-26 产品决策:当前同权):
* super_admin 身份:显式初始管理员 / 管理 API 提权;权限 = admin 级
* fallback_admin 身份:仅应急账号 administrator(不可经管理 API 分配);权限 = admin 级
* admin 身份:用户管理中提权;权限 = admin 级
* user 仅看自己会话,无设置 / 不可建工作区
*
* admin 级(isAdminClass)权限相同:用户管理、设置、建工作区、默认可看全部会话。
* 超管只是身份;默认可持有该身份的包括 admin,以及首位扫码加入者(bootstrap)。
* prefs.viewAllSessions === false 时关闭全览。
* 「超管」是身份标签,不是更强权限隔离;viewAllSessions 是显示偏好,非安全边界。
* 持久化: roles.json (roles + prefs + fallbackPasswordHash)
*/
import { createHash, randomBytes } from 'node:crypto'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { ROLE_LABELS_ZH } from './i18n.js'
import { atomicWriteJson } from './utils/atomic-write.js'
import { hashPasswordAsync, verifyScryptAsync } from './utils/password-kdf.js'
function codedError(code) {
const err = new Error(code)
@ -23,6 +24,73 @@ function codedError(code) {
return err
}
const ACCOUNT_STATUS = {
ACTIVE: 'active',
DISABLED: 'disabled',
}
/** Known shared default from pre-0.3 installs — must never remain usable (R10). */
const LEGACY_DEFAULT_SHA256 = createHash('sha256').update('Admin@123').digest('hex')
/**
* R10: versioned scrypt password hash.
* Format: scrypt$N$r$p$saltB64$urlHashB64
* Legacy bare SHA-256 hex still verifies once, then must be rotated (except known default).
*/
function hashPasswordScrypt(password, saltBuf = randomBytes(16)) {
const N = 16384
const r = 8
const p = 1
const derived = scryptSync(String(password), saltBuf, 32, { N, r, p })
return [
'scrypt',
String(N),
String(r),
String(p),
saltBuf.toString('base64url'),
derived.toString('base64url'),
].join('$')
}
function verifyPasswordHash(password, stored) {
const s = String(stored || '')
if (!s) return false
if (s.startsWith('scrypt$')) {
const parts = s.split('$')
if (parts.length !== 6) return false
const N = Number(parts[1])
const r = Number(parts[2])
const p = Number(parts[3])
const salt = Buffer.from(parts[4], 'base64url')
const expect = Buffer.from(parts[5], 'base64url')
if (N !== 16384 || r !== 8 || p !== 1 || salt.length !== 16 || expect.length !== 32) return false
let derived
try {
derived = scryptSync(String(password), salt, expect.length, { N, r, p })
} catch {
return false
}
if (derived.length !== expect.length) return false
return timingSafeEqual(derived, expect)
}
// Legacy unsalted SHA-256 — reject known shared default outright.
if (/^[0-9a-f]{64}$/i.test(s)) {
if (s.toLowerCase() === LEGACY_DEFAULT_SHA256) return false
const got = createHash('sha256').update(String(password)).digest('hex')
try {
return timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(s, 'hex'))
} catch {
return false
}
}
return false
}
/** @deprecated keep name for call sites that still import hashPassword */
function hashPassword(password) {
return hashPasswordScrypt(password)
}
export const ROLES = {
SUPER_ADMIN: 'super_admin',
ADMIN: 'admin',
@ -73,11 +141,7 @@ export function canToggleViewAllSessions(role) {
return isAdminClass(role)
}
function hashPassword(password) {
return createHash('sha256').update(password).digest('hex')
}
/** 初始部署默认兜底密码(扫码不可用时用);可在设置里改密或关闭。 */
/** Historical default — never enable automatically (R10). */
export const DEFAULT_FALLBACK_PASSWORD = 'Admin@123'
export const DEFAULT_FALLBACK_USERNAME = 'administrator'
@ -85,35 +149,39 @@ export const DEFAULT_FALLBACK_USERNAME = 'administrator'
* RolesStore — 角色存储
* 内存 Map + 可选 JSON 文件持久化
*
* fallback admin: 默认启用,密码 Admin@123;roles.json 显式 null 表示已关闭。
* fallback admin: 默认关闭;须在设置中显式设置 scrypt 口令(≥10,禁 Admin@123)。
* 登录路径: POST /uds-auth/api/fallback/login { username, password }
*/
export class RolesStore {
constructor(options = {}) {
this._roles = new Map() // empNo → role
this._prefs = new Map() // empNo → { viewAllSessions?: boolean }
this._status = new Map() // empNo → active|disabled (R12)
this._firstBootLock = Promise.resolve()
this._mutateChain = Promise.resolve() // R09: serialize role mutations
this._saveChain = Promise.resolve()
this._rolesFile = options.rolesFile ? resolve(options.rolesFile) : null
this._fallbackPasswordHash = null // SHA-256 hex,null = 未启用
this._fallbackPasswordHash = null // scrypt$… or legacy hex;null = 未启用
this._fallbackNeedsRehash = false
this._fallbackCredVersion = 0 // F02: bumps on set/clear password
this._committedFallback = { hash: null, needs: false, ver: 0 }
this._fallbackRateLimit = new Map() // ip → { count, resetAt }
this._dirty = false
this._saveTimer = null
}
/**
* New installs do NOT enable a shared default password (SEC-08).
* Admins must set a password explicitly via settings / setFallbackPassword.
*/
_ensureDefaultFallback() {
if (this._fallbackPasswordHash) return
this._fallbackPasswordHash = hashPassword(DEFAULT_FALLBACK_PASSWORD)
this._markDirty()
console.info(
'[uds-auth] fallback_admin enabled by default'
+ ` (user=${DEFAULT_FALLBACK_USERNAME}, change password in settings)`,
)
// no-op — kept for call-site compatibility
}
// === 持久化 ===
async init() {
let loadedHash = undefined // undefined = missing / new file; null = explicitly cleared
let loadedOk = false
if (this._rolesFile) {
try {
const raw = await readFile(this._rolesFile, 'utf-8')
@ -127,27 +195,60 @@ export class RolesStore {
}
}
if (Object.prototype.hasOwnProperty.call(data, 'fallbackPasswordHash')) {
loadedHash = data.fallbackPasswordHash || null
if (loadedHash) this._fallbackPasswordHash = loadedHash
this._fallbackPasswordHash = data.fallbackPasswordHash || null
}
this._fallbackCredVersion = Number.isSafeInteger(data.fallbackCredVersion)
&& data.fallbackCredVersion >= 0 ? data.fallbackCredVersion : 0
for (const [empNo, st] of Object.entries(data.status || {})) {
if (st === ACCOUNT_STATUS.DISABLED || st === ACCOUNT_STATUS.ACTIVE) {
this._status.set(String(empNo), st)
}
}
loadedOk = true
} catch (err) {
if (err.code === 'ENOENT') {
// 首次启动,文件不存在 — 走默认兜底
// First boot — empty roles, fallback disabled until explicitly configured.
console.info(
'[uds-auth] roles file missing; fallback password disabled until configured'
+ ` (set initialAdmin via UDS_AUTH_INITIAL_ADMIN)`,
)
} else {
console.warn('[uds-auth:RolesStore] Failed to load roles file:', err.message)
// Corrupt roles must NOT restore a default password (SEC-08/21).
console.error('[uds-auth:RolesStore] Failed to load roles file — refusing default password:', err.message)
this._loadFailed = true
}
}
}
// 无持久化哈希(新部署或旧文件未写该字段)→ 默认开启;显式 null 表示超管已关闭
if (loadedHash === undefined && !this._fallbackPasswordHash) {
this._ensureDefaultFallback()
// R10: known shared default hash cannot remain enabled after upgrade.
if (loadedOk && this._fallbackPasswordHash
&& String(this._fallbackPasswordHash).toLowerCase() === LEGACY_DEFAULT_SHA256) {
console.warn('[uds-auth] refusing legacy shared default fallback password — disabled until reset')
this._fallbackPasswordHash = null
this._dirty = true
await this._save()
} else if (loadedOk && this._fallbackPasswordHash
&& /^[0-9a-f]{64}$/i.test(String(this._fallbackPasswordHash))) {
this._fallbackNeedsRehash = true
console.warn('[uds-auth] legacy SHA-256 fallback hash loaded — will upgrade on next successful login')
}
if (loadedOk && this._fallbackPasswordHash) {
console.info('[uds-auth] fallback_admin password loaded from roles file')
}
this._captureCommittedFallback()
}
get loadFailed() {
return !!this._loadFailed
}
_markDirty() {
this._dirty = true
if (this._saveTimer) return
this._saveTimer = setTimeout(() => { void this._save() }, 2000)
this._saveTimer = setTimeout(() => {
this._saveTimer = null
void this._withMutationLock(() => this.flush()).catch(() => {})
}, 2000)
this._saveTimer.unref?.()
}
/** Flush pending roles/prefs to disk immediately (e.g. view-all toggle). */
@ -160,26 +261,106 @@ export class RolesStore {
}
async _save() {
const run = this._saveChain.then(() => this._saveNow())
this._saveChain = run.catch(() => {})
return run
}
async _saveNow() {
this._saveTimer = null
if (!this._dirty || !this._rolesFile) return
const snapshot = {
roles: Object.fromEntries(this._roles),
prefs: Object.fromEntries(this._prefs),
status: Object.fromEntries(this._status),
fallbackPasswordHash: this._fallbackPasswordHash,
fallbackCredVersion: this._fallbackCredVersion,
savedAt: new Date().toISOString(),
}
this._dirty = false
try {
const data = {
roles: Object.fromEntries(this._roles),
prefs: Object.fromEntries(this._prefs),
fallbackPasswordHash: this._fallbackPasswordHash,
savedAt: new Date().toISOString(),
}
await mkdir(dirname(this._rolesFile), { recursive: true })
await writeFile(this._rolesFile, JSON.stringify(data, null, 2), 'utf-8')
await atomicWriteJson(this._rolesFile, snapshot, { mode: 0o600 })
} catch (err) {
this._dirty = true
console.warn('[uds-auth:RolesStore] Failed to save roles file:', err.message)
console.error('[uds-auth:RolesStore] Failed to save roles file:', err.message)
const e = new Error('roles_persist_failed')
e.code = 'roles_persist_failed'
e.cause = err
throw e
}
// Changes during await writeFile — schedule another save.
if (this._dirty) this._markDirty()
}
/** R09: serialize mutations that check last-admin invariants. */
_withMutationLock(fn) {
const transaction = async () => {
const previous = {
roles: new Map(this._roles), prefs: new Map(this._prefs), status: new Map(this._status),
hash: this._fallbackPasswordHash, needs: this._fallbackNeedsRehash,
ver: this._fallbackCredVersion,
}
this._transactionSnapshot = previous
try {
return await fn()
} catch (err) {
this._roles = previous.roles
this._prefs = previous.prefs
this._status = previous.status
this._fallbackPasswordHash = previous.hash
this._fallbackNeedsRehash = previous.needs
this._fallbackCredVersion = previous.ver
this._dirty = false
if (this._saveTimer) clearTimeout(this._saveTimer)
this._saveTimer = null
throw err
} finally {
this._transactionSnapshot = null
}
}
const run = this._mutateChain.then(transaction, transaction)
this._mutateChain = run.then(() => {}, () => {})
return run
}
getAccountStatus(empNo) {
const e = String(empNo || '').trim()
if (!e) return ACCOUNT_STATUS.ACTIVE
return (this._transactionSnapshot?.status || this._status).get(e) || ACCOUNT_STATUS.ACTIVE
}
isDisabled(empNo) {
return this.getAccountStatus(empNo) === ACCOUNT_STATUS.DISABLED
}
async setAccountStatus(empNo, status, currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
const e = String(empNo || '').trim()
if (!e) throw codedError('invalid_role_params')
if (status !== ACCOUNT_STATUS.ACTIVE && status !== ACCOUNT_STATUS.DISABLED) {
throw codedError('invalid_role_params')
}
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
if (status === ACCOUNT_STATUS.DISABLED && this._roles.get(e) === ROLES.SUPER_ADMIN
&& !this.isDisabled(e)) {
const n = await this.countActiveByRole(ROLES.SUPER_ADMIN)
if (n <= 1) throw codedError('last_super_admin_demote')
}
const prevStatus = this._status.get(e)
this._status.set(e, status)
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prevStatus === undefined) this._status.delete(e)
else this._status.set(e, prevStatus)
throw err
}
return true
})
}
// === 首次部署 bootstrap ===
/**
@ -187,28 +368,28 @@ export class RolesStore {
* 返回 { role, bootstrapped } bootstrapped=true 表示本次是 bootstrap
*/
async bootstrapFirstUser(empNo) {
// 用锁保证原子性
this._firstBootLock = this._firstBootLock.then(async () => {
return this._withMutationLock(async () => {
if (this._roles.size === 0) {
this._roles.set(empNo, ROLES.SUPER_ADMIN)
this._markDirty()
this._dirty = true
await this.flush()
return { role: ROLES.SUPER_ADMIN, bootstrapped: true }
}
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
this._markDirty()
this._dirty = true
await this.flush()
return { role: ROLES.USER, bootstrapped: false }
}
return { role: this._roles.get(empNo), bootstrapped: false }
})
return this._firstBootLock
}
// === 角色查询 ===
getRole(empNo) {
if (empNo === 'administrator') return ROLES.FALLBACK_ADMIN
return this._roles.get(empNo) || ROLES.USER
return (this._transactionSnapshot?.roles || this._roles).get(empNo) || ROLES.USER
}
/**
@ -217,7 +398,7 @@ export class RolesStore {
*/
isViewAllSessionsEnabled(empNo) {
if (!empNo) return false
const prefs = this._prefs.get(String(empNo))
const prefs = (this._transactionSnapshot?.prefs || this._prefs).get(String(empNo))
if (prefs && Object.prototype.hasOwnProperty.call(prefs, 'viewAllSessions')) {
return !!prefs.viewAllSessions
}
@ -244,6 +425,14 @@ export class RolesStore {
return true
}
async setViewAllSessionsDurable(empNo, enabled) {
return this._withMutationLock(async () => {
this.setViewAllSessions(empNo, enabled)
await this.flush()
return true
})
}
/** 角色 + 个人偏好 → 有效权限 */
resolvePermissions(empNo, role) {
const id = empNo != null ? String(empNo) : ''
@ -255,16 +444,16 @@ export class RolesStore {
}
hasRole(empNo) {
return this._roles.has(empNo)
return (this._transactionSnapshot?.roles || this._roles).has(empNo)
}
getAll() {
return Array.from(this._roles.entries()).map(([empNo, role]) => ({ empNo, role }))
return Array.from((this._transactionSnapshot?.roles || this._roles).entries()).map(([empNo, role]) => ({ empNo, role }))
}
/** 角色表是否为空(用于 bootstrap) */
isEmpty() {
return this._roles.size === 0
return (this._transactionSnapshot?.roles || this._roles).size === 0
}
/**
@ -276,7 +465,7 @@ export class RolesStore {
const pageSize = Math.min(200, Math.max(1, Number(opts.pageSize) || 50))
const q = String(opts.q || '').trim().toLowerCase()
let rows = Array.from(this._roles.entries()).map(([empNo, role]) => ({ empNo, role }))
let rows = Array.from((this._transactionSnapshot?.roles || this._roles).entries()).map(([empNo, role]) => ({ empNo, role }))
if (q) {
rows = rows.filter((r) => String(r.empNo).toLowerCase().includes(q)
|| String(ROLE_LABELS[r.role] || r.role).toLowerCase().includes(q))
@ -304,86 +493,258 @@ export class RolesStore {
return n
}
/** A07: count only active (non-disabled) accounts with the given role. */
async countActiveByRole(role) {
let n = 0
for (const [empNo, r] of this._roles) {
if (r !== role) continue
if (this.isDisabled(empNo)) continue
n++
}
return n
}
// === 角色管理 (admin 级) ===
/**
* 设置用户角色
* 保护性 invariant: 至少保留 1 个 super_admin 身份(若表中曾有)
* 保护性 invariant: 至少保留 1 个 active super_admin(若表中曾有)
*/
async setRole(empNo, newRole, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_set_role')
}
// invariant: 不能让系统变成 0 个 super_admin(身份仍保留)
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN && newRole !== ROLES.SUPER_ADMIN) {
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_demote')
}
// SEC-26: management API may only assign assignable roles — never fallback_admin.
const assignable = new Set([ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.USER])
if (!assignable.has(newRole)) {
throw codedError('invalid_role_params')
}
this._roles.set(empNo, newRole)
this._markDirty()
return true
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN && newRole !== ROLES.SUPER_ADMIN
&& !this.isDisabled(empNo)) {
const superAdmins = await this.countActiveByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_demote')
}
}
const prev = this._roles.get(empNo)
const prevStatus = this._status.get(empNo)
this._roles.set(empNo, newRole)
// Re-activating via setRole clears disabled when admin assigns a role.
if (this._status.get(empNo) === ACCOUNT_STATUS.DISABLED) {
this._status.set(empNo, ACCOUNT_STATUS.ACTIVE)
}
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prev === undefined) this._roles.delete(empNo)
else this._roles.set(empNo, prev)
// A07: roll back status as well as role on persist failure.
if (prevStatus === undefined) this._status.delete(empNo)
else this._status.set(empNo, prevStatus)
throw err
}
return true
})
}
/** 删除用户 */
async removeUser(empNo, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
/**
* R12: disable account (preferred) — keeps record so open registration cannot re-add.
* Pass { hard: true } to delete role entry entirely.
*/
async removeUser(empNo, currentAdminRole, opts = {}) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_remove_user')
}
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN) {
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_delete')
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_remove_user')
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN && !this.isDisabled(empNo)) {
const superAdmins = await this.countActiveByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_delete')
}
}
}
this._roles.delete(empNo)
this._prefs.delete(empNo)
this._markDirty()
return true
if (opts.hard === true) {
const prevRole = this._roles.get(empNo)
const prevPrefs = this._prefs.get(empNo)
const prevStatus = this._status.get(empNo)
this._roles.delete(empNo)
this._prefs.delete(empNo)
this._status.delete(empNo)
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prevRole !== undefined) this._roles.set(empNo, prevRole)
if (prevPrefs !== undefined) this._prefs.set(empNo, prevPrefs)
if (prevStatus !== undefined) this._status.set(empNo, prevStatus)
throw err
}
} else {
const key = String(empNo)
const prevStatus = this._status.get(key)
this._status.set(key, ACCOUNT_STATUS.DISABLED)
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prevStatus === undefined) this._status.delete(key)
else this._status.set(key, prevStatus)
throw err
}
}
return true
})
}
/** 确保用户存在 (如果不存在设为 user) */
ensureUser(empNo, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
/**
* 确保用户存在 (如果不存在设为 user);disabled 账号不会被自动复活。
* F03: await flush before success — no silent delayed-only persist.
*/
async ensureUser(empNo, currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_add_user')
}
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
this._markDirty()
if (this.isDisabled(empNo)) {
throw codedError('account_disabled')
}
return true
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_add_user')
if (this.isDisabled(empNo)) throw codedError('account_disabled')
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
this._status.set(String(empNo), ACCOUNT_STATUS.ACTIVE)
this._dirty = true
try {
await this.flush()
} catch (err) {
this._roles.delete(empNo)
this._status.delete(String(empNo))
throw err
}
}
return true
})
}
// === Fallback Administrator ===
setFallbackPassword(password, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
throw codedError('forbidden_set_fallback')
_ensureCommittedFallback() {
if (!this._committedFallback) {
this._committedFallback = {
hash: this._fallbackPasswordHash,
needs: this._fallbackNeedsRehash,
ver: this._fallbackCredVersion || 0,
}
}
if (!password || password.length < 6) {
throw codedError('password_too_short')
}
this._fallbackPasswordHash = hashPassword(password)
this._markDirty()
return true
}
clearFallbackPassword(currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
_restoreCommittedFallback() {
this._ensureCommittedFallback()
this._fallbackPasswordHash = this._committedFallback.hash
this._fallbackNeedsRehash = this._committedFallback.needs
this._fallbackCredVersion = this._committedFallback.ver
}
_captureCommittedFallback() {
this._committedFallback = {
hash: this._fallbackPasswordHash,
needs: this._fallbackNeedsRehash,
ver: this._fallbackCredVersion || 0,
}
}
/**
* D05/D06/F03: persist against last-committed snapshot.
* Failed writes restore the last successful commit (not a concurrent peer's
* uncommitted value), including credVersion.
*/
async setFallbackPassword(password, currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_set_fallback')
}
if (String(password) === 'Admin@123') {
throw codedError('password_too_common')
}
if (typeof password !== 'string' || password.length < 10) {
throw codedError('password_too_short')
}
if (Buffer.byteLength(password) > 1024) throw codedError('password_invalid')
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_fallback')
const newHash = await hashPasswordAsync(password)
this._fallbackPasswordHash = newHash
this._fallbackNeedsRehash = false
this._fallbackCredVersion++
this._dirty = true
await this.flush()
this._captureCommittedFallback()
return true
})
}
async clearFallbackPassword(currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_clear_fallback')
}
this._fallbackPasswordHash = null
this._markDirty()
return true
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_clear_fallback')
this._fallbackPasswordHash = null
this._fallbackNeedsRehash = false
this._fallbackCredVersion++
this._dirty = true
await this.flush()
this._captureCommittedFallback()
return true
})
}
/** Auth material version for password-fallback sessions (F02). */
getFallbackCredVersion() {
return this._transactionSnapshot?.ver ?? this._fallbackCredVersion ?? 0
}
isFallbackEnabled() {
return this._fallbackPasswordHash !== null
return (this._transactionSnapshot ? this._transactionSnapshot.hash : this._fallbackPasswordHash) !== null
}
/** Runtime path: asynchronous bounded KDF and recheck after a concurrent rotation. */
async verifyFallbackAsync(password, ip) {
if (typeof password !== 'string' || Buffer.byteLength(password) > 1024) return false
const hash = this._transactionSnapshot ? this._transactionSnapshot.hash : this._fallbackPasswordHash
const version = this.getFallbackCredVersion()
if (!hash) return false
const now = Date.now()
for (const [key, value] of this._fallbackRateLimit) {
if (value.resetAt <= now) this._fallbackRateLimit.delete(key)
}
let bucket = this._fallbackRateLimit.get(ip)
if (!bucket) {
if (this._fallbackRateLimit.size >= 5000) return false
this._fallbackRateLimit.set(ip, bucket = { count: 0, resetAt: now + 60_000 })
}
if (++bucket.count > 5) return false
const ok = String(hash).startsWith('scrypt$')
? await verifyScryptAsync(password, hash) : verifyPasswordHash(password, hash)
if (!ok) return false
// Serialize the final check with all durable credential changes.
return this._withMutationLock(async () => {
if (this._fallbackPasswordHash !== hash || this._fallbackCredVersion !== version) return false
if (!String(hash).startsWith('scrypt$')) {
this._fallbackPasswordHash = await hashPasswordAsync(password)
this._fallbackNeedsRehash = false
this._dirty = true
await this.flush()
this._captureCommittedFallback()
}
return true
})
}
/**
@ -405,6 +766,16 @@ export class RolesStore {
return false // rate limited
}
return hashPassword(password) === this._fallbackPasswordHash
const ok = verifyPasswordHash(password, this._fallbackPasswordHash)
if (ok && (this._fallbackNeedsRehash || !String(this._fallbackPasswordHash).startsWith('scrypt$'))) {
try {
this._fallbackPasswordHash = hashPasswordScrypt(password)
this._fallbackNeedsRehash = false
this._markDirty()
} catch { /* keep legacy until next save */ }
}
return ok
}
}
export { ACCOUNT_STATUS, hashPasswordScrypt, verifyPasswordHash, LEGACY_DEFAULT_SHA256 }

View file

@ -4,10 +4,11 @@
* Used with workspace/cwd checks in dsh-acl.js (owner OR own workspace).
* Missing owner must NOT deny access by itself (legacy sessions).
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { getUserContext } from './context.js'
import { ROLES, computePermissions } from './roles.js'
import { atomicWriteJson } from './utils/atomic-write.js'
export class SessionAclStore {
/**
@ -29,12 +30,17 @@ export class SessionAclStore {
this._owners.set(String(sessionId), String(empNo))
}
} catch (err) {
if (err.code !== 'ENOENT') {
console.warn('[uds-auth:SessionAcl] load failed:', err.message)
}
if (err.code === 'ENOENT') return
// R09: corrupt ownership must fail closed — do not treat as empty/unowned.
console.error('[uds-auth:SessionAcl] load failed — fail closed:', err.message)
this._loadFailed = true
}
}
get loadFailed() {
return !!this._loadFailed
}
_markDirty() {
this._dirty = true
if (this._saveTimer) return
@ -46,16 +52,12 @@ export class SessionAclStore {
if (!this._dirty || !this._ownersFile) return
this._dirty = false
try {
await mkdir(dirname(this._ownersFile), { recursive: true })
await writeFile(
this._ownersFile,
JSON.stringify({
owners: Object.fromEntries(this._owners),
savedAt: new Date().toISOString(),
}, null, 2),
'utf-8',
)
await atomicWriteJson(this._ownersFile, {
owners: Object.fromEntries(this._owners),
savedAt: new Date().toISOString(),
}, { mode: 0o600 })
} catch (err) {
this._dirty = true
console.warn('[uds-auth:SessionAcl] save failed:', err.message)
}
}
@ -64,10 +66,21 @@ export class SessionAclStore {
return this._owners.get(String(sessionId)) || null
}
setOwner(sessionId, empNo) {
if (!sessionId || !empNo) return
this._owners.set(String(sessionId), String(empNo))
/**
* Stamp owner. Existing foreign owner is NOT overwritten unless force=true (SEC-13).
* @returns {boolean} true if written / already same owner
*/
setOwner(sessionId, empNo, { force = false } = {}) {
if (!sessionId || !empNo) return false
const id = String(sessionId)
const next = String(empNo)
const existing = this._owners.get(id)
if (existing && String(existing) !== next && !force) {
return false
}
this._owners.set(id, next)
this._markDirty()
return true
}
/** All stamped owners for export / analytics. */

View file

@ -5,8 +5,8 @@
* localStorage and sends X-UDS-Bridge-* headers on subsequent /uds-auth requests.
*/
import { randomBytes, timingSafeEqual, createHash } from 'node:crypto'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
import { readFile } from 'node:fs/promises'
import { atomicWriteJson } from './utils/atomic-write.js'
export const BRIDGE_EMPNO_HEADER = 'x-uds-bridge-empno'
export const BRIDGE_TOKEN_HEADER = 'x-uds-bridge-token'
@ -30,6 +30,8 @@ export class SessionBridgeStore {
this._ttlMs = opts.ttlMs || DEFAULT_TTL_MS
/** @type {Map<string, { empNo: string, kind: string, exp: number, ssoToken?: string }>} */
this._byToken = new Map()
/** @type {Map<string, { empNo: string, bearer: string, exp: number }>} T01: one-time WS tickets */
this._wsTickets = new Map()
this._saveTimer = null
}
@ -72,30 +74,31 @@ export class SessionBridgeStore {
this._byToken.delete(token)
continue
}
// Persist bridge metadata only — strip upstream SSO token from disk (SEC-17).
tokens[token] = {
empNo: row.empNo,
kind: row.kind,
exp: row.exp,
...(row.ssoToken ? { ssoToken: row.ssoToken } : {}),
}
}
try {
await mkdir(dirname(this._file), { recursive: true })
await writeFile(this._file, JSON.stringify({ tokens }, null, 2), 'utf-8')
await atomicWriteJson(this._file, { tokens }, { mode: 0o600 })
} catch (err) {
console.warn('[uds-auth:SessionBridge] save failed:', err.message)
}
}
/**
* @param {{ empNo: string, kind?: string, ssoToken?: string, ttlMs?: number }} row
* @param {{ empNo: string, kind?: string, ssoToken?: string, ttlMs?: number, token?: string }} row
* @returns {{ empNo: string, kind: string, token: string, exp: number }}
*/
mint(row) {
const empNo = String(row.empNo || '').trim()
if (!empNo) throw new Error('bridge_empNo_required')
const kind = String(row.kind || 'fallback')
const token = randomBytes(32).toString('hex')
// Prefer caller-supplied token (local session bearer) so bridge ≡ session proof.
const token = row.token ? String(row.token) : randomBytes(32).toString('hex')
if (token.length < 32) throw new Error('bridge_token_too_short')
const exp = Date.now() + (row.ttlMs || this._ttlMs)
this._byToken.set(token, {
empNo,
@ -127,6 +130,33 @@ export class SessionBridgeStore {
return { empNo: row.empNo, kind: row.kind, ssoToken: row.ssoToken }
}
/**
* Slide bridge expiry with its local session (bridge token ≡ session bearer).
* Without this the bridge died at mint+TTL even while the session stayed active,
* leaving cookie-less Desktop (and emergency admins, who have no portal SSO
* re-mint path) anonymous on remote.mux.
* @param {string} token
* @param {number} [ttlMs]
* @returns {boolean}
*/
touch(token, ttlMs) {
const t = String(token || '').trim()
if (!t) return false
const row = this._byToken.get(t)
if (!row) return false
const now = Date.now()
if (row.exp <= now) {
this._byToken.delete(t)
this._scheduleSave()
return false
}
const next = now + (Number(ttlMs) > 0 ? Number(ttlMs) : this._ttlMs)
if (next <= row.exp) return true
row.exp = next
this._scheduleSave()
return true
}
/** Clear all bridge tokens for an empNo (logout). */
revokeEmpNo(empNo) {
const e = String(empNo || '').trim()
@ -141,11 +171,63 @@ export class SessionBridgeStore {
if (changed) this._scheduleSave()
}
/**
* D04: revoke only bridges whose kind matches (password vs sealed_box).
* @param {string} empNo
* @param {string|string[]} kinds
*/
revokeEmpNoKinds(empNo, kinds) {
const e = String(empNo || '').trim()
const allow = new Set(Array.isArray(kinds) ? kinds.map(String) : [String(kinds)])
if (!e || !allow.size) return 0
let n = 0
for (const [token, row] of this._byToken) {
if (row.empNo === e && allow.has(String(row.kind || 'fallback'))) {
this._byToken.delete(token)
n++
}
}
if (n) this._scheduleSave()
return n
}
revokeToken(token) {
const t = String(token || '').trim()
if (!t) return
if (this._byToken.delete(t)) this._scheduleSave()
}
/**
* T01: short-TTL, single-use WS handshake ticket (not a reusable session bearer).
* @param {{ empNo: string, bearer: string, ttlMs?: number }} input
*/
mintWsTicket(input) {
const empNo = String(input?.empNo || '').trim()
const bearer = String(input?.bearer || '').trim()
if (!empNo || bearer.length < 16) throw new Error('ws_ticket_input_required')
const now = Date.now()
for (const [k, v] of this._wsTickets) if (v.exp <= now) this._wsTickets.delete(k)
if (this._wsTickets.size >= 5000) throw new Error('ws_ticket_capacity')
const ttl = Math.min(120_000, Math.max(15_000, Number(input.ttlMs) || 60_000))
const ticket = randomBytes(24).toString('hex')
const exp = Date.now() + ttl
this._wsTickets.set(ticket, { empNo, bearer, exp })
return { ticket, empNo, expiresAt: exp, expiresIn: Math.floor(ttl / 1000) }
}
/**
* Atomically consume a WS ticket. Replay returns null.
* @returns {{ empNo: string, bearer: string } | null}
*/
consumeWsTicket(ticket) {
const id = String(ticket || '').trim()
if (!id) return null
const row = this._wsTickets.get(id)
if (!row) return null
this._wsTickets.delete(id)
if (row.exp <= Date.now()) return null
return { empNo: row.empNo, bearer: row.bearer }
}
}
/**
@ -163,34 +245,25 @@ export function readBridgeHeaders(req) {
}
/**
* Headers first, then WebSocket upgrade query
* (`?udsBridgeEmpNo=&udsBridgeToken=&udsBridgeKind=`).
* Browser WS cannot set custom headers; Desktop also drops cookies.
* Headers only for reusable bridge credentials.
* T01: WS upgrade must use one-time `udsWsTicket` (see extractSessionBearer) —
* long-lived bridge tokens in the URL query are ignored.
* @param {any} req
* @returns {{ empNo: string, token: string, kind: string } | null}
*/
export function readBridgeFromRequest(req) {
const fromHdr = readBridgeHeaders(req)
if (fromHdr) return fromHdr
return readBridgeHeaders(req)
}
/**
* Read one-time WS ticket id from upgrade URL (does not consume).
* @param {any} req
* @returns {string|null}
*/
export function readWsTicketFromRequest(req) {
try {
const url = new URL(req?.url || '/', 'http://uds-auth.local')
const empNo = String(
url.searchParams.get('udsBridgeEmpNo')
|| url.searchParams.get('x-uds-bridge-empno')
|| '',
).trim()
const token = String(
url.searchParams.get('udsBridgeToken')
|| url.searchParams.get('x-uds-bridge-token')
|| '',
).trim()
const kind = String(
url.searchParams.get('udsBridgeKind')
|| url.searchParams.get('x-uds-bridge-kind')
|| 'fallback',
).trim() || 'fallback'
if (!empNo || !token) return null
return { empNo, token, kind }
return String(url.searchParams.get('udsWsTicket') || '').trim() || null
} catch {
return null
}

View file

@ -4,9 +4,10 @@ import { RedisStore } from './redis-store.js'
/**
* Factory function to create appropriate session store based on config
* @param {object} config - Session configuration
* @param {{ file?: string, dataDir?: string, logger?: any }} [persist] - MemoryStore disk persistence
* @returns {Promise<SessionStore>}
*/
export async function createSessionStore(config) {
export async function createSessionStore(config, persist = {}) {
const { storeType, redisUrl } = config
if (storeType === 'redis') {
@ -18,10 +19,14 @@ export async function createSessionStore(config) {
} catch (err) {
console.error('[uds-auth] Failed to connect to Redis:', err.message)
console.warn('[uds-auth] Falling back to MemoryStore')
return new MemoryStore()
const store = new MemoryStore(persist)
await store.init()
return store
}
}
// Default to memory store
return new MemoryStore()
// Default to memory store (persisted to the profile data dir when configured)
const store = new MemoryStore(persist)
await store.init()
return store
}

View file

@ -1,61 +1,440 @@
import { SessionStore } from './store.js'
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { SessionStore, SESSION_ABSOLUTE_MAX_SECONDS } from './store.js'
import { atomicWriteJson } from '../utils/atomic-write.js'
import { loadOrCreateSecretKey, sealSecret, openSecret } from '../utils/secret-box.js'
function hashBearer(bearer) {
return createHash('sha256').update(String(bearer)).digest('hex')
}
function safeEqualHex(a, b) {
if (!a || !b || a.length !== b.length) return false
try {
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'))
} catch {
return false
}
}
/**
* In-memory session store — 单索引 empNo → userData
* 一个工号 = 一个会话,多终端同工号共享同一条目。
* In-memory auth session store, optionally persisted so a Host restart does not
* log everyone out (emergency admins have no SSO re-mint path).
* Primary key: sessionId. Auth proof: bearer (stored as SHA-256 hash only).
* empNo is a secondary index for logout-all / listing — not an auth credential.
*
* Persistence (opts.file + opts.dataDir): bearer hashes and expiry in clear,
* userData (may carry the UAC token) sealed with the plugin AES-GCM key.
*/
export class MemoryStore extends SessionStore {
constructor() {
/**
* @param {{ file?: string, dataDir?: string, logger?: any }} [opts]
*/
constructor(opts = {}) {
super()
this._sessions = new Map() // Map<empNo, { userData, expiresAt }>
this._timers = new Map() // Map<empNo, Timeout>
this._file = opts.file || null
this._dataDir = opts.dataDir || null
this._logger = opts.logger || console
this._key = null
this._saveTimer = null
/** @type {Map<string, object>} sessionId → record */
this._sessions = new Map()
/** @type {Map<string, string>} bearerHash → sessionId */
this._byBearer = new Map()
/** @type {Map<string, Set<string>>} empNo → sessionIds */
this._byEmpNo = new Map()
/** @type {Map<string, object>} empNo → profile (cache only) */
this._profiles = new Map()
/** @type {Map<string, NodeJS.Timeout>} */
this._timers = new Map()
}
async get(empNo) {
const session = this._sessions.get(empNo)
if (!session) return null
if (session.expiresAt && Date.now() > session.expiresAt) {
await this.delete(empNo)
/** Load persisted sessions (if configured). Never throws: bad state is dropped. */
async init() {
if (!this._file) return
let raw = null
try {
raw = JSON.parse(await readFile(this._file, 'utf-8'))
} catch (err) {
if (err?.code !== 'ENOENT') this._logger.warn?.('[uds-auth:sessions] load failed, starting empty:', err.message)
}
const rows = Array.isArray(raw?.sessions) ? raw.sessions : []
try {
this._key = await loadOrCreateSecretKey(this._dataDir || '.', { hasEncryptedData: rows.length > 0 })
} catch (err) {
this._logger.warn?.('[uds-auth:sessions] secret key unavailable, sessions will not persist:', err.code || err.message)
this._file = null
return
}
const now = Date.now()
let restored = 0
for (const r of rows) {
if (!r?.sessionId || !r.bearerHash || !r.empNo) continue
if (!(r.expiresAt > now) || !(r.absoluteExpiresAt > now)) continue
const plain = openSecret(this._key, r.userData)
if (plain == null) continue
let userData
try { userData = JSON.parse(plain) } catch { continue }
const record = {
sessionId: String(r.sessionId),
bearerHash: String(r.bearerHash),
empNo: String(r.empNo),
kind: String(r.kind || 'uds'),
userData,
createdAt: Number(r.createdAt) || now,
expiresAt: Number(r.expiresAt),
absoluteExpiresAt: Number(r.absoluteExpiresAt),
}
this._sessions.set(record.sessionId, record)
this._byBearer.set(record.bearerHash, record.sessionId)
if (!this._byEmpNo.has(record.empNo)) this._byEmpNo.set(record.empNo, new Set())
this._byEmpNo.get(record.empNo).add(record.sessionId)
this._scheduleExpiry(record.sessionId, Math.ceil((record.expiresAt - now) / 1000))
if (userData.displayName || userData.username) {
this._profiles.set(record.empNo, {
empNo: record.empNo,
username: userData.username,
displayName: userData.displayName,
department: userData.department,
organization: userData.organization,
email: userData.email,
phone: userData.phone,
})
}
restored++
}
if (restored !== rows.length) this._scheduleSave()
if (restored) this._logger.info?.('[uds-auth:sessions] restored %d session(s)', restored)
}
_scheduleSave() {
if (!this._file || !this._key || this._saveTimer) return
this._saveTimer = setTimeout(() => {
this._saveTimer = null
void this.flush()
}, 200)
if (typeof this._saveTimer.unref === 'function') this._saveTimer.unref()
}
/** Write current sessions now (also used on shutdown / tests). */
async flush() {
if (!this._file || !this._key) return
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
}
const now = Date.now()
const sessions = []
for (const row of this._sessions.values()) {
if (!(row.expiresAt > now) || !(row.absoluteExpiresAt > now)) continue
sessions.push({
sessionId: row.sessionId,
bearerHash: row.bearerHash,
empNo: row.empNo,
kind: row.kind,
createdAt: row.createdAt,
expiresAt: row.expiresAt,
absoluteExpiresAt: row.absoluteExpiresAt,
userData: sealSecret(this._key, JSON.stringify(row.userData || {})),
})
}
try {
await atomicWriteJson(this._file, { version: 1, sessions }, { mode: 0o600 })
} catch (err) {
this._logger.warn?.('[uds-auth:sessions] save failed:', err.message)
}
}
_purgeExpired(sessionId) {
const row = this._sessions.get(sessionId)
if (!row) return null
if (row.expiresAt && Date.now() > row.expiresAt) {
this._removeRecord(sessionId, row)
return null
}
return { ...session.userData }
return row
}
async set(empNo, userData) {
this._sessions.set(empNo, { userData })
}
async setex(empNo, seconds, userData) {
this._sessions.set(empNo, { userData, expiresAt: Date.now() + seconds * 1000 })
if (this._timers.has(empNo)) clearTimeout(this._timers.get(empNo))
this._timers.set(empNo, setTimeout(() => this._sessions.delete(empNo), seconds * 1000))
}
async delete(empNo) {
if (this._timers.has(empNo)) {
clearTimeout(this._timers.get(empNo))
this._timers.delete(empNo)
_removeRecord(sessionId, row) {
if (this._sessions.has(sessionId)) this._scheduleSave()
this._sessions.delete(sessionId)
if (row?.bearerHash) this._byBearer.delete(row.bearerHash)
if (row?.empNo) {
const set = this._byEmpNo.get(row.empNo)
if (set) {
set.delete(sessionId)
if (set.size === 0) this._byEmpNo.delete(row.empNo)
}
}
if (this._timers.has(sessionId)) {
clearTimeout(this._timers.get(sessionId))
this._timers.delete(sessionId)
}
this._sessions.delete(empNo)
}
async has(empNo) {
const session = this._sessions.get(empNo)
if (!session) return false
if (session.expiresAt && Date.now() > session.expiresAt) {
await this.delete(empNo)
return false
_scheduleExpiry(sessionId, ttlSeconds) {
if (this._timers.has(sessionId)) {
clearTimeout(this._timers.get(sessionId))
this._timers.delete(sessionId)
}
if (!ttlSeconds || ttlSeconds <= 0) return
const timer = setTimeout(() => {
const row = this._sessions.get(sessionId)
if (row) this._removeRecord(sessionId, row)
}, ttlSeconds * 1000)
// Do not keep the process alive solely for session TTLs (tests / short-lived hosts).
if (typeof timer.unref === 'function') timer.unref()
this._timers.set(sessionId, timer)
}
_publicRecord(row) {
if (!row) return null
return {
sessionId: row.sessionId,
empNo: row.empNo,
kind: row.kind,
userData: { ...row.userData },
createdAt: row.createdAt,
expiresAt: row.expiresAt,
absoluteExpiresAt: row.absoluteExpiresAt,
}
}
async create(userData, ttlSeconds = 1800) {
const empNo = String(userData?.empNo || '').trim()
if (!empNo) throw new Error('session_empNo_required')
const sessionId = randomBytes(16).toString('hex')
const bearer = randomBytes(32).toString('hex')
const bearerHash = hashBearer(bearer)
const now = Date.now()
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
// Idle TTL slides via touch(); absolute lifetime is a fixed 7 days. (Was min(ttl, 7d),
// which capped every session at its 30-min idle TTL and made sliding a no-op.)
const absoluteCap = SESSION_ABSOLUTE_MAX_SECONDS
const record = {
sessionId,
bearerHash,
empNo,
kind: (() => {
const mode = String(userData?.authMode || '')
if (mode.includes('local-admin') || mode === 'sealed_box' || mode === 'local_admin') {
return 'sealed_box'
}
if (mode.includes('fallback') || mode === 'fallback-password' || mode === 'fallback-login') {
return 'fallback'
}
if (empNo === 'administrator') return 'fallback'
return 'uds'
})(),
userData: { ...userData },
createdAt: now,
expiresAt: now + ttl * 1000,
absoluteExpiresAt: now + absoluteCap * 1000,
}
this._sessions.set(sessionId, record)
this._byBearer.set(bearerHash, sessionId)
if (!this._byEmpNo.has(empNo)) this._byEmpNo.set(empNo, new Set())
this._byEmpNo.get(empNo).add(sessionId)
this._scheduleExpiry(sessionId, ttl)
this._scheduleSave()
if (userData.displayName || userData.username) {
this._profiles.set(empNo, {
empNo,
username: userData.username,
displayName: userData.displayName,
department: userData.department,
organization: userData.organization,
email: userData.email,
phone: userData.phone,
})
}
return { sessionId, bearer, record: this._publicRecord(record) }
}
_lookupBearer(bearer) {
const t = String(bearer || '').trim()
if (!t || t.length < 16) return null
const sessionId = this._byBearer.get(hashBearer(t))
if (!sessionId) return null
const row = this._purgeExpired(sessionId)
if (!row) return null
if (!safeEqualHex(row.bearerHash, hashBearer(t))) return null
if (row.absoluteExpiresAt && Date.now() > row.absoluteExpiresAt) {
this._removeRecord(sessionId, row)
return null
}
return row
}
async getByBearer(bearer) {
return this._publicRecord(this._lookupBearer(bearer))
}
getByBearerSync(bearer) {
return this._publicRecord(this._lookupBearer(bearer))
}
async getBySessionId(sessionId) {
const id = String(sessionId || '').trim()
if (!id) return null
return this._publicRecord(this._purgeExpired(id))
}
getBySessionIdSync(sessionId) {
const id = String(sessionId || '').trim()
if (!id) return null
return this._publicRecord(this._purgeExpired(id))
}
async touch(sessionId, ttlSeconds) {
const row = this._purgeExpired(String(sessionId || ''))
if (!row) return null
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
const absLeft = row.absoluteExpiresAt
? Math.max(0, Math.floor((row.absoluteExpiresAt - Date.now()) / 1000))
: ttl
const nextTtl = Math.min(ttl, absLeft || ttl)
if (nextTtl <= 0) {
this._removeRecord(row.sessionId, row)
return null
}
row.expiresAt = Date.now() + nextTtl * 1000
row.userData = {
...row.userData,
lastActiveAt: new Date().toISOString(),
}
this._scheduleExpiry(row.sessionId, nextTtl)
this._scheduleSave()
return this._publicRecord(row)
}
async revokeSession(sessionId) {
const row = this._sessions.get(String(sessionId || ''))
if (!row) return false
this._removeRecord(row.sessionId, row)
return true
}
async revokeBearer(bearer) {
const row = this._lookupBearer(bearer)
if (!row) return false
this._removeRecord(row.sessionId, row)
return true
}
async revokeEmpNo(empNo) {
const e = String(empNo || '').trim()
const set = this._byEmpNo.get(e)
if (!set) return 0
let n = 0
for (const sid of [...set]) {
const row = this._sessions.get(sid)
if (row) {
this._removeRecord(sid, row)
n++
}
}
return n
}
/**
* D04: revoke only sessions matching predicate (e.g. password-mode, not sealed-box).
* @param {string} empNo
* @param {(row: object) => boolean} predicate
*/
async revokeEmpNoMatching(empNo, predicate) {
const e = String(empNo || '').trim()
const set = this._byEmpNo.get(e)
if (!set || typeof predicate !== 'function') return 0
let n = 0
for (const sid of [...set]) {
const row = this._sessions.get(sid)
if (row && predicate(row)) {
this._removeRecord(sid, row)
n++
}
}
return n
}
async setProfile(empNo, profile) {
const e = String(empNo || '').trim()
if (!e || !profile) return
this._profiles.set(e, { ...profile, empNo: e })
}
async getProfile(empNo) {
const row = this._profiles.get(String(empNo || '').trim())
return row ? { ...row } : null
}
async clear() {
this._scheduleSave()
for (const t of this._timers.values()) clearTimeout(t)
this._timers.clear()
this._sessions.clear()
this._byBearer.clear()
this._byEmpNo.clear()
this._profiles.clear()
}
/**
* Legacy: return first active session userData for empNo (profile-like).
* Does NOT prove the caller is authenticated as that empNo.
*/
async get(empNo) {
const e = String(empNo || '').trim()
const set = this._byEmpNo.get(e)
if (!set || set.size === 0) return null
for (const sid of set) {
const row = this._purgeExpired(sid)
if (row) return { ...row.userData, _sessionId: row.sessionId }
}
return null
}
/**
* Legacy compat: create a new session for empNo (multi-device safe).
* Returns the public userData; caller should also capture bearer via create().
*/
async setex(empNo, seconds, userData) {
const data = { ...userData, empNo: String(empNo) }
const { bearer, sessionId, record } = await this.create(data, seconds)
// Stash last mint so login handlers can read bearer without API break.
this._lastMint = { empNo: String(empNo), bearer, sessionId }
return record.userData
}
takeLastMint() {
const m = this._lastMint || null
this._lastMint = null
return m
}
async delete(empNo) {
await this.revokeEmpNo(empNo)
}
async has(empNo) {
const e = String(empNo || '').trim()
const set = this._byEmpNo.get(e)
if (!set) return false
for (const sid of set) {
if (this._purgeExpired(sid)) return true
}
return false
}
async keys() {
return Array.from(this._sessions.keys())
const out = new Set()
for (const [empNo, set] of this._byEmpNo) {
for (const sid of set) {
if (this._purgeExpired(sid)) {
out.add(empNo)
break
}
}
}
return [...out]
}
}

View file

@ -1,47 +1,226 @@
import { SessionStore } from './store.js'
import { createHash, randomBytes } from 'node:crypto'
import { SessionStore, SESSION_ABSOLUTE_MAX_SECONDS } from './store.js'
function hashBearer(bearer) {
return createHash('sha256').update(String(bearer)).digest('hex')
}
/**
* Redis-backed session store (for production / 多实例)
* Requires 'redis' package: npm install redis
* Key: uds-session:{empNo}
* Redis-backed auth session store.
* Keys: uds-sess:{sessionId}, uds-bearer:{hash} → sessionId, uds-emp:{empNo} set
*/
export class RedisStore extends SessionStore {
constructor(redisClient) {
super()
if (!redisClient) throw new Error('Redis client is required')
this._client = redisClient
this._lastMint = null
}
_key(empNo) { return `uds-session:${empNo}` }
_sessKey(id) { return `uds-sess:${id}` }
_bearerKey(hash) { return `uds-bearer:${hash}` }
_empKey(empNo) { return `uds-emp:${empNo}` }
_profileKey(empNo) { return `uds-profile:${empNo}` }
async get(empNo) {
const data = await this._client.get(this._key(empNo))
return data ? JSON.parse(data) : null
async create(userData, ttlSeconds = 1800) {
const empNo = String(userData?.empNo || '').trim()
if (!empNo) throw new Error('session_empNo_required')
const sessionId = randomBytes(16).toString('hex')
const bearer = randomBytes(32).toString('hex')
const bearerHash = hashBearer(bearer)
const now = Date.now()
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
const record = {
sessionId,
bearerHash,
empNo,
kind: (() => {
const mode = String(userData?.authMode || '')
if (mode.includes('local-admin') || mode === 'sealed_box' || mode === 'local_admin') {
return 'sealed_box'
}
if (mode.includes('fallback') || mode === 'fallback-password' || mode === 'fallback-login') {
return 'fallback'
}
if (empNo === 'administrator') return 'fallback'
return 'uds'
})(),
userData: { ...userData },
createdAt: now,
expiresAt: now + ttl * 1000,
absoluteExpiresAt: now + SESSION_ABSOLUTE_MAX_SECONDS * 1000,
}
const payload = JSON.stringify(record)
await this._client.setEx(this._sessKey(sessionId), ttl, payload)
await this._client.setEx(this._bearerKey(bearerHash), ttl, sessionId)
await this._client.sAdd(this._empKey(empNo), sessionId)
await this._client.expire(this._empKey(empNo), ttl)
this._lastMint = { empNo, bearer, sessionId }
return {
sessionId,
bearer,
record: {
sessionId,
empNo,
kind: record.kind,
userData: { ...userData },
createdAt: record.createdAt,
expiresAt: record.expiresAt,
absoluteExpiresAt: record.absoluteExpiresAt,
},
}
}
async set(empNo, userData) {
await this._client.set(this._key(empNo), JSON.stringify(userData))
async _load(sessionId) {
if (!sessionId) return null
const raw = await this._client.get(this._sessKey(sessionId))
if (!raw) return null
try {
const row = JSON.parse(raw)
if (row.absoluteExpiresAt && Date.now() > row.absoluteExpiresAt) {
await this.revokeSession(sessionId)
return null
}
return row
} catch {
return null
}
}
async setex(empNo, seconds, userData) {
await this._client.setEx(this._key(empNo), seconds, JSON.stringify(userData))
_public(row) {
if (!row) return null
return {
sessionId: row.sessionId,
empNo: row.empNo,
kind: row.kind,
userData: { ...row.userData },
createdAt: row.createdAt,
expiresAt: row.expiresAt,
absoluteExpiresAt: row.absoluteExpiresAt,
}
}
async delete(empNo) {
await this._client.del(this._key(empNo))
async getByBearer(bearer) {
const t = String(bearer || '').trim()
if (!t || t.length < 16) return null
const sessionId = await this._client.get(this._bearerKey(hashBearer(t)))
return this._public(await this._load(sessionId))
}
async has(empNo) {
return (await this._client.exists(this._key(empNo))) === 1
getByBearerSync() {
// Redis cannot sync-read; callers must use async path or MemoryStore.
return null
}
async getBySessionId(sessionId) {
return this._public(await this._load(String(sessionId || '').trim()))
}
async touch(sessionId, ttlSeconds) {
const row = await this._load(sessionId)
if (!row) return null
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
const absLeft = row.absoluteExpiresAt
? Math.max(0, Math.floor((row.absoluteExpiresAt - Date.now()) / 1000))
: ttl
const nextTtl = Math.min(ttl, absLeft || ttl)
if (nextTtl <= 0) {
await this.revokeSession(sessionId)
return null
}
row.expiresAt = Date.now() + nextTtl * 1000
row.userData = { ...row.userData, lastActiveAt: new Date().toISOString() }
await this._client.setEx(this._sessKey(sessionId), nextTtl, JSON.stringify(row))
await this._client.setEx(this._bearerKey(row.bearerHash), nextTtl, sessionId)
return this._public(row)
}
async revokeSession(sessionId) {
const row = await this._load(sessionId)
if (!row) {
await this._client.del(this._sessKey(sessionId))
return false
}
await this._client.del(this._sessKey(sessionId))
await this._client.del(this._bearerKey(row.bearerHash))
await this._client.sRem(this._empKey(row.empNo), sessionId)
return true
}
async revokeBearer(bearer) {
const t = String(bearer || '').trim()
if (!t) return false
const sessionId = await this._client.get(this._bearerKey(hashBearer(t)))
if (!sessionId) return false
return this.revokeSession(sessionId)
}
async revokeEmpNo(empNo) {
const e = String(empNo || '').trim()
const members = await this._client.sMembers(this._empKey(e))
let n = 0
for (const sid of members || []) {
if (await this.revokeSession(sid)) n++
}
await this._client.del(this._empKey(e))
return n
}
async setProfile(empNo, profile) {
const e = String(empNo || '').trim()
if (!e || !profile) return
await this._client.set(this._profileKey(e), JSON.stringify({ ...profile, empNo: e }))
}
async getProfile(empNo) {
const raw = await this._client.get(this._profileKey(String(empNo || '').trim()))
if (!raw) return null
try { return JSON.parse(raw) } catch { return null }
}
async clear() {
const keys = await this._client.keys('uds-session:*')
if (keys.length > 0) await this._client.del(keys)
const keys = await this._client.keys('uds-sess:*')
const bkeys = await this._client.keys('uds-bearer:*')
const ekeys = await this._client.keys('uds-emp:*')
const pkeys = await this._client.keys('uds-profile:*')
const all = [...keys, ...bkeys, ...ekeys, ...pkeys]
if (all.length) await this._client.del(all)
}
async get(empNo) {
const members = await this._client.sMembers(this._empKey(String(empNo || '').trim()))
for (const sid of members || []) {
const row = await this._load(sid)
if (row) return { ...row.userData, _sessionId: row.sessionId }
}
return null
}
async setex(empNo, seconds, userData) {
const { bearer, sessionId, record } = await this.create(
{ ...userData, empNo: String(empNo) },
seconds,
)
this._lastMint = { empNo: String(empNo), bearer, sessionId }
return record.userData
}
takeLastMint() {
const m = this._lastMint
this._lastMint = null
return m
}
async delete(empNo) {
await this.revokeEmpNo(empNo)
}
async has(empNo) {
return !!(await this.get(empNo))
}
async keys() {
const all = await this._client.keys('uds-session:*')
return all.map(k => k.replace(/^uds-session:/, ''))
const ekeys = await this._client.keys('uds-emp:*')
return ekeys.map((k) => k.replace(/^uds-emp:/, ''))
}
}

View file

@ -0,0 +1,167 @@
/**
* Extract local session bearer from Cookie / Header / Desktop bridge.
* EmpNo cookies (PORTALSSO*, UDS_FALLBACK_*) are never auth proofs.
*/
import { SESSION_COOKIE, SESSION_HEADER } from './store.js'
import { readBridgeFromRequest, readWsTicketFromRequest } from '../session-bridge.js'
import { requestIsLoopback } from '../skill-credentials.js'
function parseCookie(header, name) {
if (!header || typeof header !== 'string') return null
for (const part of header.split(';')) {
const idx = part.indexOf('=')
if (idx < 0) continue
if (part.slice(0, idx).trim() !== name) continue
try {
return decodeURIComponent(part.slice(idx + 1).trim())
} catch {
return part.slice(idx + 1).trim()
}
}
return null
}
/**
* @param {any} req
* @param {{ sessionBridge?: any, isLiveBearer?: (bearer: string) => boolean }} [deps]
* isLiveBearer: when given, a cookie bearer with no live session no longer shadows
* a valid WS ticket / bridge (stale UDS_SESSION → anonymous remote.mux).
* @returns {{ bearer: string, via: 'cookie'|'header'|'bridge', bridgeEmpNo?: string } | null}
*/
export function extractSessionBearer(req, deps = {}) {
const headers = req?.headers || {}
const fromHeader = String(headers[SESSION_HEADER] || headers['X-UDS-Session'] || '').trim()
if (fromHeader) return { bearer: fromHeader, via: 'header' }
const fromCookie = parseCookie(headers.cookie || '', SESSION_COOKIE)
if (fromCookie) {
const cookieHit = { bearer: fromCookie, via: 'cookie' }
if (typeof deps.isLiveBearer !== 'function') return cookieHit
let live = false
try { live = !!deps.isLiveBearer(fromCookie) } catch { live = false }
if (live) return cookieHit
return extractBridgeBearer(req, deps) || cookieHit
}
return extractBridgeBearer(req, deps)
}
/**
* Async variant for stores that can only read sessions asynchronously (Redis).
* @param {any} req
* @param {{ sessionBridge?: any, isLiveBearer?: (bearer: string) => Promise<boolean>|boolean }} [deps]
*/
export async function extractSessionBearerAsync(req, deps = {}) {
const headers = req?.headers || {}
const fromHeader = String(headers[SESSION_HEADER] || headers['X-UDS-Session'] || '').trim()
if (fromHeader) return { bearer: fromHeader, via: 'header' }
const fromCookie = parseCookie(headers.cookie || '', SESSION_COOKIE)
if (fromCookie) {
const cookieHit = { bearer: fromCookie, via: 'cookie' }
if (typeof deps.isLiveBearer !== 'function') return cookieHit
let live = false
try { live = !!(await deps.isLiveBearer(fromCookie)) } catch { live = false }
if (live) return cookieHit
return extractBridgeBearer(req, deps) || cookieHit
}
return extractBridgeBearer(req, deps)
}
/** WS ticket (upgrade only) → loopback bridge query (upgrade only) → bridge headers. */
function extractBridgeBearer(req, deps = {}) {
const headers = req?.headers || {}
if (deps.sessionBridge) {
try {
// D07/T01: one-time WS ticket only on WebSocket upgrade to remote.mux — never normal HTTP.
// 0.3.9 accepted Upgrade: websocket alone. 0.3.10 also required Connection to list
// "upgrade"; some Desktop/Electron stacks omit that and then never consume the ticket
// → workspace follow stays anonymous →「未分组」. Keep path+method+Upgrade gate.
const pathname = new URL(req?.url || '/', 'http://uds-auth.local').pathname
const upgrade = String(headers.upgrade || '').toLowerCase()
const connection = String(headers.connection || '').toLowerCase()
const connectionOk = !connection
|| connection.split(',').map((x) => x.trim()).includes('upgrade')
const isUpgrade = String(req?.method || 'GET').toUpperCase() === 'GET'
&& upgrade === 'websocket'
&& connectionOk
&& pathname === '/api/remote.mux'
const wsTicket = isUpgrade ? readWsTicketFromRequest(req) : null
if (wsTicket && typeof deps.sessionBridge.consumeWsTicket === 'function') {
const consumed = deps.sessionBridge.consumeWsTicket(wsTicket)
if (consumed?.bearer) {
return {
bearer: consumed.bearer,
via: 'ws_ticket',
bridgeEmpNo: consumed.empNo,
}
}
}
// Desktop 0.3.9 fallback: loopback remote.mux may carry bridge in the query
// when one-time ticket mint fails (sync XHR/CORS). Not accepted off-loopback.
if (isUpgrade && requestIsLoopback(req)) {
try {
const q = new URL(req?.url || '/', 'http://uds-auth.local').searchParams
const empNo = String(q.get('udsBridgeEmpNo') || '').trim()
const token = String(q.get('udsBridgeToken') || '').trim()
if (empNo && token && typeof deps.sessionBridge.verify === 'function') {
const ok = deps.sessionBridge.verify(empNo, token)
if (ok) {
return { bearer: token, via: 'bridge_query', bridgeEmpNo: ok.empNo }
}
}
} catch { /* ignore */ }
}
const hdr = readBridgeFromRequest(req)
if (hdr?.token) {
const ok = deps.sessionBridge.verify(hdr.empNo, hdr.token)
if (ok) {
// Bridge token itself is the local bearer (minted at login). Headers only.
return { bearer: hdr.token, via: 'bridge', bridgeEmpNo: ok.empNo }
}
}
} catch { /* ignore */ }
}
return null
}
/**
* Portal SSO cookies for login exchange only — not sufficient for principal.
*/
export function extractPortalCredentials(req) {
const cookie = req?.headers?.cookie || ''
const empNo = parseCookie(cookie, 'PORTALSSOUser')
|| parseCookie(cookie, 'ZTEDPGSSOUser')
const token = parseCookie(cookie, 'PORTALSSOCookie')
|| parseCookie(cookie, 'ZTEDPGSSOCookie')
if (!empNo || !token) return null
return { empNo: String(empNo), token: String(token) }
}
/** Display hint only — never authenticate. */
export function extractDisplayEmpNoHint(req) {
const cookie = req?.headers?.cookie || ''
return parseCookie(cookie, 'UDS_FALLBACK_UI')
|| parseCookie(cookie, 'UDS_FALLBACK_USER')
|| parseCookie(cookie, 'PORTALSSOUser')
|| parseCookie(cookie, 'ZTEDPGSSOUser')
|| null
}
export function buildSessionCookie(bearer, maxAgeSeconds, { secure = false } = {}) {
const parts = [
`${SESSION_COOKIE}=${encodeURIComponent(bearer)}`,
`Max-Age=${Math.max(60, Number(maxAgeSeconds) || 1800)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
]
if (secure) parts.push('Secure')
return parts.join('; ')
}
export function clearSessionCookie({ secure = false } = {}) {
const base = `${SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`
return secure ? `${base}; Secure` : base
}
export { SESSION_COOKIE, SESSION_HEADER, parseCookie }

View file

@ -1,22 +1,89 @@
/**
* Session store — 单索引: empNo → userData
*
* 一个工号 = 一个会话,不需要 sessionId,不需要额外 cookie。
* 多终端同工号自动共享同一个会话条目。
* Auth session store — opaque sessionId + random bearer.
* empNo is an attribute / secondary index, never the auth credential.
*/
export class SessionStore {
/** 按 empNo 获取会话 */
async get(empNo) { throw new Error('SessionStore#get must be implemented') }
/** 按 empNo 存储会话 */
async set(empNo, userData) { throw new Error('SessionStore#set must be implemented') }
/** 按 empNo 存储并设置过期(秒) */
async setex(empNo, seconds, userData) { throw new Error('SessionStore#setex must be implemented') }
/** 按 empNo 删除会话 */
async delete(empNo) { throw new Error('SessionStore#delete must be implemented') }
/** 检查 empNo 是否存在 */
async has(empNo) { throw new Error('SessionStore#has must be implemented') }
/** 清空所有会话 */
async clear() { throw new Error('SessionStore#clear must be implemented') }
/** 列出所有活跃会话 */
async keys() { throw new Error('SessionStore#keys must be implemented') }
/**
* Create a verified session.
* @param {object} userData
* @param {number} ttlSeconds
* @returns {Promise<{ sessionId: string, bearer: string, record: object }>}
*/
async create(userData, ttlSeconds) {
throw new Error('SessionStore#create must be implemented')
}
/** Lookup by bearer token (proof of possession). */
async getByBearer(bearer) {
throw new Error('SessionStore#getByBearer must be implemented')
}
/** Sync lookup for WS upgrade paths. */
getByBearerSync(bearer) {
throw new Error('SessionStore#getByBearerSync must be implemented')
}
async getBySessionId(sessionId) {
throw new Error('SessionStore#getBySessionId must be implemented')
}
async touch(sessionId, ttlSeconds) {
throw new Error('SessionStore#touch must be implemented')
}
async revokeSession(sessionId) {
throw new Error('SessionStore#revokeSession must be implemented')
}
async revokeBearer(bearer) {
throw new Error('SessionStore#revokeBearer must be implemented')
}
/** Revoke all devices for an empNo. */
async revokeEmpNo(empNo) {
throw new Error('SessionStore#revokeEmpNo must be implemented')
}
/** Profile cache only — never proves authentication. */
async setProfile(empNo, profile) {
throw new Error('SessionStore#setProfile must be implemented')
}
async getProfile(empNo) {
throw new Error('SessionStore#getProfile must be implemented')
}
async clear() {
throw new Error('SessionStore#clear must be implemented')
}
// --- legacy compat shims (empNo-keyed) — prefer create/getByBearer ---
/** @deprecated use getByBearer */
async get(empNo) {
throw new Error('SessionStore#get must be implemented')
}
/** @deprecated use create */
async setex(empNo, seconds, userData) {
throw new Error('SessionStore#setex must be implemented')
}
/** @deprecated use revokeEmpNo */
async delete(empNo) {
throw new Error('SessionStore#delete must be implemented')
}
async has(empNo) {
throw new Error('SessionStore#has must be implemented')
}
async keys() {
throw new Error('SessionStore#keys must be implemented')
}
}
export const SESSION_COOKIE = 'UDS_SESSION'
export const SESSION_HEADER = 'x-uds-session'
/** Absolute session lifetime; idle expiry (cookieMaxAge) slides within it. */
export const SESSION_ABSOLUTE_MAX_SECONDS = 7 * 24 * 60 * 60

View file

@ -1,28 +1,47 @@
/**
* Skill credential cache — separate from UI sessionStore.
* empNo → { token, updatedAt }; long TTL for cron/skills after UI logout.
* SEC-17: upstream tokens encrypted at rest (AES-256-GCM); plaintext only in memory.
*/
import { readFile, writeFile } from 'node:fs/promises'
import { readFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { mkdir } from 'node:fs/promises'
import { loadOrCreateSecretKey, sealSecret, openSecret } from './utils/secret-box.js'
import { atomicWriteJson } from './utils/atomic-write.js'
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000
export class SkillCredentialCache {
/**
* @param {{ file?: string, ttlMs?: number, logger?: Console }} opts
* @param {{ file?: string, ttlMs?: number, logger?: Console, dataDir?: string }} opts
*/
constructor(opts = {}) {
this._file = opts.file || null
this._dataDir = opts.dataDir || (opts.file ? dirname(opts.file) : null)
this._ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS
this._logger = opts.logger || console
/** @type {Map<string, { token: string, updatedAt: string, expiresAt: number }>} */
this._map = new Map()
this._dirty = false
this._flushTimer = null
/** @type {Buffer|null} */
this._key = null
}
async init() {
let hasEncryptedData = false
if (this._file) {
try {
const peek = await readFile(this._file, 'utf-8')
hasEncryptedData = /tokenEnc/.test(peek) && peek.trim().length > 2
} catch { /* absent */ }
}
if (this._dataDir) {
try {
this._key = await loadOrCreateSecretKey(this._dataDir, { hasEncryptedData })
} catch (err) {
this._logger?.error?.('[uds-auth] secret key unavailable:', err.message)
throw err
}
}
if (!this._file) return
try {
const raw = await readFile(this._file, 'utf-8')
@ -30,16 +49,35 @@ export class SkillCredentialCache {
const entries = data?.entries && typeof data.entries === 'object' ? data.entries : data
if (!entries || typeof entries !== 'object') return
const now = Date.now()
let decryptFailures = 0
for (const [empNo, row] of Object.entries(entries)) {
if (!row?.token) continue
if (!row) continue
const expiresAt = Number(row.expiresAt) || (now + this._ttlMs)
if (expiresAt <= now) continue
let token = null
if (row.tokenEnc && this._key) {
token = openSecret(this._key, row.tokenEnc)
if (!token) decryptFailures++
} else if (row.token) {
// Legacy plaintext — accept once, rewrite encrypted on next flush.
token = String(row.token)
this._dirty = true
}
if (!token) continue
this._map.set(String(empNo), {
token: String(row.token),
token,
updatedAt: row.updatedAt || new Date().toISOString(),
expiresAt,
})
}
if (decryptFailures > 0) {
this._logger?.error?.(
'[uds-auth] skill credential decrypt failed for',
decryptFailures,
'entries — check secret key (not silently replaced)',
)
}
if (this._dirty) this._scheduleFlush()
} catch {
/* optional file */
}
@ -95,23 +133,29 @@ export class SkillCredentialCache {
this._flushTimer = null
void this._flush()
}, 250)
if (typeof this._flushTimer.unref === 'function') this._flushTimer.unref()
}
async _flush() {
if (!this._file || !this._dirty) return
this._dirty = false
if (!this._key) {
this._logger?.error?.('[uds-auth] skillCredentialCache flush skipped: no key')
this._dirty = true
return
}
const entries = {}
for (const [empNo, row] of this._map.entries()) {
entries[empNo] = {
token: row.token,
tokenEnc: sealSecret(this._key, row.token),
updatedAt: row.updatedAt,
expiresAt: row.expiresAt,
}
}
try {
await mkdir(dirname(this._file), { recursive: true })
await writeFile(this._file, JSON.stringify({ entries }, null, 2), 'utf-8')
await atomicWriteJson(this._file, { entries, v: 2 }, { mode: 0o600 })
} catch (err) {
this._dirty = true
this._logger?.warn?.('[uds-auth] skillCredentialCache flush failed:', err.message)
}
}
@ -123,10 +167,10 @@ export function isLoopbackAddress(addr) {
return a === '127.0.0.1' || a === '::1' || a === 'localhost'
}
/**
* Real socket peer only — never trust X-Forwarded-For / X-Real-IP (SEC-05).
*/
export function requestIsLoopback(req) {
const ra = req?.socket?.remoteAddress
if (isLoopbackAddress(ra)) return true
const xf = String(req?.headers?.['x-forwarded-for'] || '').split(',')[0].trim()
if (xf && isLoopbackAddress(xf)) return true
return false
return isLoopbackAddress(ra)
}

View file

@ -0,0 +1,59 @@
import { readFile, copyFile, unlink, lstat } from 'node:fs/promises'
import { constants } from 'node:fs'
import { resolve } from 'node:path'
import { atomicWriteJson } from './utils/atomic-write.js'
export const SECURITY_STATE_FILES = ['roles.json', 'session-owners.json', 'user-workspaces.json',
'session-bridge.json', 'skill-credentials.json', 'config.runtime.json', '.uds-auth-secret-key']
async function readOptional(path) {
try {
const stat = await lstat(path)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('legacy_migrate_unsafe_file')
return await readFile(path)
} catch (err) { if (err.code === 'ENOENT') return null; throw err }
}
/** Exclusive copies, no overwrite, identical partial copies can resume after a crash. */
export async function migrateState(dataDir, sourceDir, logger = console, io = {}) {
const marker = resolve(dataDir, '.uds-auth-migrated-from-install')
if (await readOptional(marker)) return { migrated: false, reason: 'already' }
const present = []
for (const name of SECURITY_STATE_FILES) {
const bytes = await readOptional(resolve(sourceDir, name))
if (bytes) present.push({ name, bytes })
}
if (!present.length) return { migrated: false, reason: 'no_legacy' }
for (const { name, bytes } of present) {
const existing = await readOptional(resolve(dataDir, name))
if (existing && !existing.equals(bytes)) {
throw Object.assign(new Error('legacy_migrate_conflict'), { code: 'legacy_migrate_conflict', conflicts: [name] })
}
}
const copied = []
try {
for (const { name, bytes } of present) {
const dest = resolve(dataDir, name)
try {
await (io.copyFile || copyFile)(resolve(sourceDir, name), dest, constants.COPYFILE_EXCL)
copied.push(name)
} catch (err) {
if (err.code !== 'EEXIST' || !(await readOptional(dest))?.equals(bytes)) throw err
}
if (!(await readOptional(dest))?.equals(bytes)) throw new Error('legacy_migrate_source_changed')
}
await atomicWriteJson(marker, { at: new Date().toISOString(), from: resolve(sourceDir),
copied, seen: present.map(row => row.name) })
} catch (err) {
// Remove only files created by this attempt which still equal the source snapshot.
for (const name of copied) {
const dest = resolve(dataDir, name)
const bytes = present.find(row => row.name === name).bytes
try { if ((await readOptional(dest))?.equals(bytes)) await unlink(dest) } catch { /* preserve evidence */ }
}
logger.error?.('[uds-auth] legacy migration failed; no success marker written')
throw err
}
logger.info?.('[uds-auth] legacy state migration committed')
return { migrated: true, copied, present: present.map(row => row.name) }
}

View file

@ -0,0 +1,145 @@
/**
* R06: short-lived task capabilities for skill/cron (not browser session bearers).
* Bound to empNo + optional dshSessionId + audience + scope + expiry; revocable.
*/
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
function hashToken(token) {
return createHash('sha256').update(String(token)).digest('hex')
}
function safeEqualHex(a, b) {
if (!a || !b || a.length !== b.length) return false
try {
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'))
} catch {
return false
}
}
export class TaskCapabilityStore {
/**
* @param {{ defaultTtlSeconds?: number, maxTtlSeconds?: number }} [opts]
*/
constructor(opts = {}) {
this._defaultTtl = Math.max(60, Number(opts.defaultTtlSeconds) || 3600)
this._maxTtl = Math.max(this._defaultTtl, Number(opts.maxTtlSeconds) || 24 * 3600)
/** @type {Map<string, object>} id → record */
this._byId = new Map()
/** @type {Map<string, string>} tokenHash → id */
this._byHash = new Map()
}
/**
* @param {{
* empNo: string,
* dshSessionId?: string|null,
* audience?: string,
* scopes?: string[],
* ttlSeconds?: number,
* mintedBy?: string,
* }} input
*/
mint(input) {
for (const [id, row] of this._byId) {
if (row.expiresAt <= Date.now()) this.revoke(id)
}
if (this._byId.size >= 5000) throw new Error('capability_capacity')
const empNo = String(input?.empNo || '').trim()
if (!empNo) throw new Error('capability_empNo_required')
const ttl = Math.min(
this._maxTtl,
Math.max(30, Number(input.ttlSeconds) || this._defaultTtl),
)
const id = randomBytes(12).toString('hex')
const token = randomBytes(32).toString('hex')
const tokenHash = hashToken(token)
const now = Date.now()
const record = {
id,
tokenHash,
empNo,
dshSessionId: input.dshSessionId ? String(input.dshSessionId) : null,
audience: String(input.audience || 'uds-auth-agent'),
scopes: Array.isArray(input.scopes) ? input.scopes.map(String) : ['outbound', 'credentials'],
mintedBy: input.mintedBy || null,
createdAt: now,
expiresAt: now + ttl * 1000,
revoked: false,
}
this._byId.set(id, record)
this._byHash.set(tokenHash, id)
return {
id,
token,
empNo: record.empNo,
dshSessionId: record.dshSessionId,
audience: record.audience,
scopes: record.scopes,
expiresAt: record.expiresAt,
expiresIn: ttl,
}
}
/**
* @param {string} token
* @param {{ audience?: string, scope?: string, dshSessionId?: string|null }} [opts]
*/
verify(token, opts = {}) {
const t = String(token || '').trim()
if (!t || t.length < 16) return null
const id = this._byHash.get(hashToken(t))
if (!id) return null
const row = this._byId.get(id)
if (!row || row.revoked) return null
if (row.expiresAt && Date.now() > row.expiresAt) {
this.revoke(id)
return null
}
if (!safeEqualHex(row.tokenHash, hashToken(t))) return null
if (opts.audience && row.audience !== opts.audience) return null
if (opts.scope && !(row.scopes || []).includes(opts.scope)) return null
if (opts.dshSessionId != null && row.dshSessionId
&& String(opts.dshSessionId) !== String(row.dshSessionId)) {
return null
}
return {
id: row.id,
empNo: row.empNo,
dshSessionId: row.dshSessionId,
audience: row.audience,
scopes: [...(row.scopes || [])],
expiresAt: row.expiresAt,
}
}
revoke(id) {
const row = this._byId.get(String(id || ''))
if (!row) return false
row.revoked = true
this._byHash.delete(row.tokenHash)
this._byId.delete(row.id)
return true
}
revokeEmpNo(empNo) {
const e = String(empNo || '').trim()
let n = 0
for (const [id, row] of [...this._byId]) {
if (row.empNo === e) {
this.revoke(id)
n++
}
}
return n
}
extractFromRequest(req) {
const h = req?.headers || {}
return (
h['x-uds-task-capability']
|| h['X-UDS-Task-Capability']
|| null
)
}
}

View file

@ -0,0 +1,60 @@
/** Host shellEnv adapter. Values are rebuilt after ordinary environment entries. */
export function createTaskEnvironment({ getOwner, rolesStore, capabilities, getWebServer }) {
const issued = new Map()
const release = execution => {
const key = execution?.token || execution
const row = issued.get(key)
if (!row) return
capabilities.revoke(row.id)
row.signal?.removeEventListener('abort', row.abort)
clearTimeout(row.timer)
row.off?.()
issued.delete(key)
}
const contributor = {
name: 'uds-auth',
variables: {
DSH_UDS_TASK_CAPABILITY: { description: 'Short-lived session-bound UDS outbound capability; never print or persist.' },
DSH_UDS_AUTH_BASE: { description: 'Owned loopback uds-auth endpoint of this Host.' },
},
resolve(execution) {
release(execution)
const empty = { DSH_UDS_TASK_CAPABILITY: '', DSH_UDS_AUTH_BASE: '' }
const sid = execution?.agent?.session?.header?.id
const owner = sid ? getOwner(String(sid)) : null
const server = getWebServer()
const port = Number(server?.port)
if (!owner || rolesStore.isDisabled(owner) || !Number.isInteger(port) || port <= 0 || port > 65535
|| execution?.signal?.aborted) return empty
const cap = capabilities.mint({
empNo: owner, dshSessionId: String(sid), audience: 'uds-auth-agent',
scopes: ['outbound'], ttlSeconds: 600, mintedBy: 'host:shellEnv',
})
const row = { id: cap.id, sessionId: String(sid), signal: execution.signal, abort: () => release(execution) }
issued.set(execution.token || execution, row)
row.signal?.addEventListener('abort', row.abort, { once: true })
row.timer = setTimeout(() => release(execution), cap.expiresAt - Date.now())
row.timer.unref?.()
return { DSH_UDS_TASK_CAPABILITY: cap.token, DSH_UDS_AUTH_BASE: `http://127.0.0.1:${port}/uds-auth` }
},
}
return { contributor, release, finish(execution, result, jobs) {
const row = issued.get(execution?.token || execution)
const value = result?.value
if (row && !result?.isError && ['background', 'promoted'].includes(value?.kind)
&& typeof value.jobId === 'string' && jobs?.events?.subscribe) {
const job = jobs.list(row.sessionId).find(job => job.id === value.jobId)
if (job && ['running', 'stopping'].includes(job.status)) {
row.signal?.removeEventListener('abort', row.abort)
row.signal = null
row.off = jobs.events.subscribe({ owner: row.sessionId }, event => {
if (event.job?.id === value.jobId && ['settled', 'stopping', 'removed'].includes(event.type)) release(execution)
})
return
}
}
release(execution)
}, dispose() {
for (const key of [...issued.keys()]) release({ token: key })
} }
}

View file

@ -2,13 +2,17 @@
* Outbound HTTP(S) that must NOT use HTTP_PROXY / HTTPS_PROXY.
* ZTE icenter / UAC user APIs are intranet-only; corporate forward proxies
* typically return 401/403 and never reach the real service.
*
* SEC-16: subject must strictly equal requested empNo — no substring / digit-contains match.
*/
import http from 'node:http'
import https from 'node:https'
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024
/**
* @param {string|URL} url
* @param {{ method?: string, headers?: Record<string,string|number>, body?: string|Buffer, timeoutMs?: number }} opts
* @param {{ method?: string, headers?: Record<string,string|number>, body?: string|Buffer, timeoutMs?: number, maxBytes?: number }} opts
* @returns {Promise<{ statusCode: number, headers: object, body: string, json: any }>}
*/
export function directRequest(url, opts = {}) {
@ -21,7 +25,8 @@ export function directRequest(url, opts = {}) {
if (body && headers['Content-Length'] == null && headers['content-length'] == null) {
headers['Content-Length'] = body.length
}
const timeoutMs = opts.timeoutMs ?? 3000
const timeoutMs = Math.min(Math.max(Number(opts.timeoutMs) || 3000, 500), 60000)
const maxBytes = opts.maxBytes ?? MAX_RESPONSE_BYTES
// Fresh Agent — never inherit globalAgent (often patched by proxy bootstraps).
const agent = new lib.Agent({ keepAlive: false })
@ -38,7 +43,16 @@ export function directRequest(url, opts = {}) {
timeout: timeoutMs,
}, (res) => {
const chunks = []
res.on('data', (c) => chunks.push(c))
let total = 0
res.on('data', (c) => {
total += c.length
if (total > maxBytes) {
req.destroy()
reject(new Error(`directRequest response too large (>${maxBytes})`))
return
}
chunks.push(c)
})
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
let json = null
@ -61,8 +75,20 @@ export function directRequest(url, opts = {}) {
})
}
function extractSubject(row) {
if (!row || typeof row !== 'object') return ''
return String(
row.employeeShortId || row.employeeNO || row.empUIID || row.empNo || '',
).trim()
}
function subjectsEqual(a, b) {
return String(a || '').trim() === String(b || '').trim()
}
/**
* POST userSearchUrl with X-Emp-No + X-Auth-Value (intranet, no proxy).
* Success requires: HTTP 2xx, business success code, exactly one subject-matching row.
*/
export async function searchUserByEmpNoToken({
userSearchUrl,
@ -76,14 +102,15 @@ export async function searchUserByEmpNoToken({
if (!userSearchUrl || !empNo || !token) {
return { ok: false, reason: 'missing_args' }
}
const want = String(empNo).trim()
const body = JSON.stringify({
employeeShortId: empNo,
employeeShortId: want,
enableLabel: true,
keyword: empNo,
keyword: want,
})
const headers = {
'Content-Type': 'application/json;charset=UTF-8',
[empNoHeader]: empNo,
[empNoHeader]: want,
[authValueHeader]: token,
}
if (origin) {
@ -98,18 +125,21 @@ export async function searchUserByEmpNoToken({
return { ok: false, reason: 'network', error: err.message }
}
if (res.statusCode === 401 || res.statusCode === 403) {
// Only accept 2xx — do not treat HTTP 500 + biz success as ok (SEC-16).
if (res.statusCode < 200 || res.statusCode >= 300) {
return {
ok: false,
reason: 'http_auth',
reason: res.statusCode === 401 || res.statusCode === 403 ? 'http_auth' : 'http_status',
statusCode: res.statusCode,
msg: res.json?.code?.msg || res.json?.msg || res.body.slice(0, 200),
hint: 'userSearchUrl must be reachable on intranet WITHOUT HTTP(S)_PROXY',
hint: res.statusCode === 401 || res.statusCode === 403
? 'userSearchUrl must be reachable on intranet WITHOUT HTTP(S)_PROXY'
: undefined,
}
}
const result = res.json
if (!result) {
if (!result || typeof result !== 'object') {
return { ok: false, reason: 'bad_json', statusCode: res.statusCode, raw: res.body.slice(0, 300) }
}
@ -129,35 +159,42 @@ export async function searchUserByEmpNoToken({
: Array.isArray(result?.bo?.list) ? result.bo.list
: []
if (!list.length) {
return { ok: false, reason: 'empty', statusCode: res.statusCode, result }
return { ok: false, reason: 'empty', statusCode: res.statusCode }
}
const emp = list[0]
const resolvedEmpNo = String(
emp.employeeShortId || emp.employeeNO || emp.empUIID || emp.empNo || empNo,
).trim()
if (resolvedEmpNo && resolvedEmpNo !== String(empNo).trim()
&& !String(empNo).includes(resolvedEmpNo)
&& !resolvedEmpNo.includes(String(empNo).trim())) {
const a = String(empNo).replace(/\D/g, '')
const b = resolvedEmpNo.replace(/\D/g, '')
if (!a || !b || !(a.includes(b) || b.includes(a))) {
return { ok: false, reason: 'emp_mismatch', resolvedEmpNo }
// Strict subject equality — no substring / digit-contains (SEC-16).
const matches = list.filter((row) => subjectsEqual(extractSubject(row), want))
if (matches.length !== 1) {
return {
ok: false,
reason: matches.length === 0 ? 'emp_mismatch' : 'ambiguous',
statusCode: res.statusCode,
matchCount: matches.length,
}
}
const emp = matches[0]
const subject = extractSubject(emp)
if (!subject || !subjectsEqual(subject, want)) {
return { ok: false, reason: 'emp_mismatch', resolvedEmpNo: subject }
}
// Reserved / emergency account names never come from UAC search.
if (subject === 'administrator' || subject.startsWith('__')) {
return { ok: false, reason: 'reserved_subject' }
}
return {
ok: true,
profile: {
empNo: String(empNo).trim(),
username: emp.name || emp.empName || emp.userName || empNo,
empNo: subject,
username: emp.name || emp.empName || emp.userName || subject,
department: emp.deptFullName || emp.deptName || emp.deptShortName || emp.orgNamePath || emp.orgName || emp.department || '',
organization: emp.orgNamePath || emp.orgName || '',
email: emp.email || emp.mail || '',
phone: emp.mobile || emp.phone || '',
raw: emp,
token,
// Omit raw upstream blob from default profile to limit accidental logging.
token: undefined,
},
result,
result: { code: result.code },
}
}

View file

@ -0,0 +1,51 @@
/**
* SEC-21: atomic JSON replace — write temp in same dir, then rename.
*/
import { open, rename, mkdir, unlink } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { randomBytes } from 'node:crypto'
/**
* @param {string} filePath
* @param {string|Buffer} data
* @param {{ mode?: number }} [opts]
*/
export async function atomicWriteFile(filePath, data, opts = {}) {
const dir = dirname(filePath)
await mkdir(dir, { recursive: true })
const tmp = join(dir, `.${randomBytes(8).toString('hex')}.tmp`)
const mode = opts.mode ?? 0o600
let handle, committed = false
try {
handle = await open(tmp, 'wx', mode)
await handle.writeFile(data, typeof data === 'string' ? 'utf-8' : undefined)
await handle.sync()
await handle.close()
handle = null
await rename(tmp, filePath)
committed = true
if (process.platform !== 'win32') {
// Rename is the commit point. A filesystem that cannot fsync directories
// must not make callers roll back memory after the replacement succeeded.
let directory
try { directory = await open(dir, 'r'); await directory.sync() }
catch { /* directory durability is best effort on unsupported filesystems */ }
finally { await directory?.close().catch(() => {}) }
}
} finally {
await handle?.close()
if (!committed) await unlink(tmp).catch(err => { if (err.code !== 'ENOENT') throw err })
}
}
/**
* @param {string} filePath
* @param {any} obj
* @param {{ mode?: number, pretty?: boolean }} [opts]
*/
export async function atomicWriteJson(filePath, obj, opts = {}) {
const text = opts.pretty === false
? JSON.stringify(obj)
: JSON.stringify(obj, null, 2)
await atomicWriteFile(filePath, text + '\n', { mode: opts.mode })
}

View file

@ -0,0 +1,59 @@
/**
* SEC-25: structured security audit events (no secrets).
*/
import { randomBytes } from 'node:crypto'
const SECRET_KEYS = /^(token|password|passphrase|authorization|cookie|authvalue|sso|bearer|secret|key)$/i
/**
* @param {unknown} value
* @returns {unknown}
*/
export function redactValue(value) {
if (value == null) return value
if (typeof value === 'string') {
if (value.length > 64) return value.slice(0, 8) + '…(' + value.length + ')'
return value
}
if (Array.isArray(value)) return value.map(redactValue)
if (typeof value === 'object') {
const out = {}
for (const [k, v] of Object.entries(value)) {
if (SECRET_KEYS.test(k) || /token|password|cookie|secret|auth/i.test(k)) {
out[k] = '[redacted]'
} else {
out[k] = redactValue(v)
}
}
return out
}
return value
}
/**
* @param {object} event
* @param {{ logger?: Console }} [opts]
*/
export function audit(event, opts = {}) {
const logger = opts.logger || console
const row = {
ts: new Date().toISOString(),
type: 'uds-auth.audit',
correlationId: event.correlationId || randomBytes(8).toString('hex'),
action: event.action,
decision: event.decision || 'info',
reasonCode: event.reasonCode || null,
actor: event.actor || null,
target: event.target || null,
resource: event.resource || null,
service: event.service || 'uds-auth',
meta: event.meta ? redactValue(event.meta) : undefined,
}
const line = JSON.stringify(row)
if (event.decision === 'deny' || event.decision === 'error') {
logger.warn?.(line)
} else {
logger.info?.(line)
}
return row
}

View file

@ -0,0 +1,136 @@
/**
* SEC-19 / R13 / A09: Origin / Host checks for /uds-auth routes.
* trustedHosts allowlist (exact host[:port]). Empty allowlist = loopback Host only.
* Desktop (null / missing Origin) allowed only for loopback on mutating methods.
*/
import { requestIsLoopback } from '../skill-credentials.js'
function normalizeHost(h, protocolHint) {
const lower = String(h || '').toLowerCase()
if (lower.endsWith(':443') && (protocolHint === 'https:' || !protocolHint)) {
return lower.slice(0, -4)
}
if (lower.endsWith(':80') && (protocolHint === 'http:' || !protocolHint)) {
return lower.slice(0, -3)
}
return lower
}
function hostIsLoopback(host) {
const raw = String(host || '').trim().toLowerCase()
// F08: parse bracketed IPv6 authority ([::1]:3000) — do not split on ':'.
let hostname = raw
try {
hostname = new URL('http://' + raw).hostname
} catch {
if (raw.startsWith('[')) {
const end = raw.indexOf(']')
hostname = end > 0 ? raw.slice(0, end + 1) : raw
} else {
hostname = normalizeHost(raw).split(':')[0]
}
}
// Node may return hostname as ::1 or [::1] depending on version.
const bare = hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname
return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1'
}
/**
* @param {import('node:http').IncomingMessage} req
* @param {{ allowNullOrigin?: boolean, trustedHosts?: string[]|(() => string[]) }} [opts]
* @returns {{ ok: boolean, reason?: string }}
*/
export function checkRequestOrigin(req, opts = {}) {
const method = opts.upgrade ? 'POST' : String(req?.method || 'GET').toUpperCase()
const host = String(req?.headers?.host || '').trim().toLowerCase()
if (!host) {
return { ok: false, reason: 'missing_host' }
}
if (host.includes('@') || host.includes('\\') || /[\s\0/?#]/.test(host)) {
return { ok: false, reason: 'invalid_host' }
}
try {
const parsedHost = new URL('http://' + host)
if (!parsedHost.hostname || parsedHost.username || parsedHost.password || parsedHost.pathname !== '/') {
return { ok: false, reason: 'invalid_host' }
}
} catch { return { ok: false, reason: 'invalid_host' } }
const trustedRaw = typeof opts.trustedHosts === 'function'
? opts.trustedHosts()
: opts.trustedHosts
const trusted = Array.isArray(trustedRaw)
? trustedRaw.map((h) => normalizeHost(h)).filter(Boolean)
: []
const reqHost = normalizeHost(host)
if (trusted.length) {
if (!trusted.includes(reqHost)) {
return { ok: false, reason: 'host_not_trusted' }
}
} else if (!hostIsLoopback(host)) {
// A09: empty trustedHosts = loopback-only default (no silent open allowlist).
return { ok: false, reason: 'host_not_trusted' }
}
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
return { ok: true }
}
const origin = String(req?.headers?.origin || '').trim()
const fetchSite = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
if (fetchSite === 'cross-site') {
return { ok: false, reason: 'cross_site' }
}
if (!origin || origin === 'null') {
if (opts.allowNullOrigin !== false && requestIsLoopback(req)) {
return { ok: true }
}
if (!origin && requestIsLoopback(req)) return { ok: true }
return { ok: false, reason: 'missing_origin' }
}
let originUrl
try {
originUrl = new URL(origin)
} catch {
return { ok: false, reason: 'invalid_origin' }
}
// Only the renderer's owned scheme authority may represent Desktop.
if (originUrl.protocol === 'dsh-app:') {
return requestIsLoopback(req) && originUrl.hostname === 'app' && !originUrl.port
&& !originUrl.username && !originUrl.password && originUrl.pathname === ''
? { ok: true } : { ok: false, reason: 'origin_mismatch' }
}
if (!['http:', 'https:'].includes(originUrl.protocol) || originUrl.username || originUrl.password
|| !['', '/'].includes(originUrl.pathname) || originUrl.search || originUrl.hash) {
return { ok: false, reason: 'invalid_origin' }
}
const originHost = normalizeHost(originUrl.host, originUrl.protocol)
const hostForOrigin = normalizeHost(host, originUrl.protocol)
if (originHost !== hostForOrigin) {
return { ok: false, reason: 'origin_mismatch' }
}
if (trusted.length && !trusted.includes(originHost)) {
return { ok: false, reason: 'origin_not_trusted' }
}
return { ok: true }
}
/**
* Reject non-JSON mutating Content-Types for state-changing APIs.
*/
export function requireJsonContentType(req) {
const method = String(req?.method || 'GET').toUpperCase()
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return { ok: true }
const ct = String(req?.headers?.['content-type'] || '').toLowerCase()
if (!ct.includes('application/json')) {
return { ok: false, reason: 'content_type' }
}
return { ok: true }
}

View file

@ -0,0 +1,47 @@
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
// A shared bound across password login, rotation and sealed-box unlock. Reject
// overload instead of keeping unbounded request/password buffers in memory.
const MAX_RUNNING = 2
const MAX_WAITING = 8
let running = 0
const waiting = []
export async function derivePasswordKey(password, salt, length = 32) {
if (typeof password !== 'string' || Buffer.byteLength(password) > 1024) {
throw Object.assign(new Error('password_invalid'), { code: 'password_invalid' })
}
if (running >= MAX_RUNNING) {
if (waiting.length >= MAX_WAITING) {
throw Object.assign(new Error('kdf_busy'), { code: 'kdf_busy' })
}
await new Promise(resolve => waiting.push(resolve))
} else running++
try {
return await new Promise((resolve, reject) => {
scrypt(password, salt, length, { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 },
(err, key) => err ? reject(err) : resolve(key))
})
} finally {
const next = waiting.shift()
if (next) next()
else running--
}
}
export async function hashPasswordAsync(password) {
const salt = randomBytes(16)
const key = await derivePasswordKey(password, salt)
return `scrypt$16384$8$1$${salt.toString('base64url')}$${key.toString('base64url')}`
}
export async function verifyScryptAsync(password, stored) {
const parts = String(stored).split('$')
// Never accept attacker-controlled KDF work factors or output sizes.
if (parts.length !== 6 || parts.slice(0, 4).join('$') !== 'scrypt$16384$8$1') return false
const salt = Buffer.from(parts[4], 'base64url')
const expected = Buffer.from(parts[5], 'base64url')
if (salt.length !== 16 || expected.length !== 32) return false
const actual = await derivePasswordKey(password, salt)
return timingSafeEqual(actual, expected)
}

View file

@ -0,0 +1,58 @@
/**
* SEC-23: simple in-memory rate limiter with bucket cleanup.
* Keyed by real socket address (not XFF).
*/
export class RateLimiter {
/**
* @param {{ windowMs?: number, max?: number, maxBuckets?: number }} [opts]
*/
constructor(opts = {}) {
this._windowMs = opts.windowMs ?? 60_000
this._max = opts.max ?? 30
this._maxBuckets = opts.maxBuckets ?? 10_000
/** @type {Map<string, { count: number, resetAt: number }>} */
this._buckets = new Map()
this._sweepTimer = setInterval(() => this._sweep(), Math.max(this._windowMs, 30_000))
if (typeof this._sweepTimer.unref === 'function') this._sweepTimer.unref()
}
_sweep() {
const now = Date.now()
for (const [k, b] of this._buckets) {
if (now > b.resetAt) this._buckets.delete(k)
}
}
/**
* @param {string} key
* @returns {boolean} true if allowed
*/
allow(key) {
const id = String(key || 'unknown')
const now = Date.now()
let bucket = this._buckets.get(id)
if (!bucket || now > bucket.resetAt) {
if (this._buckets.size >= this._maxBuckets) this._sweep()
if (this._buckets.size >= this._maxBuckets) {
// Evict oldest-ish entry
const first = this._buckets.keys().next().value
if (first != null) this._buckets.delete(first)
}
bucket = { count: 0, resetAt: now + this._windowMs }
this._buckets.set(id, bucket)
}
bucket.count++
return bucket.count <= this._max
}
dispose() {
clearInterval(this._sweepTimer)
this._buckets.clear()
}
}
export function clientKey(req) {
const ra = req?.socket?.remoteAddress || 'unknown'
return String(ra).replace(/^::ffff:/i, '')
}

View file

@ -0,0 +1,49 @@
/**
* SEC-23: bounded body readers.
*/
export const DEFAULT_MAX_BODY = 256 * 1024
export const MAX_QR_PROXY_BODY = 64 * 1024
export const MAX_QR_DATA_LEN = 2048
/**
* @param {import('node:http').IncomingMessage} req
* @param {{ maxBytes?: number }} [opts]
* @returns {Promise<string>}
*/
export async function readBodyLimited(req, opts = {}) {
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BODY
const chunks = []
let total = 0
for await (const chunk of req) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
total += buf.length
if (total > maxBytes) {
const err = new Error('payload_too_large')
err.code = 'payload_too_large'
err.statusCode = 413
// Drain remaining to free the socket (best-effort).
try { req.resume() } catch { /* ignore */ }
throw err
}
chunks.push(buf)
}
return Buffer.concat(chunks).toString('utf8')
}
/**
* @param {import('node:http').IncomingMessage} req
* @param {{ maxBytes?: number }} [opts]
* @returns {Promise<object>}
*/
export async function readJsonBodyLimited(req, opts = {}) {
const raw = await readBodyLimited(req, opts)
if (!raw) return {}
try {
return JSON.parse(raw)
} catch {
const err = new Error('invalid_json')
err.code = 'invalid_json'
err.statusCode = 400
throw err
}
}

View file

@ -0,0 +1,52 @@
/**
* SEC-14: normalize empNo / user directory segment — reject path semantics.
*/
import { resolve, join, sep } from 'node:path'
const MAX_LEN = 64
const SAFE_RE = /^[A-Za-z0-9._@+-]+$/
const WIN_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i
/**
* @param {unknown} empNo
* @returns {string|null} sanitized id or null if rejected
*/
export function sanitizeUserId(empNo) {
if (empNo == null) return null
const s = String(empNo).trim()
if (!s || s.length > MAX_LEN) return null
if (s.includes('\0') || s.includes('..') || s.includes('/') || s.includes('\\') || s.includes(':')) {
return null
}
if (!SAFE_RE.test(s)) return null
if (WIN_RESERVED.test(s)) return null
return s
}
/**
* Resolve user workspace directory under root; returns null if escape would occur.
* @param {string} workspaceRootAbs
* @param {string} empNo
* @returns {string|null}
*/
export function resolveUserDir(workspaceRootAbs, empNo) {
const safe = sanitizeUserId(empNo)
if (!safe) return null
const root = resolve(String(workspaceRootAbs))
const userPath = resolve(join(root, safe))
if (userPath !== root && !userPath.startsWith(root + sep)) return null
return userPath
}
/**
* True when candidate is equal to or under base (after resolve).
*/
export function isPathInside(baseAbs, candidateAbs) {
try {
const base = resolve(String(baseAbs))
const cand = resolve(String(candidateAbs))
return cand === base || cand.startsWith(base + sep)
} catch {
return false
}
}

View file

@ -0,0 +1,135 @@
/**
* SEC-17 / R11: encrypt upstream secrets at rest (AES-256-GCM).
* Key from UDS_AUTH_SECRET_KEY (64 hex) or plugin-local key file.
* Invalid env / corrupt key file fail closed — never silently replace.
*/
import { createCipheriv, createDecipheriv, randomBytes, createHash, scryptSync } from 'node:crypto'
import { readFile, writeFile, mkdir, chmod, access } from 'node:fs/promises'
import { constants as fsConstants } from 'node:fs'
import { dirname, join } from 'node:path'
const MAGIC = 'uds-secret-v1'
function keyFilePath(dataDir) {
return join(dataDir, '.uds-auth-secret-key')
}
/**
* @param {string} dataDir plugin data directory
* @param {{ hasEncryptedData?: boolean }} [opts]
* @returns {Promise<Buffer>} 32-byte key
*/
export async function loadOrCreateSecretKey(dataDir, opts = {}) {
const env = String(process.env.UDS_AUTH_SECRET_KEY || '').trim()
if (env) {
if (!/^[0-9a-fA-F]{64}$/.test(env)) {
const err = new Error('invalid_secret_key_env')
err.code = 'invalid_secret_key_env'
throw err
}
return Buffer.from(env, 'hex')
}
const keyFile = keyFilePath(dataDir)
let fileExists = false
try {
await access(keyFile, fsConstants.F_OK)
fileExists = true
} catch { /* absent */ }
if (fileExists) {
let raw
try {
raw = (await readFile(keyFile, 'utf-8')).trim()
} catch (err) {
const e = new Error('secret_key_unreadable')
e.code = 'secret_key_unreadable'
e.cause = err
throw e
}
if (!/^[0-9a-fA-F]{64}$/.test(raw)) {
const e = new Error('secret_key_corrupt')
e.code = 'secret_key_corrupt'
throw e
}
return Buffer.from(raw, 'hex')
}
// Only mint a new key on a confirmed empty deployment (no prior key file).
if (opts.hasEncryptedData) {
const e = new Error('secret_key_missing')
e.code = 'secret_key_missing'
throw e
}
const key = randomBytes(32)
await mkdir(dirname(keyFile), { recursive: true })
// Exclusive create — avoid concurrent overwrite races.
const { open } = await import('node:fs/promises')
let fh
try {
fh = await open(keyFile, 'wx')
await fh.writeFile(key.toString('hex') + '\n', { encoding: 'utf8' })
} catch (err) {
if (err.code === 'EEXIST') {
const raw = (await readFile(keyFile, 'utf-8')).trim()
if (/^[0-9a-fA-F]{64}$/.test(raw)) return Buffer.from(raw, 'hex')
const e = new Error('secret_key_corrupt')
e.code = 'secret_key_corrupt'
throw e
}
throw err
} finally {
try { await fh?.close() } catch { /* ignore */ }
}
try { await chmod(keyFile, 0o600) } catch { /* win */ }
return key
}
/**
* @param {Buffer} key
* @param {string} plaintext
* @returns {string} sealed blob
*/
export function sealSecret(key, plaintext) {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const enc = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return [
MAGIC,
iv.toString('base64url'),
tag.toString('base64url'),
enc.toString('base64url'),
].join('.')
}
/**
* @param {Buffer} key
* @param {string} blob
* @returns {string|null}
*/
export function openSecret(key, blob) {
const parts = String(blob || '').split('.')
if (parts.length !== 4 || parts[0] !== MAGIC) return null
try {
const iv = Buffer.from(parts[1], 'base64url')
const tag = Buffer.from(parts[2], 'base64url')
const enc = Buffer.from(parts[3], 'base64url')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
} catch {
return null
}
}
/** Stable fingerprint for logs — never the secret itself. */
export function secretFingerprint(value) {
return createHash('sha256').update(String(value)).digest('hex').slice(0, 12)
}
/** Derive a key from passphrase (for tests / migration helpers). */
export function deriveKeyFromPassphrase(passphrase, salt = 'uds-auth-skill') {
return scryptSync(String(passphrase), String(salt), 32)
}

View file

@ -2,11 +2,13 @@
* Per-user workspace provisioning under workspaceRoot/<empNo>.
*/
import { mkdir } from 'node:fs/promises'
import { join, resolve, sep } from 'node:path'
import { join, resolve } from 'node:path'
import { homedir } from 'node:os'
import { readFile, writeFile } from 'node:fs/promises'
import { readFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { withUserContext, getUserContext } from './context.js'
import { sanitizeUserId, resolveUserDir, isPathInside } from './utils/safe-userid.js'
import { atomicWriteJson } from './utils/atomic-write.js'
function defaultWorkspaceRoot() {
const home = process.env.DSH_HOME || process.env.DSH_PROFILE_DIR || join(homedir(), '.dsh')
@ -56,16 +58,12 @@ export class UserWorkspaceStore {
if (!this._dirty || !this._mapFile) return
this._dirty = false
try {
await mkdir(dirname(this._mapFile), { recursive: true })
await writeFile(
this._mapFile,
JSON.stringify({
users: Object.fromEntries(this._map),
savedAt: new Date().toISOString(),
}, null, 2),
'utf-8',
)
await atomicWriteJson(this._mapFile, {
users: Object.fromEntries(this._map),
savedAt: new Date().toISOString(),
}, { mode: 0o600 })
} catch (err) {
this._dirty = true
console.warn('[uds-auth:UserWorkspace] save failed:', err.message)
}
}
@ -90,8 +88,15 @@ export class UserWorkspaceStore {
// Fallback admin: still get a dedicated folder
empNo = empNo || 'administrator'
}
const safeId = sanitizeUserId(empNo)
if (!safeId) {
console.warn('[uds-auth] reject unsafe empNo for workspace:', String(empNo).slice(0, 32))
return null
}
empNo = safeId
const root = resolveWorkspaceRoot(workspaceRoot)
const userPath = join(root, String(empNo))
const userPath = resolveUserDir(root, empNo)
if (!userPath) return null
await mkdir(userPath, { recursive: true })
// Cordis throws on ctx.workspaceRegistry without inject.
@ -131,8 +136,12 @@ export class UserWorkspaceStore {
isUserPath(empNo, candidatePath, workspaceRoot) {
if (!empNo || !candidatePath) return false
const root = resolveWorkspaceRoot(workspaceRoot)
const userPath = resolve(join(root, String(empNo)))
const cand = resolve(String(candidatePath))
return cand === userPath || cand.startsWith(userPath + sep)
const userPath = resolveUserDir(root, empNo)
if (!userPath) return false
// Candidate must stay under user dir; user dir must stay under workspace root.
if (!isPathInside(root, userPath)) return false
return isPathInside(userPath, candidatePath)
}
}
export { sanitizeUserId, resolveUserDir }

View file

@ -1,18 +1,18 @@
{
"name": "uds-auth",
"version": "0.2.21",
"version": "0.3.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "uds-auth",
"version": "0.2.21",
"version": "0.3.11",
"license": "MIT",
"devDependencies": {
"qrcode": "1.5.4"
},
"engines": {
"node": ">=18.0.0"
"node": "^22.19.0 || >=24.0.0"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.0.0"

View file

@ -1,9 +1,12 @@
{
"name": "uds-auth",
"version": "0.2.21",
"description": "UDS authentication plugin for DeepSeek Harness with multi-tenant session isolation",
"version": "0.3.15",
"description": "UDS authentication plugin for DeepSeek Harness — trusted single-instance access control (not strong multi-tenant isolation)",
"type": "module",
"main": "lib/index.js",
"bin": {
"uds-auth-seal-local-admin": "./scripts/seal-local-admin.mjs"
},
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js",
@ -18,8 +21,10 @@
},
"scripts": {
"bundle:qrcode": "bun run scripts/bundle-qrcode.mjs",
"prepack": "bun run scripts/bundle-qrcode.mjs",
"test": "node --test test/**/*.test.js"
"prepack": "bun run scripts/bundle-qrcode.mjs && node scripts/generate-sbom.mjs",
"pack:check": "node scripts/pack-check.mjs",
"test": "node --test test/**/*.test.js",
"sbom": "node scripts/generate-sbom.mjs"
},
"keywords": [
"dsh-plugin",
@ -28,13 +33,14 @@
"authentication",
"uds",
"sso",
"session-management",
"multi-tenant"
"session-management"
],
"files": [
"lib",
"skills",
"skill-helpers",
"scripts/seal-local-admin.mjs",
"scripts/pack-check.mjs",
"config.default.yaml",
"cordis.patch.yml",
"docs",
@ -45,7 +51,7 @@
"author": "",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
"node": "^22.19.0 || >=24.0.0"
},
"dependencies": {},
"devDependencies": {

View file

@ -0,0 +1,45 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { resolve, join } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
const lock = JSON.parse(await readFile(join(root, 'package-lock.json'), 'utf8'))
const bundle = await readFile(join(root, 'lib/qrcode-bundled.cjs'))
const sourceFiles = [...bundle.toString().matchAll(/^\/\/ node_modules\/(.+)$/gm)].map(m => m[1].trim())
if (!sourceFiles.length) throw new Error('bundle source inventory missing')
const names = [...new Set(sourceFiles.map(path => path.startsWith('@') ? path.split('/').slice(0, 2).join('/') : path.split('/')[0]))].sort()
const components = [], notices = []
for (const name of names) {
const row = lock.packages['node_modules/' + name]
const manifest = JSON.parse(await readFile(join(root, 'node_modules', name, 'package.json'), 'utf8'))
if (!row?.integrity || row.version !== manifest.version || !manifest.license) throw new Error('unlocked/unlicensed bundle component: ' + name)
let license
for (const file of ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENSE-MIT', 'license']) {
try { license = await readFile(join(root, 'node_modules', name, file), 'utf8'); break } catch (err) { if (err.code !== 'ENOENT') throw err }
}
if (!license) throw new Error('missing third-party license: ' + name)
notices.push(`${name} ${row.version} (${manifest.license})\n\n${license.trim()}\n`)
const hashes = []
for (const file of sourceFiles.filter(file => file.startsWith(name + '/'))) {
const data = await readFile(join(root, 'node_modules', file))
hashes.push({ file, sha256: createHash('sha256').update(data).digest('hex') })
}
const purl = `pkg:npm/${name.replace('@', '%40')}@${row.version}`
components.push({ type: 'library', name, version: row.version, purl, 'bom-ref': purl,
licenses: [{ license: { id: manifest.license } }],
properties: [{ name: 'uds-auth:delivery', value: 'bundled in lib/qrcode-bundled.cjs' },
{ name: 'npm:integrity', value: row.integrity }, { name: 'uds-auth:source-files', value: JSON.stringify(hashes) }] })
}
const mainRef = `pkg:npm/uds-auth@${pkg.version}`
const bom = { bomFormat: 'CycloneDX', specVersion: '1.5', version: 1,
metadata: { component: { type: 'application', name: pkg.name, version: pkg.version, 'bom-ref': mainRef,
licenses: [{ license: { id: pkg.license } }] }, properties: [
{ name: 'uds-auth:bundle-sha256', value: createHash('sha256').update(bundle).digest('hex') },
{ name: 'uds-auth:external-peer', value: 'React supplied by DSH; audit its exact version in the Host SBOM' },
{ name: 'uds-auth:scope', value: 'Actual vendored runtime bundle; development dependency audit is a separate npm audit --include=dev gate' },
] }, components, dependencies: [{ ref: mainRef, dependsOn: components.map(c => c['bom-ref']) },
...components.map(c => ({ ref: c['bom-ref'], dependsOn: [] }))] }
await mkdir(join(root, 'docs'), { recursive: true })
await writeFile(join(root, 'docs/sbom.cdx.json'), JSON.stringify(bom, null, 2) + '\n')
await writeFile(join(root, 'docs/THIRD-PARTY-NOTICES.txt'), notices.join('\n---\n\n'))
console.log(`SBOM: ${components.length} bundled components, ${sourceFiles.length} source files`)

View file

@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* SEC-24: verify npm pack file list before publish.
* Usage: node scripts/pack-check.mjs
*/
import { execSync } from 'node:child_process'
import { readFileSync, existsSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const forbidden = [
/(?:^|\/)node_modules\//,
/(?:^|\/)__pycache__\//,
/\.pyc$/,
/(?:^|\/)skill-credentials\.json$/,
/(?:^|\/)session-bridge\.json$/,
/(?:^|\/)session-owners\.json/,
/(?:^|\/)user-workspaces\.json$/,
/(?:^|\/)roles\.json$/,
/(?:^|\/)config\.runtime\.json$/,
/\.uds-auth-secret-key$/,
/\.tgz$/,
]
const required = [
'package.json',
'LICENSE',
'lib/index.js',
'lib/qrcode-bundled.cjs',
'scripts/seal-local-admin.mjs',
'skills/uds-skill-auth/scripts/uds_skill_auth.py',
'skill-helpers/python/uds_skill_auth.py',
'README.md',
'README.zh.md',
'docs/sbom.cdx.json',
'docs/THIRD-PARTY-NOTICES.txt',
'docs/upgrade-0.3.10.zh.md',
'docs/upgrade-0.3.11.zh.md',
'lib/desktop-bootstrap.js',
]
function listPackFiles() {
// --ignore-scripts avoids prepack (bun bundle) polluting stdout / requiring bun.
const raw = execSync('npm pack --dry-run --json --ignore-scripts', {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, npm_config_loglevel: 'error' },
})
const start = raw.indexOf('[')
const end = raw.lastIndexOf(']')
if (start < 0 || end <= start) {
throw new Error('npm pack --json produced no array')
}
const parsed = JSON.parse(raw.slice(start, end + 1))
const entry = Array.isArray(parsed) ? parsed[0] : parsed
return (entry?.files || []).map((f) => String(f.path || f).replace(/\\/g, '/'))
}
const files = listPackFiles()
let failed = false
if (!files.length) {
console.error('[pack-check] empty pack file list')
process.exit(1)
}
for (const f of files) {
for (const re of forbidden) {
if (re.test(f)) {
console.error('[pack-check] FORBIDDEN in package:', f)
failed = true
}
}
}
for (const need of required) {
const norm = need.replace(/\\/g, '/')
const ok = files.some((f) => f === norm || f.endsWith('/' + norm))
if (!ok && !existsSync(join(root, need))) {
console.error('[pack-check] MISSING required path on disk:', need)
failed = true
} else if (!ok) {
console.error('[pack-check] MISSING from pack list:', need)
failed = true
}
}
const a = readFileSync(join(root, 'skills/uds-skill-auth/scripts/uds_skill_auth.py'), 'utf8')
const b = readFileSync(join(root, 'skill-helpers/python/uds_skill_auth.py'), 'utf8')
if (a !== b) {
console.error('[pack-check] Python helpers diverged')
failed = true
}
const sbom = JSON.parse(readFileSync(join(root, 'docs/sbom.cdx.json'), 'utf8'))
const metadata = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
const bundleHash = createHash('sha256').update(readFileSync(join(root, 'lib/qrcode-bundled.cjs'))).digest('hex')
if (sbom.metadata?.component?.version !== metadata.version
|| !sbom.metadata?.properties?.some(p => p.name === 'uds-auth:bundle-sha256' && p.value === bundleHash)) {
console.error('[pack-check] SBOM version/bundle hash differs; rebuild bundle and SBOM')
failed = true
}
if (failed) {
process.exit(1)
}
console.log('[pack-check] OK —', files.length, 'files')

View file

@ -3,22 +3,93 @@
* Generate UDS_AUTH_LOCAL_ADMIN_BOX for local decrypt-to-unlock admin.
*
* Usage:
* node scripts/seal-local-admin.mjs "your-passphrase-here"
* node scripts/seal-local-admin.mjs
* # prompts for passphrase on stdin (hidden when TTY)
* node scripts/seal-local-admin.mjs --stdin # read one line from stdin
*
* Then set the printed env on the Harness process (keep the passphrase offline).
* Passphrase on argv is rejected (R10) so it does not land in shell history.
*/
import { createInterface } from 'node:readline'
import { stdin as input, stdout as output, stderr } from 'node:process'
import { sealLocalAdminBox, LOCAL_ADMIN_BOX_ENV } from '../lib/local-admin.js'
const passphrase = process.argv[2]
if (!passphrase) {
console.error('Usage: node scripts/seal-local-admin.mjs "<passphrase>"')
async function readPassphrase() {
if (process.argv.includes('--stdin') || !process.stdin.isTTY) {
const rl = createInterface({ input, crlfDelay: Infinity })
const line = await new Promise((resolve) => {
rl.once('line', (l) => resolve(l))
rl.once('close', () => resolve(''))
})
rl.close()
return String(line || '').replace(/\r$/, '')
}
// Interactive: do not echo
stderr.write('Passphrase (input hidden): ')
return await new Promise((resolve, reject) => {
const wasRaw = input.isRaw
try { input.setRawMode?.(true) } catch { /* ignore */ }
input.resume()
let buf = ''
const onData = (chunk) => {
const s = chunk.toString('utf8')
for (const ch of s) {
if (ch === '\n' || ch === '\r' || ch === '\u0004') {
cleanup()
stderr.write('\n')
resolve(buf)
return
}
if (ch === '\u0003') {
cleanup()
reject(new Error('cancelled'))
return
}
if (ch === '\u007f' || ch === '\b') {
buf = buf.slice(0, -1)
continue
}
buf += ch
}
}
const cleanup = () => {
input.off('data', onData)
try { input.setRawMode?.(!!wasRaw) } catch { /* ignore */ }
input.pause()
}
input.on('data', onData)
})
}
if (process.argv[2] && process.argv[2] !== '--stdin') {
console.error('Do not pass the passphrase on argv (shell history risk).')
console.error('Usage: node scripts/seal-local-admin.mjs')
console.error(' or: echo passphrase | node scripts/seal-local-admin.mjs --stdin')
process.exit(1)
}
try {
const passphrase = await readPassphrase()
if (!passphrase || passphrase.length < 12) {
console.error('Passphrase required (min 12 characters).')
process.exit(1)
}
const box = sealLocalAdminBox(passphrase)
console.log(`# Keep the passphrase secret. Only the box goes into the process env.`)
console.log('# Keep the passphrase secret. Only the box goes into env and/or profile file.')
console.log(`${LOCAL_ADMIN_BOX_ENV}=${box}`)
console.log('# Also durable for Desktop Host (often misses User env after reinstall):')
console.log(`# write the box string into %USERPROFILE%\\.uds-auth\\local-admin.box`)
try {
const { writeFileSync, mkdirSync } = await import('node:fs')
const { homedir } = await import('node:os')
const { join } = await import('node:path')
const dir = join(homedir(), '.uds-auth')
mkdirSync(dir, { recursive: true })
const file = join(dir, 'local-admin.box')
writeFileSync(file, box + '\n', { encoding: 'utf8', mode: 0o600 })
console.log(`# Wrote ${file}`)
} catch (writeErr) {
console.error('# Could not write ~/.uds-auth/local-admin.box:', writeErr.message || writeErr)
}
} catch (err) {
console.error(err.message || err)
process.exit(1)

View file

@ -6,9 +6,10 @@ Resolve SSO credentials via loopback Host APIs, or call intranet APIs through
the outbound proxy.
Environment:
DSH_SESSION_ID — agent session id (injected by DSH shell-env)
DSH_WEB_URL — optional base URL of the Harness web server
UDS_AUTH_BASE — optional override, e.g. http://127.0.0.1:PORT/uds-auth
DSH_SESSION_ID — agent session id (injected by DSH shell-env)
UDS_TASK_CAPABILITY — short-lived Host-minted capability (preferred for cron/skill)
DSH_WEB_URL — optional base URL of the Harness web server
UDS_AUTH_BASE — optional override, e.g. http://127.0.0.1:PORT/uds-auth
"""
from __future__ import annotations
@ -29,19 +30,50 @@ class UdsAuthError(RuntimeError):
def _base_url() -> str:
explicit = (os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/")
if explicit:
return explicit
"""Prefer loopback Host. Env override cannot point at non-loopback without explicit allow."""
managed = os.environ.get("DSH_UDS_AUTH_BASE")
explicit = (managed if managed is not None else os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/")
web = (os.environ.get("DSH_WEB_URL") or "").strip().rstrip("/")
if web:
return web + "/uds-auth"
return "http://127.0.0.1:8787/uds-auth"
base = explicit or (web + "/uds-auth" if web else "")
if managed is not None and not explicit:
raise UdsAuthError("Host did not authorize this session", code="no_session")
if not base:
raise UdsAuthError("Missing Host endpoint; load uds-auth shellEnv adapter", code="no_base")
parsed = urlparse(base)
if parsed.scheme not in ("http", "https") or parsed.hostname not in ("127.0.0.1", "localhost", "::1") or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise UdsAuthError("UDS auth endpoint must be an owned loopback HTTP(S) URL", code="unsafe_base")
return base
def _session_id() -> str:
return (os.environ.get("DSH_SESSION_ID") or "").strip()
def _task_capability() -> str:
managed = os.environ.get("DSH_UDS_TASK_CAPABILITY")
return (managed if managed is not None else os.environ.get("UDS_TASK_CAPABILITY") or os.environ.get("UDS_AUTH_TASK_CAPABILITY") or "").strip()
def _auth_headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
hdrs = dict(extra or {})
sid = _session_id()
if sid:
hdrs.setdefault("X-DSH-Session-Id", sid)
cap = _task_capability()
if cap:
hdrs["X-UDS-Task-Capability"] = cap
hdrs.setdefault("Accept", "application/json")
return hdrs
def _is_loopback_url(url: str) -> bool:
try:
host = (urlparse(url).hostname or "").lower()
except Exception:
return False
return host in ("127.0.0.1", "localhost", "::1")
def _opener_no_proxy():
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
@ -68,9 +100,10 @@ def _http_json(
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
hdrs.setdefault("Content-Type", "application/json;charset=UTF-8")
req = urllib.request.Request(url, data=data, headers=hdrs, method=method.upper())
ctx = ssl._create_unverified_context()
# SEC-18: always use standard TLS verification — never unverified context.
ctx = ssl.create_default_context()
try:
if "127.0.0.1" in url or "localhost" in url.lower():
if _is_loopback_url(url):
opener = _opener_no_proxy()
with opener.open(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
@ -94,26 +127,43 @@ def _http_json(
return status, parsed, raw
def resolve(*, apply_env_aliases: bool = True) -> Dict[str, str]:
def resolve(*, apply_env_aliases: bool = False) -> Dict[str, str]:
"""
Fetch {empNo, token} for the current DSH session from Host.
Optionally set process-local EMP_NO / AUTH_VALUE (and coclaw_* aliases).
Prefer UDS_TASK_CAPABILITY (Host-minted) over a browser session cookie.
apply_env_aliases defaults False (SEC-18) — subprocesses inherit secrets if True.
Prefer request()/outbound instead of exposing raw tokens when possible.
"""
sid = _session_id()
if not sid:
raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session")
cap = _task_capability()
if not sid and not cap:
raise UdsAuthError(
"缺少 DSH_SESSION_ID 或 UDS_TASK_CAPABILITY",
code="no_session",
)
url = _base_url() + "/agent-credentials"
body: Dict[str, Any] = {}
if sid:
body["sessionId"] = sid
if cap:
body["taskCapability"] = cap
status, parsed, raw = _http_json(
"POST",
url,
headers={"X-DSH-Session-Id": sid, "Accept": "application/json"},
body={"sessionId": sid},
headers=_auth_headers(),
body=body,
timeout=15.0,
)
if status == 401 or (isinstance(parsed, dict) and parsed.get("error") == "no_skill_credentials"):
msg = (parsed or {}).get("message") if isinstance(parsed, dict) else None
raise UdsAuthError(msg or "请先完成 UDS 扫码登录", code="no_credentials", status=status)
if status == 403 and isinstance(parsed, dict) and parsed.get("error") == "raw_token_disabled":
raise UdsAuthError(
"原始凭证接口已关闭,请使用 outbound()",
code="raw_token_disabled",
status=403,
)
if status != 200 or not isinstance(parsed, dict):
raise UdsAuthError(
f"获取凭证失败 (HTTP {status})",
@ -147,22 +197,29 @@ def request(
Returns {statusCode, headers, body, json}.
"""
sid = _session_id()
if not sid:
raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session")
cap = _task_capability()
if not sid and not cap:
raise UdsAuthError(
"缺少 DSH_SESSION_ID 或 UDS_TASK_CAPABILITY",
code="no_session",
)
host = urlparse(url).hostname or ""
payload = {
"sessionId": sid,
payload: Dict[str, Any] = {
"method": method.upper(),
"url": url,
"headers": headers or {},
"body": body,
"timeoutMs": int(timeout * 1000),
}
if sid:
payload["sessionId"] = sid
if cap:
payload["taskCapability"] = cap
status, parsed, raw = _http_json(
"POST",
_base_url() + "/outbound",
headers={"X-DSH-Session-Id": sid, "Accept": "application/json"},
headers=_auth_headers(),
body=payload,
timeout=timeout + 5.0,
)

View file

@ -23,12 +23,12 @@ uds-auth/skills/uds-skill-auth/
## 给其它 Skill 用
业务 skill 的 Python 脚本里,把本 skill 的 `scripts/` 加入 `sys.path` 后:
业务 skill 的 Python 脚本里,把本 skill 的 `scripts/` 加入 `sys.path` 后。**推荐**走 `request()`(outbound);`resolve()` 仅在 Host 开启 `allowRawAgentToken` 且任务注入了 `credentials` scope 的 `UDS_TASK_CAPABILITY` 时可用。
```python
from uds_skill_auth import resolve, request, UdsAuthError
from uds_skill_auth import request, UdsAuthError
creds = resolve()
# 需要环境:DSH_SESSION_ID +(推荐)UDS_TASK_CAPABILITY
out = request("POST", "https://icenterapi.zte.com.cn/...", headers={...}, body={...})
```
@ -51,10 +51,11 @@ def load_uds_skill_auth():
也可设置 `UDS_AUTH_HELPERS` 指向本 skill 的 `scripts` 绝对路径。
## 环境变量(Agent 已注入,勿教模型打印密钥)
## 环境变量(Agent / 任务注入,勿教模型打印密钥)
- `DSH_SESSION_ID`
- `DSH_WEB_URL`
- `UDS_TASK_CAPABILITY`(Host `mintTaskCapability`;scopes 按任务最小授权)
## 标准文档

View file

@ -6,9 +6,10 @@ Resolve SSO credentials via loopback Host APIs, or call intranet APIs through
the outbound proxy.
Environment:
DSH_SESSION_ID — agent session id (injected by DSH shell-env)
DSH_WEB_URL — optional base URL of the Harness web server
UDS_AUTH_BASE — optional override, e.g. http://127.0.0.1:PORT/uds-auth
DSH_SESSION_ID — agent session id (injected by DSH shell-env)
UDS_TASK_CAPABILITY — short-lived Host-minted capability (preferred for cron/skill)
DSH_WEB_URL — optional base URL of the Harness web server
UDS_AUTH_BASE — optional override, e.g. http://127.0.0.1:PORT/uds-auth
"""
from __future__ import annotations
@ -29,19 +30,50 @@ class UdsAuthError(RuntimeError):
def _base_url() -> str:
explicit = (os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/")
if explicit:
return explicit
"""Prefer loopback Host. Env override cannot point at non-loopback without explicit allow."""
managed = os.environ.get("DSH_UDS_AUTH_BASE")
explicit = (managed if managed is not None else os.environ.get("UDS_AUTH_BASE") or "").strip().rstrip("/")
web = (os.environ.get("DSH_WEB_URL") or "").strip().rstrip("/")
if web:
return web + "/uds-auth"
return "http://127.0.0.1:8787/uds-auth"
base = explicit or (web + "/uds-auth" if web else "")
if managed is not None and not explicit:
raise UdsAuthError("Host did not authorize this session", code="no_session")
if not base:
raise UdsAuthError("Missing Host endpoint; load uds-auth shellEnv adapter", code="no_base")
parsed = urlparse(base)
if parsed.scheme not in ("http", "https") or parsed.hostname not in ("127.0.0.1", "localhost", "::1") or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise UdsAuthError("UDS auth endpoint must be an owned loopback HTTP(S) URL", code="unsafe_base")
return base
def _session_id() -> str:
return (os.environ.get("DSH_SESSION_ID") or "").strip()
def _task_capability() -> str:
managed = os.environ.get("DSH_UDS_TASK_CAPABILITY")
return (managed if managed is not None else os.environ.get("UDS_TASK_CAPABILITY") or os.environ.get("UDS_AUTH_TASK_CAPABILITY") or "").strip()
def _auth_headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
hdrs = dict(extra or {})
sid = _session_id()
if sid:
hdrs.setdefault("X-DSH-Session-Id", sid)
cap = _task_capability()
if cap:
hdrs["X-UDS-Task-Capability"] = cap
hdrs.setdefault("Accept", "application/json")
return hdrs
def _is_loopback_url(url: str) -> bool:
try:
host = (urlparse(url).hostname or "").lower()
except Exception:
return False
return host in ("127.0.0.1", "localhost", "::1")
def _opener_no_proxy():
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
@ -68,9 +100,10 @@ def _http_json(
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
hdrs.setdefault("Content-Type", "application/json;charset=UTF-8")
req = urllib.request.Request(url, data=data, headers=hdrs, method=method.upper())
ctx = ssl._create_unverified_context()
# SEC-18: always use standard TLS verification — never unverified context.
ctx = ssl.create_default_context()
try:
if "127.0.0.1" in url or "localhost" in url.lower():
if _is_loopback_url(url):
opener = _opener_no_proxy()
with opener.open(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
@ -94,26 +127,43 @@ def _http_json(
return status, parsed, raw
def resolve(*, apply_env_aliases: bool = True) -> Dict[str, str]:
def resolve(*, apply_env_aliases: bool = False) -> Dict[str, str]:
"""
Fetch {empNo, token} for the current DSH session from Host.
Optionally set process-local EMP_NO / AUTH_VALUE (and coclaw_* aliases).
Prefer UDS_TASK_CAPABILITY (Host-minted) over a browser session cookie.
apply_env_aliases defaults False (SEC-18) — subprocesses inherit secrets if True.
Prefer request()/outbound instead of exposing raw tokens when possible.
"""
sid = _session_id()
if not sid:
raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session")
cap = _task_capability()
if not sid and not cap:
raise UdsAuthError(
"缺少 DSH_SESSION_ID 或 UDS_TASK_CAPABILITY",
code="no_session",
)
url = _base_url() + "/agent-credentials"
body: Dict[str, Any] = {}
if sid:
body["sessionId"] = sid
if cap:
body["taskCapability"] = cap
status, parsed, raw = _http_json(
"POST",
url,
headers={"X-DSH-Session-Id": sid, "Accept": "application/json"},
body={"sessionId": sid},
headers=_auth_headers(),
body=body,
timeout=15.0,
)
if status == 401 or (isinstance(parsed, dict) and parsed.get("error") == "no_skill_credentials"):
msg = (parsed or {}).get("message") if isinstance(parsed, dict) else None
raise UdsAuthError(msg or "请先完成 UDS 扫码登录", code="no_credentials", status=status)
if status == 403 and isinstance(parsed, dict) and parsed.get("error") == "raw_token_disabled":
raise UdsAuthError(
"原始凭证接口已关闭,请使用 outbound()",
code="raw_token_disabled",
status=403,
)
if status != 200 or not isinstance(parsed, dict):
raise UdsAuthError(
f"获取凭证失败 (HTTP {status})",
@ -147,22 +197,29 @@ def request(
Returns {statusCode, headers, body, json}.
"""
sid = _session_id()
if not sid:
raise UdsAuthError("缺少 DSH_SESSION_ID,请在 Agent shell 中运行", code="no_session")
cap = _task_capability()
if not sid and not cap:
raise UdsAuthError(
"缺少 DSH_SESSION_ID 或 UDS_TASK_CAPABILITY",
code="no_session",
)
host = urlparse(url).hostname or ""
payload = {
"sessionId": sid,
payload: Dict[str, Any] = {
"method": method.upper(),
"url": url,
"headers": headers or {},
"body": body,
"timeoutMs": int(timeout * 1000),
}
if sid:
payload["sessionId"] = sid
if cap:
payload["taskCapability"] = cap
status, parsed, raw = _http_json(
"POST",
_base_url() + "/outbound",
headers={"X-DSH-Session-Id": sid, "Accept": "application/json"},
headers=_auth_headers(),
body=payload,
timeout=timeout + 5.0,
)

View file

@ -0,0 +1,170 @@
/**
* Acceptance batch-1 regressions (R01–R05).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { MemoryStore } from '../lib/session/memory-store.js'
import { createAgentAuthHandlers } from '../lib/agent-auth.js'
import { RolesStore, ROLES } from '../lib/roles.js'
import { identityFromSessionRecord } from '../lib/dsh-acl.js'
import { runWithUserContext } from '../lib/context.js'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
test('R01/A05: client attaches bridge only to same-host http↔ws / https↔wss', () => {
const src = readFileSync(join(root, 'lib/client.js'), 'utf8')
assert.match(src, /isTrustedHostUrl/)
assert.match(src, /locSecure !== urlSecure/)
assert.match(src, /!isTrustedHostUrl\(url\)/)
assert.match(src, /!isTrustedHostUrl\(input\)/)
})
test('R03: revoked session fails getBySessionIdSync', async () => {
const store = new MemoryStore()
const { sessionId, bearer } = await store.create({ empNo: 'A001', displayName: 'A' }, 600)
assert.ok(await store.getByBearer(bearer))
assert.ok(store.getBySessionIdSync(sessionId))
await store.revokeSession(sessionId)
assert.equal(store.getBySessionIdSync(sessionId), null)
assert.equal(await store.getByBearer(bearer), null)
})
test('R03/Q10: fallback identity rejected when emergency password disabled', () => {
const roles = new RolesStore({})
roles._roles.set('administrator', ROLES.FALLBACK_ADMIN || 'fallback_admin')
roles._fallbackPasswordHash = null
assert.equal(roles.isFallbackEnabled(), false)
const id = identityFromSessionRecord({
sessionId: 's1',
empNo: 'administrator',
kind: 'fallback',
userData: { empNo: 'administrator' },
}, roles)
assert.equal(id, null)
})
test('R03/Q10: fallback identity accepted when emergency password enabled', () => {
const roles = new RolesStore({})
roles._roles.set('administrator', 'fallback_admin')
roles._fallbackPasswordHash = 'abc'
assert.equal(roles.isFallbackEnabled(), true)
const id = identityFromSessionRecord({
sessionId: 's1',
empNo: 'administrator',
kind: 'fallback',
userData: { empNo: 'administrator' },
}, roles)
assert.equal(id?.empNo, 'administrator')
assert.equal(id?.kind, 'fallback')
})
test('R04: create/follow use live permissions; no admin follow bypass', () => {
const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(src, /assertCreateTargetAllowed[\s\S]*?livePermissions\(identity\)/)
assert.match(src, /never bypass projection for admin-at-subscribe/)
assert.equal(/if \(canSeeAllWorkspaces\(identity\)\) \{\s*yield\* origFollow/.test(src), false)
})
test('R05: closing allowRawAgentToken takes effect immediately', async () => {
let allowRaw = true
const handlers = createAgentAuthHandlers({
allowRawTokenResponse: () => allowRaw,
outboundHosts: () => ['example.com'],
resolveCallerSession: async () => ({ empNo: 'A', sessionId: 's' }),
getSessionOwner: () => 'A',
resolveCredentialsForSession: async () => ({ empNo: 'A', token: 'secret' }),
})
allowRaw = false
const chunks = []
const res = {
statusCode: 200,
setHeader() {},
end(s) { chunks.push(String(s)) },
}
await handlers.handleAgentCredentials({
method: 'GET',
headers: {},
socket: { remoteAddress: '127.0.0.1' },
}, res)
assert.equal(res.statusCode, 403)
assert.match(chunks.join(''), /raw_token_disabled/)
})
test('R05: empty outbound list does not revive defaults', async () => {
const handlers = createAgentAuthHandlers({
allowRawTokenResponse: () => false,
outboundHosts: () => [],
resolveCallerSession: async () => ({ empNo: 'A', sessionId: 's' }),
getSessionOwner: () => 'A',
resolveCredentialsForSession: async () => ({ empNo: 'A', token: 'secret' }),
})
const chunks = []
const res = {
statusCode: 200,
setHeader() {},
end(s) { chunks.push(String(s)) },
}
const body = JSON.stringify({
sessionId: 's',
url: 'https://icenterapi.zte.com.cn/x',
method: 'GET',
})
const req = {
method: 'POST',
headers: { 'content-type': 'application/json' },
socket: { remoteAddress: '127.0.0.1' },
async *[Symbol.asyncIterator]() {
yield Buffer.from(body)
},
}
await handlers.handleOutbound(req, res)
const text = chunks.join('')
assert.ok(
/host_not_allowed|outbound_denied|forbidden|not_allowed/i.test(text)
|| res.statusCode >= 400,
text,
)
})
test('R05: live config getters wired in index', () => {
const src = readFileSync(join(root, 'lib/index.js'), 'utf8')
assert.match(src, /getAllowOpenRegistration/)
assert.match(src, /allowRawTokenResponse: \(\) =>/)
assert.match(src, /empty string \/ \[\] means deny-all|deny-all/)
})
test('R02: job/terminal/fs ACL wraps and principal live check', () => {
const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(src, /jobController/)
assert.match(src, /terminalController/)
assert.match(src, /file_forbidden/)
assert.match(src, /assertPrincipalLive/)
assert.match(src, /getSessionStore/)
})
test('R03: assertCanAccess rejects revoked principal via session store', async () => {
const store = new MemoryStore()
const { sessionId } = await store.create({ empNo: 'A001' }, 600)
const roles = new RolesStore({})
roles._roles.set('A001', 'user')
const identity = identityFromSessionRecord({
sessionId,
empNo: 'A001',
kind: 'uds',
userData: { empNo: 'A001' },
}, roles)
assert.ok(identity)
await store.revokeSession(sessionId)
assert.equal(store.getBySessionIdSync(sessionId), null)
// Simulate installDshAcl assertPrincipalLive gate
const sid = identity.sessionId
assert.ok(sid)
assert.equal(store.getBySessionIdSync(sid), null)
})
// silence unused in some runners
void runWithUserContext

View file

@ -0,0 +1,193 @@
/**
* Acceptance batch-2/3 regressions (R06–R14 fragments).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { mkdtemp, writeFile, readFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { TaskCapabilityStore } from '../lib/task-capability.js'
import { createAgentAuthHandlers } from '../lib/agent-auth.js'
import { RolesStore, ROLES, ACCOUNT_STATUS, LEGACY_DEFAULT_SHA256, hashPasswordScrypt, verifyPasswordHash } from '../lib/roles.js'
import { loadOrCreateSecretKey, sealSecret } from '../lib/utils/secret-box.js'
import { checkRequestOrigin } from '../lib/utils/origin-guard.js'
import { SessionAclStore } from '../lib/session-acl.js'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
test('R06: task capability authorizes outbound without browser session', async () => {
const caps = new TaskCapabilityStore({ defaultTtlSeconds: 600 })
const minted = caps.mint({
empNo: 'A001',
dshSessionId: 'sess-a',
scopes: ['outbound', 'credentials'],
})
const handlers = createAgentAuthHandlers({
taskCapabilities: caps,
allowRawTokenResponse: () => false,
outboundHosts: () => ['example.com'],
getSessionOwner: () => 'A001',
resolveCredentialsForSession: async () => ({ empNo: 'A001', token: 'tok' }),
resolveCallerSession: async () => null,
})
const chunks = []
const res = { statusCode: 200, setHeader() {}, end(s) { chunks.push(String(s)) } }
// credentials still blocked when raw disabled
await handlers.handleAgentCredentials({
method: 'POST',
headers: {
'x-uds-task-capability': minted.token,
'x-dsh-session-id': 'sess-a',
'content-type': 'application/json',
},
socket: { remoteAddress: '127.0.0.1' },
async *[Symbol.asyncIterator]() {
yield Buffer.from(JSON.stringify({ sessionId: 'sess-a', taskCapability: minted.token }))
},
}, res)
assert.equal(res.statusCode, 403)
assert.match(chunks.join(''), /raw_token_disabled/)
// verify() binds empNo
const v = caps.verify(minted.token, { audience: 'uds-auth-agent', dshSessionId: 'sess-a' })
assert.equal(v?.empNo, 'A001')
assert.equal(caps.verify('deadbeefdeadbeefdeadbeefdeadbeef', {}), null)
})
test('R07: user-info response includes bo whitelist (source)', () => {
const src = readFileSync(join(root, 'lib/index.js'), 'utf8')
assert.match(src, /bo: \[safeRow\]/)
assert.match(src, /profile:\s*\{/)
})
test('R08: nested sessionIds and workspace remove projection (source)', () => {
const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(src, /projectWorkspaceItem/)
assert.match(src, /frame\.type === 'remove'/)
assert.match(src, /projectMutationResult/)
})
test('R09: concurrent last-admin demotion keeps >=1 super_admin', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-roles-'))
const file = join(dir, 'roles.json')
const store = new RolesStore({ rolesFile: file })
await store.init()
store._roles.set('a', ROLES.SUPER_ADMIN)
store._roles.set('b', ROLES.SUPER_ADMIN)
store._dirty = true
await store.flush()
const results = await Promise.allSettled([
store.setRole('a', ROLES.USER, ROLES.SUPER_ADMIN),
store.setRole('b', ROLES.USER, ROLES.SUPER_ADMIN),
])
const fulfilled = results.filter((r) => r.status === 'fulfilled').length
const rejected = results.filter((r) => r.status === 'rejected').length
assert.equal(fulfilled + rejected, 2)
assert.ok(rejected >= 1, 'at least one demote must fail')
const supers = await store.countByRole(ROLES.SUPER_ADMIN)
assert.ok(supers >= 1, `remaining super_admins=${supers}`)
})
test('R09: corrupt session owners fail closed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-own-'))
const file = join(dir, 'owners.json')
await writeFile(file, '{not-json', 'utf8')
const store = new SessionAclStore({ ownersFile: file })
await store.init()
assert.equal(store.loadFailed, true)
})
test('R10: scrypt hash roundtrip; legacy default refused', () => {
const h = hashPasswordScrypt('CorrectHorseBattery')
assert.ok(h.startsWith('scrypt$'))
assert.equal(verifyPasswordHash('CorrectHorseBattery', h), true)
assert.equal(verifyPasswordHash('wrong', h), false)
assert.equal(verifyPasswordHash('Admin@123', LEGACY_DEFAULT_SHA256), false)
})
test('R10: setFallbackPassword rejects short and known default', async () => {
const store = new RolesStore({})
store._roles.set('admin', ROLES.SUPER_ADMIN)
await assert.rejects(() => store.setFallbackPassword('short', ROLES.SUPER_ADMIN), /password_too_short/)
await assert.rejects(() => store.setFallbackPassword('Admin@123', ROLES.SUPER_ADMIN), /password_too_common/)
await store.setFallbackPassword('LongEnoughPass1', ROLES.SUPER_ADMIN)
assert.equal(store.verifyFallback('LongEnoughPass1', '127.0.0.1'), true)
})
test('R11: invalid env key fails closed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-key-'))
const prev = process.env.UDS_AUTH_SECRET_KEY
process.env.UDS_AUTH_SECRET_KEY = 'not-hex'
try {
await assert.rejects(() => loadOrCreateSecretKey(dir), (err) => err.code === 'invalid_secret_key_env')
} finally {
if (prev === undefined) delete process.env.UDS_AUTH_SECRET_KEY
else process.env.UDS_AUTH_SECRET_KEY = prev
}
})
test('R11: corrupt key file is not replaced', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-key2-'))
await writeFile(join(dir, '.uds-auth-secret-key'), 'garbage\n', 'utf8')
const prev = process.env.UDS_AUTH_SECRET_KEY
delete process.env.UDS_AUTH_SECRET_KEY
try {
await assert.rejects(() => loadOrCreateSecretKey(dir), (err) => err.code === 'secret_key_corrupt')
const still = await readFile(join(dir, '.uds-auth-secret-key'), 'utf8')
assert.match(still, /garbage/)
} finally {
if (prev !== undefined) process.env.UDS_AUTH_SECRET_KEY = prev
}
})
test('R12: disabled account cannot be ensureUser / open-register revived', async () => {
const store = new RolesStore({})
store._roles.set('u1', ROLES.USER)
store._roles.set('admin', ROLES.SUPER_ADMIN)
await store.removeUser('u1', ROLES.SUPER_ADMIN)
assert.equal(store.isDisabled('u1'), true)
assert.equal(store.getAccountStatus('u1'), ACCOUNT_STATUS.DISABLED)
await assert.rejects(() => store.ensureUser('u1', ROLES.SUPER_ADMIN), /account_disabled/)
})
test('R13: trustedHosts rejects unlisted Host', () => {
const req = {
method: 'POST',
headers: {
host: 'evil.example:8787',
origin: 'https://evil.example:8787',
},
socket: { remoteAddress: '1.2.3.4' },
}
const denied = checkRequestOrigin(req, { trustedHosts: ['good.example:8787'] })
assert.equal(denied.ok, false)
assert.equal(denied.reason, 'host_not_trusted')
const ok = checkRequestOrigin({
method: 'POST',
headers: { host: 'good.example:8787', origin: 'https://good.example:8787' },
socket: { remoteAddress: '1.2.3.4' },
}, { trustedHosts: ['good.example:8787'] })
assert.equal(ok.ok, true)
})
test('R13/R14: QR challenge store + live identity patch + docs (source)', () => {
const index = readFileSync(join(root, 'lib/index.js'), 'utf8')
assert.match(index, /consumeQrChallenge/)
assert.match(index, /MAX_QR_UPSTREAM_BYTES/)
assert.match(index, /mintTaskCapability/)
const acl = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(acl, /__udsAuthIdentityLive/)
const readme = readFileSync(join(root, 'README.zh.md'), 'utf8')
assert.doesNotMatch(readme, /Admin@123.*首次启动自动启用/)
assert.match(readme, /mintTaskCapability|UDS_TASK_CAPABILITY/)
})
test('R06 python helper mentions task capability', () => {
const py = readFileSync(join(root, 'skill-helpers/python/uds_skill_auth.py'), 'utf8')
assert.match(py, /UDS_TASK_CAPABILITY/)
assert.match(py, /X-UDS-Task-Capability/)
})

View file

@ -0,0 +1,276 @@
/**
* Phase-1 P0 negative tests (SEC-01..06, partial 07-09).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { createHash } from 'node:crypto'
import { MemoryStore } from '../lib/session/memory-store.js'
import { SESSION_COOKIE } from '../lib/session/store.js'
import {
extractSessionBearer,
extractPortalCredentials,
buildSessionCookie,
} from '../lib/session/request-auth.js'
import { createAuthMiddleware } from '../lib/middleware/auth-middleware.js'
import {
resolveIdentityFromRequest,
resolveIdentityFromRequestSync,
} from '../lib/dsh-acl.js'
import { requestIsLoopback } from '../lib/skill-credentials.js'
import { createAgentAuthHandlers } from '../lib/agent-auth.js'
import { RolesStore, ROLES, DEFAULT_FALLBACK_PASSWORD } from '../lib/roles.js'
import { SessionBridgeStore } from '../lib/session-bridge.js'
import { peekBrowserIdentity, rememberBrowserIdentity } from '../lib/identity-cache.js'
function mockRoles(seed = {}) {
const store = new RolesStore({})
for (const [empNo, role] of Object.entries(seed)) {
store._roles.set(empNo, role)
}
return store
}
test('SEC-01: static generic handlers are not exported from index route helpers', async () => {
// Regression: makeStaticHandler / open path resolve must not exist.
const src = await import('node:fs/promises').then((fs) =>
fs.readFile(new URL('../lib/index.js', import.meta.url), 'utf-8'),
)
assert.equal(src.includes('makeStaticHandler'), false)
assert.equal(src.includes('handleQrcodeStatic'), false)
assert.equal(src.includes('handleVendorStatic'), false)
assert.match(src, /static file routes removed/)
})
test('SEC-02/04: MemoryStore keys by bearer — empNo alone cannot prove auth', async () => {
const store = new MemoryStore()
const { bearer, sessionId } = await store.create({
empNo: '10001',
username: 'Alice',
isAuthenticated: true,
token: 'upstream-secret',
}, 600)
assert.ok(bearer.length >= 32)
assert.ok(sessionId)
const byBearer = await store.getByBearer(bearer)
assert.equal(byBearer.empNo, '10001')
assert.equal(store.getByBearerSync(bearer).empNo, '10001')
assert.equal(await store.getByBearer('deadbeef'.repeat(8)), null)
assert.equal(await store.getByBearer(''), null)
// Profile-like get(empNo) must not be treated as auth by middleware (documented).
const cached = await store.get('10001')
assert.equal(cached.empNo, '10001')
assert.ok(cached.token, 'cache may hold token but must not grant request auth alone')
})
test('SEC-02: fallback USER/UI cookies are not session bearers', () => {
const req = {
headers: {
cookie: 'UDS_FALLBACK_USER=administrator; UDS_FALLBACK_UI=administrator',
},
}
assert.equal(extractSessionBearer(req), null)
assert.equal(extractPortalCredentials(req), null)
})
test('SEC-02/03: sync/async ACL identity requires verified local session', async () => {
const store = new MemoryStore()
const roles = mockRoles({ administrator: ROLES.FALLBACK_ADMIN, '10001': ROLES.SUPER_ADMIN })
const deps = { sessionStore: store, rolesStore: roles, sessionBridge: null }
// Forged fallback cookies + arbitrary portal token → no principal
const forged = {
headers: {
cookie: 'UDS_FALLBACK_USER=administrator; PORTALSSOUser=10001; PORTALSSOCookie=arbitrary',
},
}
assert.equal(resolveIdentityFromRequestSync(forged, deps), null)
assert.equal(await resolveIdentityFromRequest(forged, deps), null)
// EmpNo cache hit alone is not enough
await store.create({ empNo: '10001', username: 'A', isAuthenticated: true }, 600)
assert.equal(resolveIdentityFromRequestSync({
headers: { cookie: 'PORTALSSOUser=10001' },
}, deps), null)
// Valid bearer → principal (no upstream token on identity)
const { bearer } = await store.create({
empNo: '10001',
username: 'A',
token: 'uac-secret',
isAuthenticated: true,
}, 600)
const req = { headers: { cookie: `${SESSION_COOKIE}=${bearer}` } }
const sync = resolveIdentityFromRequestSync(req, deps)
assert.equal(sync.empNo, '10001')
assert.equal(sync.userContext.token, undefined)
const asyncId = await resolveIdentityFromRequest(req, deps)
assert.equal(asyncId.empNo, '10001')
assert.equal(asyncId.userContext.token, undefined)
})
test('SEC-03: ACL path does not bootstrap first user', async () => {
const store = new MemoryStore()
const roles = mockRoles({}) // empty
const { bearer } = await store.create({
empNo: 'attacker00001',
isAuthenticated: true,
}, 600)
const identity = await resolveIdentityFromRequest({
headers: { cookie: `${SESSION_COOKIE}=${bearer}` },
}, { sessionStore: store, rolesStore: roles })
assert.equal(identity.empNo, 'attacker00001')
assert.equal(roles.isEmpty(), true, 'roles table must stay empty')
assert.equal(identity.role, ROLES.USER)
})
test('SEC-04: two devices — wrong bearer / mismatched bridge rejected', async () => {
const store = new MemoryStore()
const bridge = new SessionBridgeStore({ ttlMs: 60_000 })
const a = await store.create({ empNo: 'A', isAuthenticated: true }, 600)
const b = await store.create({ empNo: 'B', isAuthenticated: true }, 600)
bridge.mint({ empNo: 'A', kind: 'uds', token: a.bearer })
bridge.mint({ empNo: 'B', kind: 'uds', token: b.bearer })
// A bearer with B empNo bridge headers
const bad = extractSessionBearer({
headers: {
'x-uds-bridge-empno': 'B',
'x-uds-bridge-token': a.bearer,
'x-uds-bridge-kind': 'uds',
},
}, { sessionBridge: bridge })
// verify fails (empNo mismatch) → null
assert.equal(bad, null)
const ok = extractSessionBearer({
headers: {
cookie: buildSessionCookie(a.bearer, 600),
},
}, { sessionBridge: bridge })
assert.equal(ok.bearer, a.bearer)
assert.equal((await store.getByBearer(ok.bearer)).empNo, 'A')
})
test('SEC-05: requestIsLoopback ignores X-Forwarded-For', () => {
assert.equal(requestIsLoopback({
socket: { remoteAddress: '10.1.2.3' },
headers: { 'x-forwarded-for': '127.0.0.1' },
}), false)
assert.equal(requestIsLoopback({
socket: { remoteAddress: '127.0.0.1' },
headers: {},
}), true)
assert.equal(requestIsLoopback({
socket: { remoteAddress: '::ffff:127.0.0.1' },
headers: { 'x-forwarded-for': '8.8.8.8' },
}), true)
})
test('SEC-05: agent-credentials rejects empNo-only and disables raw token by default', async () => {
const handlers = createAgentAuthHandlers({
resolveCredentialsForSession: async () => ({ empNo: 'A', token: 't' }),
getSessionOwner: () => 'A',
resolveCallerSession: async () => null,
allowRawTokenResponse: false,
})
const res = {
statusCode: 0,
headers: {},
body: '',
setHeader(k, v) { this.headers[k] = v },
end(s) { this.body = s },
}
await handlers.handleAgentCredentials({
method: 'POST',
socket: { remoteAddress: '127.0.0.1' },
headers: {},
async *[Symbol.asyncIterator]() { yield Buffer.from(JSON.stringify({ empNo: 'A' })) },
}, res)
assert.equal(res.statusCode, 403)
assert.match(res.body, /raw_token_disabled|error/)
})
test('SEC-05: outbound requires caller session matching session owner', async () => {
let called = false
const handlers = createAgentAuthHandlers({
resolveCredentialsForSession: async () => {
called = true
return { empNo: 'A', token: 't' }
},
getSessionOwner: (sid) => (sid === 'sess-a' ? 'A' : 'B'),
resolveCallerSession: async () => ({ empNo: 'A', sessionId: 'local' }),
allowRawTokenResponse: true,
outboundHosts: ['example.com'],
})
const res = {
statusCode: 0,
headers: {},
body: '',
setHeader(k, v) { this.headers[k] = v },
end(s) { this.body = s },
}
// Caller A + session owned by B
await handlers.handleOutbound({
method: 'POST',
socket: { remoteAddress: '127.0.0.1' },
headers: {},
async *[Symbol.asyncIterator]() {
yield Buffer.from(JSON.stringify({
sessionId: 'sess-b',
url: 'https://example.com/x',
empNo: 'B',
}))
},
}, res)
assert.equal(res.statusCode, 401)
assert.equal(called, false)
})
test('SEC-06: identity-cache never restores a principal', () => {
rememberBrowserIdentity({ empNo: 'admin', role: 'super_admin' })
assert.equal(peekBrowserIdentity(), null)
})
test('SEC-08: new RolesStore does not enable shared default password', async () => {
const store = new RolesStore({})
await store.init()
assert.equal(store.isFallbackEnabled(), false)
assert.equal(store.verifyFallback(DEFAULT_FALLBACK_PASSWORD, '127.0.0.1'), false)
})
test('SEC-09: empty roles + non-initial admin cannot bootstrap via middleware helper', async () => {
const store = new MemoryStore()
const roles = mockRoles({})
const mw = createAuthMiddleware({
userSearchUrl: 'http://127.0.0.1:9/never',
initialAdminEmpNo: '99999',
allowOpenRegistration: true,
udsAuth: { baseUrl: 'http://127.0.0.1:9' },
session: { cookieMaxAge: 600000, slidingExpiration: true },
}, store, roles, {})
await assert.rejects(
() => mw.resolveRoleOnLogin('11111'),
(err) => err.code === 'bootstrap_required',
)
const role = await mw.resolveRoleOnLogin('99999')
assert.equal(role, ROLES.SUPER_ADMIN)
assert.equal(roles.getRole('99999'), ROLES.SUPER_ADMIN)
})
test('SEC-07: session cookie helper is HttpOnly', () => {
const c = buildSessionCookie('abc123def456abc123def456abc123de', 600, { secure: false })
assert.match(c, /^UDS_SESSION=/)
assert.match(c, /HttpOnly/)
assert.match(c, /SameSite=Lax/)
})
test('bearer hash is not reversible from store maps', async () => {
const store = new MemoryStore()
const { bearer } = await store.create({ empNo: 'x', isAuthenticated: true }, 60)
const hash = createHash('sha256').update(bearer).digest('hex')
assert.ok(store._byBearer.has(hash))
assert.equal([...store._byBearer.keys()].includes(bearer), false)
})

View file

@ -0,0 +1,111 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createDesktopBootstrap, DESKTOP_BOOTSTRAP_REF } from '../lib/desktop-bootstrap.js'
import { installGatewayPolicy } from '../lib/gateway-policy.js'
function fixture() {
let configured = true, invoked = 0
const services = {
settings: { describe: () => [
{ ns: 'locale', value: { preference: 'zh', hidden: 'LOCALE-SECRET' } },
{ ns: 'llm-deepseek', value: { apiKeyEnv: 'REAL_KEY_NAME', endpoint: 'PRIVATE-ENDPOINT' } },
{ ns: 'uds-auth', value: { initialAdminEmpNo: 'PRIVATE-ADMIN' } },
] },
llm: { listConfigurableProviders: () => [] },
credentials: { describe: async ref => { assert.equal(ref, 'REAL_KEY_NAME'); return { configured, writable: true, value: 'RAW-KEY' } } },
deepseekAccount: { getState: async () => ({ status: 'credential-stored', attempt: { authorizeUrl: 'SECRET-LOGIN-LINK' }, token: 'ACCOUNT-TOKEN' }) },
}
const deny = code => { throw new Error(code) }
const project = createDesktopBootstrap({ getService: name => services[name], deny })
let principal = null, admin = false, live = true
const gateway = {
async *openRemoteEvents() {}, receiveRemoteEventResult() {},
resolveDescriptor: (namespace, method) => ({ namespace, method, parameters: [] }),
prepareInvocation: async () => ({ method: () => { invoked++; return 'full-admin-result' } }),
}
installGatewayPolicy(gateway, { identity: () => principal, assertPrincipal: p => { if (!p || !live) deny('login_required') },
assertSession: () => {}, permissions: () => ({ canAccessSettings: admin }), deny, desktopBootstrap: project })
const invoke = async (namespace, method, args = {}) => (await gateway.prepareInvocation({ namespace, method, args })).method()
return { invoke, services, setConfigured: value => { configured = value },
setPrincipal: value => { principal = value }, setAdmin: value => { admin = value },
revoke: () => { live = false }, invocations: () => invoked }
}
test('unmodified Desktop welcome reads a bounded projection before UDS login', async () => {
const f = fixture()
const settings = await f.invoke('settings', 'describe')
assert.equal(settings.writable, false); assert.equal(settings.hasDocument, false)
assert.equal(settings.namespaces.find(row => row.ns === 'locale').value.preference, 'zh')
const providers = await f.invoke('llm', 'listConfigurableProviders')
const refs = providers.map(provider => settings.namespaces.find(row => row.ns === provider.settingsNs).value.apiKeyEnv)
const credentials = await f.invoke('credentials', 'describe', { refs })
assert.deepEqual(credentials, { [DESKTOP_BOOTSTRAP_REF]: { configured: true, writable: false } })
const account = await f.invoke('account', 'getState')
assert.equal(account.status, 'credential-stored'); assert.equal(account.attempt, null)
assert.equal(f.invocations(), 0, 'anonymous calls never invoke full Remote controllers')
const data = JSON.stringify([settings, providers, credentials, account])
for (const secret of ['REAL_KEY_NAME', 'PRIVATE-ENDPOINT', 'PRIVATE-ADMIN', 'RAW-KEY', 'ACCOUNT-TOKEN', 'SECRET-LOGIN-LINK', 'LOCALE-SECRET']) assert.equal(data.includes(secret), false)
f.setConfigured(false)
assert.equal((await f.invoke('credentials', 'describe', { refs }))[DESKTOP_BOOTSTRAP_REF].configured, false)
})
test('bootstrap cannot enumerate credential refs or access settings writes and sessions', async () => {
const f = fixture()
for (const args of [{ refs: ['REAL_KEY_NAME'] }, { refs: [DESKTOP_BOOTSTRAP_REF, 'OTHER'] }, {}, { refs: 'REAL_KEY_NAME' }]) {
await assert.rejects(f.invoke('credentials', 'describe', args), /forbidden_settings/)
}
for (const [ns, method] of [['settings', 'update'], ['credentials', 'set'], ['account', 'startSignIn'], ['session', 'list'], ['newPlugin', 'describe']]) {
await assert.rejects(f.invoke(ns, method), /login_required/)
}
assert.equal(f.invocations(), 0)
})
test('admin and trusted Host retain full reads; ordinary or revoked principals never receive them', async () => {
const f = fixture()
f.setPrincipal({ empNo: 'u' })
assert.equal((await f.invoke('settings', 'describe')).writable, false)
f.setAdmin(true)
assert.equal(await f.invoke('settings', 'describe'), 'full-admin-result')
f.setAdmin(false)
assert.equal((await f.invoke('settings', 'describe')).writable, false)
f.revoke()
await assert.rejects(f.invoke('settings', 'describe'), /login_required/)
f.setPrincipal(undefined)
assert.equal(await f.invoke('settings', 'describe'), 'full-admin-result')
})
test('custom providers are detected without exposing their namespace or credential names', async () => {
const f = fixture()
f.services.settings.describe = () => [{ ns: 'private-provider', value: { profiles: { one: { apiKeyEnv: 'REAL_KEY_NAME' } } } }]
f.services.llm.listConfigurableProviders = () => [{ settingsNs: 'private-provider', settingsPath: ['profiles', 'one'] }]
const value = await f.invoke('credentials', 'describe', { refs: [DESKTOP_BOOTSTRAP_REF] })
assert.equal(value[DESKTOP_BOOTSTRAP_REF].configured, true)
assert.equal(JSON.stringify(await f.invoke('settings', 'describe')).includes('private-provider'), false)
})
test('projection still reports a stored key when another provider or reference throws', async () => {
const deny = code => { throw new Error(code) }
const services = {
settings: { describe: () => [
{ ns: 'locale', value: {} },
{ ns: 'llm-deepseek', value: { apiKeyEnv: 'BROKEN_REF' } },
{ ns: 'llm-pi-ai', value: { providers: { suanlidao: { apiKeyEnv: 'SUANLIDAO_API_KEY' } } } },
] },
llm: { listConfigurableProviders: () => [
{ provider: 'suanlidao', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'suanlidao'] },
] },
credentials: { describe: async ref => {
if (ref === 'BROKEN_REF') throw new Error('provider exploded')
return { configured: ref === 'SUANLIDAO_API_KEY', writable: true }
} },
deepseekAccount: { getState: async () => { throw new Error('account backend down') } },
}
const project = createDesktopBootstrap({ getService: name => services[name], deny })
const described = await project({ namespace: 'credentials', method: 'describe', args: { refs: [DESKTOP_BOOTSTRAP_REF] } })
assert.equal(described[DESKTOP_BOOTSTRAP_REF].configured, true)
const account = await project({ namespace: 'account', method: 'getState', args: {} })
assert.equal(account.status, 'signed-out')
services.settings.describe = () => { throw new Error('settings not mounted') }
const none = await project({ namespace: 'credentials', method: 'describe', args: { refs: [DESKTOP_BOOTSTRAP_REF] } })
assert.equal(none[DESKTOP_BOOTSTRAP_REF].configured, false)
})

View file

@ -0,0 +1,353 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { RolesStore } from '../lib/roles.js'
import { MemoryStore } from '../lib/session/memory-store.js'
import { SessionBridgeStore } from '../lib/session-bridge.js'
import { UserWorkspaceStore } from '../lib/workspace-provision.js'
import {
extractSessionBearer,
extractSessionBearerAsync,
} from '../lib/session/request-auth.js'
import {
installDshAcl,
identityFromSessionRecord,
resolveIdentityFromRequest,
resolveIdentityFromRequestSync,
} from '../lib/dsh-acl.js'
import { runWithUserContext } from '../lib/context.js'
import { installGatewayPolicy } from '../lib/gateway-policy.js'
import { createAuthMiddleware } from '../lib/middleware/auth-middleware.js'
import { buildLocalAdminUserContext, sealLocalAdminBox } from '../lib/local-admin.js'
process.env.UDS_AUTH_LOCAL_ADMIN_BOX = sealLocalAdminBox('test-passphrase-ok')
const STALE = 'deadbeef'.repeat(8)
const EMERGENCY_LOGINS = [
['password fallback', 'fallback', () => ({
empNo: 'administrator',
username: 'Fallback Administrator',
isAuthenticated: true,
role: 'fallback_admin',
authMode: 'fallback-password',
fallbackCredVersion: 1,
})],
['sealed box', 'sealed_box', () => buildLocalAdminUserContext()],
]
function makeStores() {
const roles = new RolesStore({})
roles._fallbackPasswordHash = 'scrypt$test'
roles._fallbackCredVersion = 1
roles._prefs.set('administrator', { viewAllSessions: true })
return { roles, sessionStore: new MemoryStore(), sessionBridge: new SessionBridgeStore({}) }
}
async function login(stores, userContext, kind) {
const { bearer } = await stores.sessionStore.create(userContext, 1800)
const bridge = stores.sessionBridge.mint({ empNo: userContext.empNo, kind, token: bearer })
return { bearer, bridge }
}
function muxUpgrade(query, headers = {}) {
return {
method: 'GET',
url: '/api/remote.mux?' + query,
socket: { remoteAddress: '127.0.0.1' },
headers: { upgrade: 'websocket', connection: 'Upgrade', origin: 'dsh-app://app', ...headers },
}
}
for (const [label, kind, buildContext] of EMERGENCY_LOGINS) {
test(`${label}: stale UDS_SESSION cookie no longer shadows the WS ticket`, async () => {
const stores = makeStores()
const { bearer } = await login(stores, buildContext(), kind)
const deps = { ...stores, rolesStore: stores.roles }
const cookie = { cookie: `UDS_SESSION=${STALE}` }
const t1 = stores.sessionBridge.mintWsTicket({ empNo: 'administrator', bearer })
const sync = resolveIdentityFromRequestSync(muxUpgrade('udsWsTicket=' + t1.ticket, cookie), deps)
assert.equal(sync?.empNo, 'administrator')
assert.equal(sync?.kind, kind)
const t2 = stores.sessionBridge.mintWsTicket({ empNo: 'administrator', bearer })
const asyncId = await resolveIdentityFromRequest(muxUpgrade('udsWsTicket=' + t2.ticket, cookie), deps)
assert.equal(asyncId?.empNo, 'administrator')
assert.equal(asyncId?.kind, kind)
})
test(`${label}: stale cookie + loopback bridge query still authenticates remote.mux`, async () => {
const stores = makeStores()
const { bridge } = await login(stores, buildContext(), kind)
const req = muxUpgrade(
`udsBridgeEmpNo=administrator&udsBridgeToken=${bridge.token}&udsBridgeKind=${kind}`,
{ cookie: `UDS_SESSION=${STALE}` },
)
const id = resolveIdentityFromRequestSync(req, { ...stores, rolesStore: stores.roles })
assert.equal(id?.empNo, 'administrator')
})
test(`${label}: workspace follow shows every workspace (view-all on)`, async () => {
const stores = makeStores()
const { bearer } = await login(stores, buildContext(), kind)
const identity = identityFromSessionRecord(stores.sessionStore.getByBearerSync(bearer), stores.roles)
const { wc } = installFollowHarness(stores)
const frames = await collect(wc, identity)
assert.deepEqual(frames[0].value.items.map((w) => w.id), ['ws-admin', 'ws-shared'])
})
}
test('live cookie still wins and the WS ticket is not consumed', async () => {
const stores = makeStores()
const a = await login(stores, buildLocalAdminUserContext(), 'sealed_box')
const b = await login(stores, { empNo: '1001', authMode: 'token+profile' }, 'uds')
const ticket = stores.sessionBridge.mintWsTicket({ empNo: '1001', bearer: b.bearer })
const req = muxUpgrade('udsWsTicket=' + ticket.ticket, { cookie: `UDS_SESSION=${a.bearer}` })
const got = extractSessionBearer(req, {
sessionBridge: stores.sessionBridge,
isLiveBearer: (x) => !!stores.sessionStore.getByBearerSync(x),
})
assert.equal(got.via, 'cookie')
assert.equal(got.bearer, a.bearer)
assert.ok(stores.sessionBridge.consumeWsTicket(ticket.ticket), 'ticket left unconsumed')
})
test('dead cookie with no other credential keeps legacy cookie result', async () => {
const req = { method: 'GET', url: '/uds-auth/api/me', headers: { cookie: `UDS_SESSION=${STALE}` } }
const got = await extractSessionBearerAsync(req, {
sessionBridge: new SessionBridgeStore({}),
isLiveBearer: async () => false,
})
assert.deepEqual(got, { bearer: STALE, via: 'cookie' })
})
test('auth middleware: stale cookie falls through to bridge headers for /api/me', async () => {
const stores = makeStores()
const { bridge } = await login(stores, buildLocalAdminUserContext(), 'sealed_box')
const mw = createAuthMiddleware({
udsAuth: { baseUrl: 'https://uac.example.test', systemCode: 'test' },
session: { cookieMaxAge: 1_800_000, slidingExpiration: true },
}, stores.sessionStore, stores.roles, { sessionBridge: stores.sessionBridge })
const ctx = {
req: {
method: 'GET',
url: '/uds-auth/api/me',
socket: { remoteAddress: '127.0.0.1' },
headers: {
cookie: `UDS_SESSION=${STALE}`,
'x-uds-bridge-empno': 'administrator',
'x-uds-bridge-token': bridge.token,
'x-uds-bridge-kind': 'sealed_box',
},
},
res: { setHeader() {}, getHeader() {} },
}
await mw(ctx, async () => {})
assert.equal(ctx.empNo, 'administrator')
assert.equal(ctx.authKind, 'sealed_box')
assert.equal(ctx.permissions.canViewAllSessions, true)
})
test('auth middleware slides bridge expiry with the session', async () => {
const stores = makeStores()
const { bridge } = await login(stores, buildLocalAdminUserContext(), 'sealed_box')
const row = stores.sessionBridge._byToken.get(bridge.token)
row.exp = Date.now() + 5_000
const mw = createAuthMiddleware({
udsAuth: { baseUrl: 'https://uac.example.test', systemCode: 'test' },
session: { cookieMaxAge: 1_800_000, slidingExpiration: true },
}, stores.sessionStore, stores.roles, { sessionBridge: stores.sessionBridge })
const ctx = {
req: {
method: 'GET',
url: '/uds-auth/api/me',
headers: { 'x-uds-bridge-empno': 'administrator', 'x-uds-bridge-token': bridge.token },
},
res: { setHeader() {}, getHeader() {} },
}
await mw(ctx, async () => {})
assert.equal(ctx.empNo, 'administrator')
assert.ok(row.exp > Date.now() + 25 * 60_000, 'bridge exp extended to ~30min')
})
test('SessionBridgeStore.touch never revives an expired bridge', () => {
const store = new SessionBridgeStore({ ttlMs: 60_000 })
const live = store.mint({ empNo: 'administrator', kind: 'sealed_box' })
const dead = store.mint({ empNo: 'administrator', kind: 'sealed_box' })
store._byToken.get(dead.token).exp = Date.now() - 1
assert.equal(store.touch(live.token, 600_000), true)
assert.ok(store._byToken.get(live.token).exp > Date.now() + 500_000)
assert.equal(store.touch(dead.token, 600_000), false)
assert.equal(store.verify('administrator', dead.token), null)
assert.equal(store.touch('unknown'), false)
})
test('gateway lets anonymous workspace/follow reach dsh-acl (empty baseline, not an error)', async () => {
let principal = null
const descriptors = {
'workspace/follow': { namespace: 'workspace', method: 'follow', parameters: [], invocation: { kind: 'direct' }, mode: 'stream' },
'session/list': { namespace: 'session', method: 'list', parameters: [], invocation: { kind: 'direct' } },
}
const gateway = {
async *openRemoteEvents() {}, receiveRemoteEventResult() {},
resolveDescriptor(ns, method) { return descriptors[`${ns}/${method}`] },
async prepareInvocation(request) {
if (request.method === 'follow') {
return { method: async function* () { yield { type: 'baseline', value: { items: [] } } } }
}
return { method: () => 'ok' }
},
}
installGatewayPolicy(gateway, {
identity: () => principal,
assertPrincipal: (p) => { if (!p?.empNo) throw new Error('login_required_session') },
permissions: () => ({ canAccessSettings: false }),
assertSession: () => {},
deny: (code) => { throw new Error(code) },
})
const prepared = await gateway.prepareInvocation({ namespace: 'workspace', method: 'follow', args: {} })
const frames = []
for await (const f of prepared.method()) frames.push(f)
assert.equal(frames[0].type, 'baseline')
// Other endpoints stay locked for anonymous principals.
await assert.rejects(
gateway.prepareInvocation({ namespace: 'session', method: 'list', args: {} }),
/login_required_session/,
)
principal = {}
await assert.doesNotReject(gateway.prepareInvocation({ namespace: 'workspace', method: 'follow', args: {} }))
})
test('dsh-acl follow: anonymous gets one empty baseline and holds until abort', async () => {
const stores = makeStores()
const { wc } = installFollowHarness(stores)
const ac = new AbortController()
const frames = []
const done = runWithUserContext(null, async () => {
for await (const f of wc.follow(ac.signal)) frames.push(f)
})
await new Promise((r) => setTimeout(r, 20))
assert.equal(frames.length, 1)
assert.deepEqual(frames[0].value.items, [])
ac.abort()
await done
})
function installFollowHarness(stores) {
const items = [
{ id: 'ws-admin', workspaceId: 'ws-admin', path: 'C:/u/administrator', sessionIds: [] },
{ id: 'ws-shared', workspaceId: 'ws-shared', path: 'D:/project/harness', sessionIds: ['s1'] },
]
const uw = new UserWorkspaceStore({})
uw.set('administrator', { path: 'C:/u/administrator', workspaceId: 'ws-admin' })
const noop = async () => {}
const wc = {
create: noop, rename: noop, delete: noop, insertBefore: noop, insertSessionBefore: noop,
archiveSession: noop, unarchiveSession: noop, pinSession: noop, unpinSession: noop,
async *follow() {
yield { type: 'baseline', value: { items, archivedSessionIds: [], pinnedSessionIds: [] } }
},
}
installDshAcl({
inject(deps, cb) { if (deps.includes('workspaceController')) cb({ workspaceController: wc }) },
on() { return () => {} },
get() { throw new Error('no service') },
}, {
sessionAcl: { getOwner: () => null, setOwner() {} },
userWorkspaces: uw,
getWorkspaceRoot: () => '',
getRolesStore: () => stores.roles,
getSessionStore: () => stores.sessionStore,
ensureUserWorkspace: async () => ({ workspaceId: 'ws-admin' }),
getWorkspaceRegistry: () => ({ list: () => items, get: (id) => items.find((i) => i.id === id) }),
})
return { wc, items }
}
async function collect(wc, identity) {
const frames = []
await runWithUserContext(identity, async () => {
for await (const f of wc.follow(new AbortController().signal)) frames.push(f)
})
return frames
}
test('client emits connection/reset after login so boot-time caches (settings mirror) reload', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
// connection.reconnect() alone never emits connection/reset (DSH only emits it on onConnected).
assert.match(src, /window\.__udsAuthEmitReset = \(\) => \{\s*try \{ cctx\.emit\('connection\/reset'\) \}/)
assert.match(src, /function scheduleAuthReset\(/)
// fires after a soft reconnect and on the first authenticated /api/me per empNo
assert.match(src, /_softReconnectQuietUntil = Date\.now\(\) \+ 2800\s*\n\s*scheduleAuthReset\(/)
assert.match(src, /window\.__udsAuthResetEmp !== resetEmp/)
// cleared on logout / unauthenticated so the next login resets again
assert.equal((src.match(/window\.__udsAuthResetEmp = null/g) || []).length, 2)
})
test('anonymous workspace/initializeDefault is a no-op (no default-workspace error toast)', async () => {
let principal = null
let invoked = 0
const gateway = {
async *openRemoteEvents() {}, receiveRemoteEventResult() {},
resolveDescriptor: (namespace, method) => ({ namespace, method, parameters: [], invocation: { kind: 'direct' } }),
async prepareInvocation() { return { method: () => { invoked++; return { workspace: { workspaceId: 'w' } } } } },
}
installGatewayPolicy(gateway, {
identity: () => principal,
assertPrincipal: (p) => { if (!p?.empNo) throw new Error('login_required_session') },
permissions: () => ({ canAccessSettings: true }),
assertSession: () => {},
deny: (code) => { throw new Error(code) },
})
const anon = await gateway.prepareInvocation({ namespace: 'workspace', method: 'initializeDefault', args: {} })
assert.equal(anon.method(), undefined)
assert.equal(invoked, 0, 'real controller never runs for anonymous callers')
principal = { empNo: 'administrator' }
const authed = await gateway.prepareInvocation({ namespace: 'workspace', method: 'initializeDefault', args: {} })
assert.deepEqual(authed.method(), { workspace: { workspaceId: 'w' } })
assert.equal(invoked, 1)
principal = null
await assert.rejects(
gateway.prepareInvocation({ namespace: 'workspace', method: 'create', args: {} }),
/login_required_session/,
)
})
test('anonymous stream endpoints are parked until the carrier aborts (no terminal error)', async () => {
let principal = null
let invoked = 0
const control = new AbortController()
const gateway = {
async *openRemoteEvents() {}, receiveRemoteEventResult() {},
resolveDescriptor: (namespace, method) => ({ namespace, method, parameters: [], invocation: { kind: 'direct' }, mode: 'stream' }),
async prepareInvocation() {
return {
invocation: { signal: control.signal },
method: async function* () { invoked++; yield { status: 'credential-stored' } },
}
},
}
installGatewayPolicy(gateway, {
identity: () => principal,
assertPrincipal: (p) => { if (!p?.empNo) throw new Error('login_required_session') },
permissions: () => ({ canAccessSettings: true }),
assertSession: () => {},
deny: (code) => { throw new Error(code) },
})
const prepared = await gateway.prepareInvocation({ namespace: 'account', method: 'watch', args: {} })
const it = prepared.method()[Symbol.asyncIterator]()
let settled = false
const next = it.next().then((r) => { settled = true; return r })
await new Promise((r) => setTimeout(r, 30))
assert.equal(settled, false, 'anonymous stream yields nothing and does not fail')
control.abort()
assert.deepEqual(await next, { value: undefined, done: true })
assert.equal(invoked, 0, 'real stream never opened for anonymous callers')
principal = { empNo: 'administrator' }
const authed = await gateway.prepareInvocation({ namespace: 'account', method: 'watch', args: {} })
const frames = []
for await (const f of authed.method()) frames.push(f)
assert.deepEqual(frames, [{ status: 'credential-stored' }])
})

View file

@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { getUserContext, runAsHost, withUserContext } from '../lib/context.js'
import { installDshAcl } from '../lib/dsh-acl.js'
import { MemoryStore } from '../lib/session/memory-store.js'
test('Host callback context is isolated across concurrent browser identities and exceptions', async () => {
const identities = [null, { empNo: 'u1' }, { empNo: 'u2' }]
@ -22,6 +23,8 @@ test('Host callback context is isolated across concurrent browser identities and
test('trusted Host creation works from browser ALS while unauthenticated browser creation stays denied', async () => {
let creations = 0
const owners = []
const sessionStore = new MemoryStore()
const minted = await sessionStore.create({ empNo: 'u1' }, 600)
const controller = {
async list() { return { items: [] } },
async search() { return { items: [] } },
@ -41,6 +44,7 @@ test('trusted Host creation works from browser ALS while unauthenticated browser
userWorkspaces: { isUserPath: (empNo, path) => path === `/ws/${empNo}`, get() { return null } },
getWorkspaceRoot: () => '/ws',
getWorkspaceRegistry: () => undefined,
getSessionStore: () => sessionStore,
})
await withUserContext(null, async () => {
await assert.rejects(controller.create({ cwd: '/bot' }), error => (
@ -53,7 +57,11 @@ test('trusted Host creation works from browser ALS while unauthenticated browser
})
assert.equal(creations, 1)
assert.deepEqual(owners, [], 'background sessions must not inherit a browser owner')
await withUserContext({ empNo: 'u1', permissions: {} }, async () => {
await withUserContext({
empNo: 'u1',
sessionId: minted.sessionId,
permissions: {},
}, async () => {
await assert.rejects(controller.create({ cwd: '/other' }), error => (
error.details.udsError === 'session_workspace_only'
))

View file

@ -1,5 +1,8 @@
import { describe, it, after } from 'node:test'
import assert from 'node:assert/strict'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import {
LOCAL_ADMIN_BOX_ENV,
LOCAL_ADMIN_ENV,
@ -38,6 +41,8 @@ describe('local-admin sealed box', () => {
it('env box alone does not imply auto-login; only configures unlock', () => {
delete process.env[LOCAL_ADMIN_BOX_ENV]
// Isolate from a developer machine's ~/.uds-auth/local-admin.box
process.env.UDS_AUTH_DATA_DIR = join(tmpdir(), `uds-auth-box-${Date.now()}`)
assert.equal(isLocalAdminBoxConfigured(), false)
const box = sealLocalAdminBox('another-strong-key')
@ -50,4 +55,17 @@ describe('local-admin sealed box', () => {
const stillNeedDecrypt = openLocalAdminBox(box, process.env[LOCAL_ADMIN_ENV])
assert.equal(stillNeedDecrypt.ok, false)
})
it('reads sealed box from profile local-admin.box when env is unset', () => {
delete process.env[LOCAL_ADMIN_BOX_ENV]
const dir = join(tmpdir(), `uds-auth-box-file-${Date.now()}`)
mkdirSync(dir, { recursive: true })
process.env.UDS_AUTH_DATA_DIR = dir
assert.equal(isLocalAdminBoxConfigured(), false)
const box = sealLocalAdminBox('file-backed-passphrase')
writeFileSync(join(dir, 'local-admin.box'), box, 'utf8')
assert.equal(isLocalAdminBoxConfigured(), true)
assert.equal(readLocalAdminBox(), box)
})
})

View file

@ -0,0 +1,104 @@
/**
* Phase-2 ACL tests (SEC-10/12/13/14/15/26 fragments).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { createSessionAccess } from '../lib/dsh-acl.js'
import { computePermissions, RolesStore, ROLES } from '../lib/roles.js'
import { SessionAclStore } from '../lib/session-acl.js'
import { UserWorkspaceStore, resolveWorkspaceRoot } from '../lib/workspace-provision.js'
import { sanitizeUserId, resolveUserDir } from '../lib/utils/safe-userid.js'
import { join } from 'node:path'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
test('SEC-13: foreign owner is not overridden by own cwd', () => {
const ownersMap = new Map([['s-foreign', 'u2']])
const { canAccessSession } = createSessionAccess({
sessionAcl: { getOwner: (id) => ownersMap.get(String(id)) || null },
userWorkspaces: {
get: (empNo) => (empNo === 'u1' ? { path: '/ws/u1', workspaceId: 'ws-u1' } : null),
isUserPath: (empNo, candidate) => {
if (empNo !== 'u1' || !candidate) return false
const path = String(candidate).replace(/\\/g, '/')
return path === '/ws/u1' || path.startsWith('/ws/u1/')
},
},
getWorkspaceRoot: () => '/ws',
getWorkspaceRegistry: () => ({ list: () => [] }),
})
const user = { empNo: 'u1', role: 'user', permissions: computePermissions('user') }
assert.equal(canAccessSession('s-foreign', user, { cwd: '/ws/u1/project' }), false)
assert.equal(canAccessSession('s-foreign', user, { cwd: '/ws/u2' }), false)
})
test('SEC-13: setOwner refuses overwrite of foreign owner without force', () => {
const store = new SessionAclStore({})
assert.equal(store.setOwner('s1', 'A'), true)
assert.equal(store.setOwner('s1', 'B'), false)
assert.equal(store.getOwner('s1'), 'A')
assert.equal(store.setOwner('s1', 'B', { force: true }), true)
assert.equal(store.getOwner('s1'), 'B')
})
test('SEC-14: sanitizeUserId rejects path escape segments', () => {
assert.equal(sanitizeUserId('../escape'), null)
assert.equal(sanitizeUserId('..\\escape'), null)
assert.equal(sanitizeUserId('a/b'), null)
assert.equal(sanitizeUserId('a\\b'), null)
assert.equal(sanitizeUserId('C:'), null)
assert.equal(sanitizeUserId('con'), null)
assert.equal(sanitizeUserId('10001'), '10001')
assert.equal(sanitizeUserId('user.name'), 'user.name')
})
test('SEC-14: resolveUserDir / isUserPath block ../ empNo', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-ws-'))
try {
const root = resolveWorkspaceRoot(dir)
assert.equal(resolveUserDir(root, '../escape'), null)
assert.equal(resolveUserDir(root, '10001')?.endsWith('10001') || resolveUserDir(root, '10001')?.includes('10001'), true)
const store = new UserWorkspaceStore({})
assert.equal(store.isUserPath('../escape', join(root, 'x'), root), false)
assert.equal(store.isUserPath('10001', join(root, '10001', 'a'), root), true)
// Outside user dir
assert.equal(store.isUserPath('10001', join(root, '10002'), root), false)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('SEC-26: setRole cannot assign fallback_admin', async () => {
const store = new RolesStore({})
store._roles.set('admin1', ROLES.ADMIN)
await assert.rejects(
() => store.setRole('u1', ROLES.FALLBACK_ADMIN, ROLES.ADMIN),
(err) => err.code === 'invalid_role_params',
)
await store.setRole('u1', ROLES.USER, ROLES.ADMIN)
assert.equal(store.getRole('u1'), ROLES.USER)
})
test('SEC-10: live rolesStore demotion removes view-all', () => {
const store = new RolesStore()
store._roles.set('boss', ROLES.SUPER_ADMIN)
const ownersMap = new Map([['s-peer', 'u2']])
const access = createSessionAccess({
sessionAcl: { getOwner: (id) => ownersMap.get(String(id)) || null },
userWorkspaces: { get: () => null, isUserPath: () => false },
getWorkspaceRoot: () => '/ws',
getWorkspaceRegistry: () => ({ list: () => [] }),
rolesStore: store,
})
const identity = {
empNo: 'boss',
role: 'super_admin',
permissions: computePermissions('super_admin', { viewAllSessions: true }),
}
assert.equal(access.canAccessSession('s-peer', identity), true)
// Demote in store; frozen identity.permissions still look admin — live check wins.
store._roles.set('boss', ROLES.USER)
assert.equal(access.canSeeAll(identity), false)
assert.equal(access.canAccessSession('s-peer', identity), false)
})

View file

@ -0,0 +1,165 @@
/**
* Phase-3 tests (SEC-16/17/18/19/21 fragments).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { searchUserByEmpNoToken } from '../lib/uds/user-search.js'
import { sealSecret, openSecret, deriveKeyFromPassphrase } from '../lib/utils/secret-box.js'
import { SkillCredentialCache } from '../lib/skill-credentials.js'
import { checkRequestOrigin, requireJsonContentType } from '../lib/utils/origin-guard.js'
import { createAgentAuthHandlers } from '../lib/agent-auth.js'
import { atomicWriteJson } from '../lib/utils/atomic-write.js'
import { RolesStore, ROLES } from '../lib/roles.js'
test('SEC-16: substring / digit-contains subject match is rejected', async () => {
// Monkey-patch directRequest via injecting a fake by testing parser path:
// Call search with stubs by temporarily replacing module is hard; unit-test
// the acceptance logic through a local reimplementation of the filter rules.
const want = '12345'
const list = [
{ employeeShortId: '123456', name: 'X' }, // prefix — must NOT match
]
const matches = list.filter((row) => String(row.employeeShortId || '').trim() === want)
assert.equal(matches.length, 0)
// Simulate HTTP 500 + biz ok — searchUserByEmpNoToken would need network;
// instead assert the exported function rejects missing url without calling net.
const missing = await searchUserByEmpNoToken({
userSearchUrl: '',
empNo: '1',
token: 't',
})
assert.equal(missing.ok, false)
assert.equal(missing.reason, 'missing_args')
})
test('SEC-17: seal/open secret roundtrip; wrong key fails', () => {
const key = deriveKeyFromPassphrase('test-pass')
const blob = sealSecret(key, 'upstream-token-secret')
assert.ok(blob.startsWith('uds-secret-v1.'))
assert.equal(openSecret(key, blob), 'upstream-token-secret')
const other = deriveKeyFromPassphrase('other')
assert.equal(openSecret(other, blob), null)
})
test('SEC-17: SkillCredentialCache persists encrypted tokens only', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-cred-'))
const file = join(dir, 'skill-credentials.json')
try {
const cache = new SkillCredentialCache({ file, dataDir: dir, ttlMs: 60_000 })
await cache.init()
cache.set('10001', 'plain-uac-token')
// Force flush
await cache._flush()
const raw = await readFile(file, 'utf-8')
assert.equal(raw.includes('plain-uac-token'), false)
assert.match(raw, /tokenEnc/)
const again = new SkillCredentialCache({ file, dataDir: dir, ttlMs: 60_000 })
await again.init()
assert.equal(again.get('10001')?.token, 'plain-uac-token')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('SEC-18: outbound hostAllowed is exact match only', async () => {
const handlers = createAgentAuthHandlers({
resolveCredentialsForSession: async () => ({ empNo: 'A', token: 't' }),
getSessionOwner: () => 'A',
resolveCallerSession: async () => ({ empNo: 'A' }),
outboundHosts: ['icenterapi.zte.com.cn'],
})
// Probe via hostAllowed closure — call outbound with http and wrong host
const makeRes = () => ({
statusCode: 0, headers: {}, body: '',
setHeader(k, v) { this.headers[k] = v },
end(s) { this.body = s },
})
const loopbackReq = (payload) => ({
method: 'POST',
socket: { remoteAddress: '127.0.0.1' },
headers: { 'content-type': 'application/json' },
async *[Symbol.asyncIterator]() {
yield Buffer.from(JSON.stringify(payload))
},
})
const resHttp = makeRes()
await handlers.handleOutbound(loopbackReq({
sessionId: 's1',
url: 'http://icenterapi.zte.com.cn/x',
}), resHttp)
assert.equal(resHttp.statusCode, 400)
assert.match(resHttp.body, /https_required/)
const resSub = makeRes()
await handlers.handleOutbound(loopbackReq({
sessionId: 's1',
url: 'https://evil.icenterapi.zte.com.cn/x',
}), resSub)
assert.equal(resSub.statusCode, 403)
assert.match(resSub.body, /host_not_allowed/)
})
test('SEC-19: origin guard rejects cross-site and mismatched Origin', () => {
assert.equal(checkRequestOrigin({
method: 'POST',
headers: { host: '127.0.0.1:8787', origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' },
socket: { remoteAddress: '10.0.0.1' },
}).ok, false)
// A09: non-loopback Host requires explicit trustedHosts allowlist.
assert.equal(checkRequestOrigin({
method: 'POST',
headers: { host: 'app.example:443', origin: 'https://app.example:443' },
socket: { remoteAddress: '10.0.0.1' },
}).ok, false)
assert.equal(checkRequestOrigin({
method: 'POST',
headers: { host: 'app.example:443', origin: 'https://app.example:443' },
socket: { remoteAddress: '10.0.0.1' },
}, { trustedHosts: ['app.example'] }).ok, true)
assert.equal(checkRequestOrigin({
method: 'POST',
headers: { host: '127.0.0.1:8787' },
socket: { remoteAddress: '127.0.0.1' },
}).ok, true)
assert.equal(requireJsonContentType({
method: 'POST',
headers: { 'content-type': 'text/plain' },
}).ok, false)
})
test('SEC-21: atomicWriteJson replaces file', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-atomic-'))
const file = join(dir, 'roles.json')
try {
await atomicWriteJson(file, { a: 1 })
assert.deepEqual(JSON.parse(await readFile(file, 'utf-8')), { a: 1 })
await atomicWriteJson(file, { b: 2 })
assert.deepEqual(JSON.parse(await readFile(file, 'utf-8')), { b: 2 })
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('SEC-21: RolesStore save uses durable path without throwing', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-roles-'))
const file = join(dir, 'roles.json')
try {
const store = new RolesStore({ rolesFile: file })
await store.init()
store._roles.set('u1', ROLES.USER)
store._markDirty()
await store.flush()
const data = JSON.parse(await readFile(file, 'utf-8'))
assert.equal(data.roles.u1, 'user')
} finally {
await rm(dir, { recursive: true, force: true })
}
})

View file

@ -0,0 +1,65 @@
/**
* Phase-4 ops tests (SEC-23/25 fragments).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { Readable } from 'node:stream'
import { readBodyLimited, readJsonBodyLimited } from '../lib/utils/read-body.js'
import { RateLimiter } from '../lib/utils/rate-limit.js'
import { audit, redactValue } from '../lib/utils/audit-log.js'
function fakeReq(chunks) {
return Readable.from(chunks)
}
test('SEC-23: readBodyLimited rejects oversized payload', async () => {
const big = Buffer.alloc(1024, 0x61)
await assert.rejects(
() => readBodyLimited(fakeReq([big, big]), { maxBytes: 1500 }),
(err) => err.code === 'payload_too_large',
)
})
test('SEC-23: readJsonBodyLimited parses small JSON', async () => {
const req = fakeReq([Buffer.from('{"a":1}')])
assert.deepEqual(await readJsonBodyLimited(req, { maxBytes: 100 }), { a: 1 })
})
test('SEC-23: RateLimiter eventually blocks and cleans buckets', () => {
const lim = new RateLimiter({ windowMs: 60_000, max: 3 })
assert.equal(lim.allow('ip1'), true)
assert.equal(lim.allow('ip1'), true)
assert.equal(lim.allow('ip1'), true)
assert.equal(lim.allow('ip1'), false)
assert.equal(lim.allow('ip2'), true)
lim.dispose()
})
test('SEC-25: audit redacts secrets and returns structured row', () => {
const lines = []
const row = audit({
action: 'test',
decision: 'deny',
reasonCode: 'x',
actor: 'u1',
meta: { token: 'secret-value', nested: { password: 'p' }, ok: 'fine' },
}, {
logger: {
warn(s) { lines.push(s) },
info() {},
},
})
assert.equal(row.type, 'uds-auth.audit')
assert.equal(row.meta.token, '[redacted]')
assert.equal(row.meta.nested.password, '[redacted]')
assert.equal(row.meta.ok, 'fine')
assert.equal(lines.length, 1)
assert.equal(lines[0].includes('secret-value'), false)
})
test('SEC-25: redactValue truncates long strings', () => {
const long = 'x'.repeat(100)
const out = redactValue(long)
assert.ok(String(out).includes('…'))
assert.equal(String(out).includes(long), false)
})

View file

@ -0,0 +1,353 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, writeFile, mkdir, rm, readdir, lstat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { RolesStore, ROLES } from '../lib/roles.js'
import { hashPasswordAsync, derivePasswordKey } from '../lib/utils/password-kdf.js'
import { sealLocalAdminBox, openLocalAdminBoxAsync } from '../lib/local-admin.js'
import { QrChallengeStore, qrLoginCredentials } from '../lib/qr-challenge.js'
import { TaskCapabilityStore } from '../lib/task-capability.js'
import { createTaskEnvironment } from '../lib/task-environment.js'
import { SessionBridgeStore } from '../lib/session-bridge.js'
import { extractSessionBearer } from '../lib/session/request-auth.js'
import { migrateState } from '../lib/state-migration.js'
import { atomicWriteFile } from '../lib/utils/atomic-write.js'
import { checkRequestOrigin } from '../lib/utils/origin-guard.js'
import { protectProfileDirectory } from '../lib/profile-security.js'
import { runInNewContext } from 'node:vm'
import { installGatewayPolicy } from '../lib/gateway-policy.js'
import { installGatewayEvents } from '../lib/gateway-events.js'
test('Gateway guards new namespaces and lookup owners before invocation, including live streams', async () => {
let principal = { empNo: 'u' }, active = true, viewAll = false, prepared = 0
const descriptors = {
global: { namespace: 'newPlugin', method: 'global', parameters: [], invocation: { kind: 'direct' } },
read: { namespace: 'newPlugin', method: 'read', parameters: [{ name: 'workspaceFileScope', source: 'lookup', wire: 'workspaceFileScopeId' }], invocation: { kind: 'direct' } },
stream: { namespace: 'newPlugin', method: 'stream', parameters: [{ name: 'sessionId', wire: 'sessionId' }], invocation: { kind: 'direct' }, mode: 'stream' },
}
const gateway = {
async *openRemoteEvents() {}, receiveRemoteEventResult() {},
resolveDescriptor(ns, method) { return descriptors[method] },
async prepareInvocation(request) {
prepared++
return { method: request.method === 'stream' ? async function* () { yield 'first'; yield 'second' } : () => 'ok' }
},
}
installGatewayPolicy(gateway, { identity: () => principal,
assertPrincipal: p => { if (!p || !active) throw new Error('login_required') },
permissions: () => ({ canAccessSettings: viewAll }),
assertSession: sid => { if (sid !== 'own') throw new Error('session_forbidden') },
deny: code => { throw new Error(code) } })
await assert.rejects(gateway.prepareInvocation({ namespace: 'newPlugin', method: 'global', args: {} }), /forbidden_settings/)
await assert.rejects(gateway.prepareInvocation({ namespace: 'newPlugin', method: 'read', args: { workspaceFileScopeId: 'foreign' } }), /session_forbidden/)
assert.equal(prepared, 0, 'authorization precedes providers and lookups')
const invocation = await gateway.prepareInvocation({ namespace: 'newPlugin', method: 'read', args: { workspaceFileScopeId: 'own' } })
assert.equal(invocation.method(), 'ok')
const stream = (await gateway.prepareInvocation({ namespace: 'newPlugin', method: 'stream', args: { sessionId: 'own' } })).method()
assert.equal((await stream.next()).value, 'first')
active = false; await assert.rejects(stream.next(), /login_required/)
principal = undefined
assert.equal((await gateway.prepareInvocation({ namespace: 'newPlugin', method: 'global', args: {} })).method(), 'ok')
principal = null; await assert.rejects(gateway.prepareInvocation({ namespace: 'newPlugin', method: 'global', args: {} }), /login_required/)
assert.throws(() => installGatewayPolicy({}, {}), /unsupported_gateway_acl_contract/)
})
test('Gateway event stream hides foreign events and binds responses to the issuing local session', async () => {
let principal = { empNo: 'u', sessionId: 'login1' }, active = true
let finish
const gate = new Promise(resolve => { finish = resolve })
const gateway = { pendingRemoteEvents: new Map([['event-own', { frame: { agentId: 'own' } }]]),
async *openRemoteEvents() {
yield { type: 'ready', clientId: 'client1', host: { home: 'private-home' } }
yield { type: 'emit', event: 'api-session/added', args: [{ id: 'foreign', title: 'secret' }] }
yield { type: 'emit', event: 'api-session/status', args: ['own', true] }
yield { type: 'emit', event: 'credentials/record-updated', args: ['global-secret'] }
yield { type: 'waterfall', eventId: 'event-foreign', agentId: 'foreign', request: { secret: 'hidden' } }
yield { type: 'waterfall', eventId: 'event-own', agentId: 'own', request: {} }
await gate
yield { type: 'emit', event: 'api-session/status', args: ['own', false] }
}, receiveRemoteEventResult() { return 'accepted' } }
installGatewayEvents(gateway, { identity: () => principal,
assertPrincipal: p => { if (!p || !active) throw new Error('login_required') },
permissions: () => ({ canAccessSettings: false }), assertSession: sid => { if (sid !== 'own') throw new Error('session_forbidden') },
deny: code => { throw new Error(code) } })
const stream = gateway.openRemoteEvents()
assert.deepEqual((await stream.next()).value.host, { home: '' })
assert.equal((await stream.next()).value.event, 'api-session/status')
assert.equal((await stream.next()).value.eventId, 'event-own')
const result = { clientId: 'client1', eventId: 'event-own' }
assert.equal(gateway.receiveRemoteEventResult({}, result), 'accepted')
principal = { empNo: 'u', sessionId: 'login2' }
assert.throws(() => gateway.receiveRemoteEventResult({}, result), /event_client_forbidden/)
active = false; finish(); await assert.rejects(stream.next(), /login_required/)
})
test('background shell capability survives foreground completion and ends with its own job', () => {
const caps = new TaskCapabilityStore(), roles = new RolesStore({}), controller = new AbortController()
const env = createTaskEnvironment({ getOwner: () => 'u', rolesStore: roles, capabilities: caps, getWebServer: () => ({ port: 12345 }) })
const execution = { token: {}, agent: { session: { header: { id: 'own' } } }, signal: controller.signal }
const values = env.contributor.resolve(execution)
let listener, unsubscribed = false
const jobs = { list: () => [{ id: 'job1', status: 'running' }], events: { subscribe(scope, fn) { assert.deepEqual(scope, { owner: 'own' }); listener = fn; return () => { unsubscribed = true } } } }
env.finish(execution, { value: { kind: 'background', jobId: 'job1' } }, jobs)
controller.abort()
assert.ok(caps.verify(values.DSH_UDS_TASK_CAPABILITY))
listener({ type: 'settled', job: { id: 'other' } }); assert.ok(caps.verify(values.DSH_UDS_TASK_CAPABILITY))
listener({ type: 'settled', job: { id: 'job1' } }); assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null)
assert.equal(unsubscribed, true)
})
async function temporary(t) {
const dir = await mkdtemp(join(tmpdir(), 'uds-auth-0310-'))
t.after(() => rm(dir, { recursive: true, force: true }))
return dir
}
test('password transactions queue set/set/clear and persist a restart-safe generation', async t => {
const dir = await temporary(t), file = join(dir, 'roles.json')
const store = new RolesStore({ rolesFile: file })
await store.init()
await Promise.all([
store.setFallbackPassword('password-first-0310', ROLES.SUPER_ADMIN),
store.setFallbackPassword('password-second-0310', ROLES.SUPER_ADMIN),
store.clearFallbackPassword(ROLES.SUPER_ADMIN),
])
assert.equal(store.isFallbackEnabled(), false)
assert.equal(store.getFallbackCredVersion(), 3)
const reopened = new RolesStore({ rolesFile: file }); await reopened.init()
assert.equal(reopened.isFallbackEnabled(), false)
assert.equal(reopened.getFallbackCredVersion(), 3)
})
test('queued admin writes recheck the actor after a preceding demotion commits', async t => {
const store = new RolesStore({ rolesFile: join(await temporary(t), 'roles.json') }); await store.init()
await store.ensureUser('actor', ROLES.SUPER_ADMIN); await store.setRole('actor', ROLES.ADMIN, ROLES.SUPER_ADMIN)
await store.ensureUser('target', ROLES.SUPER_ADMIN)
const flush = store.flush.bind(store)
let start, finish
const started = new Promise(resolve => { start = resolve }), gate = new Promise(resolve => { finish = resolve })
store.flush = async () => { start(); await gate; return flush() }
const demotion = store.setRole('actor', ROLES.USER, ROLES.SUPER_ADMIN)
await started
const stale = store.setRole('target', ROLES.ADMIN, () => store.getRole('actor'))
const denied = assert.rejects(stale, { code: 'forbidden_set_role' })
finish(); await demotion; await denied
assert.equal(store.getRole('target'), ROLES.USER)
})
test('failed credential write cannot resurrect an uncommitted peer; next queued write commits', async t => {
const file = join(await temporary(t), 'roles.json')
const store = new RolesStore({ rolesFile: file }); await store.init()
await store.setFallbackPassword('original-password-0310', ROLES.SUPER_ADMIN)
const originalFlush = store.flush.bind(store)
let start, finish
const started = new Promise(r => start = r), gate = new Promise(r => finish = r)
let count = 0
store.flush = async () => {
if (++count === 1) { start(); await gate; throw new Error('injected-write-failure') }
await originalFlush()
}
const first = store.setFallbackPassword('uncommitted-password-0310', ROLES.SUPER_ADMIN)
const failure = assert.rejects(first, /injected-write-failure/)
await started
const second = store.setFallbackPassword('committed-second-0310', ROLES.SUPER_ADMIN)
assert.equal(store.getFallbackCredVersion(), 1, 'readers see only the durable generation')
finish(); await failure; await second
assert.equal(await store.verifyFallbackAsync('uncommitted-password-0310', 'ip1'), false)
assert.equal(await store.verifyFallbackAsync('committed-second-0310', 'ip2'), true)
const again = new RolesStore({ rolesFile: file }); await again.init()
assert.equal(again.getFallbackCredVersion(), 2)
assert.equal(await again.verifyFallbackAsync('committed-second-0310', 'ip3'), true)
})
test('staged role promotion is invisible before commit; failed preferences restore durable state', async t => {
const store = new RolesStore({ rolesFile: join(await temporary(t), 'roles.json') }); await store.init()
await store.ensureUser('u', ROLES.SUPER_ADMIN); await store.ensureUser('boss', ROLES.SUPER_ADMIN)
await store.setRole('boss', ROLES.SUPER_ADMIN, ROLES.SUPER_ADMIN)
let start, finish
const started = new Promise(r => start = r), gate = new Promise(r => finish = r)
store.flush = async () => { start(); await gate; throw new Error('disk-offline') }
const promotion = store.setRole('u', ROLES.ADMIN, ROLES.SUPER_ADMIN)
const failed = assert.rejects(promotion, /disk-offline/)
await started
assert.equal(store.resolvePermissions('u').canAccessSettings, false)
finish(); await failed
assert.equal(store.getRole('u'), ROLES.USER)
await assert.rejects(store.setViewAllSessionsDurable('boss', false), /disk-offline/)
assert.equal(store.resolvePermissions('boss').canViewAllSessions, true)
})
test('runtime KDF leaves the event loop responsive and rejects queue overload', async () => {
let heartbeat = false
const timer = setTimeout(() => { heartbeat = true }, 0)
await hashPasswordAsync('responsive-password-0310')
clearTimeout(timer)
assert.equal(heartbeat, true)
const results = await Promise.allSettled(Array.from({ length: 12 }, () => derivePasswordKey('load-password', Buffer.alloc(16))))
assert.equal(results.filter(r => r.status === 'fulfilled').length, 10)
assert.equal(results.filter(r => r.status === 'rejected' && r.reason.code === 'kdf_busy').length, 2)
})
test('async sealed-box unlock uses the existing wire format and rejects wrong passphrase', async () => {
const box = sealLocalAdminBox('offline-passphrase-0310')
assert.equal((await openLocalAdminBoxAsync(box, 'offline-passphrase-0310')).ok, true)
assert.equal((await openLocalAdminBoxAsync(box, 'wrong-passphrase-0310')).ok, false)
})
test('QR pending survives; public scan coordinates cannot poll or consume on another browser', () => {
const store = new QrChallengeStore(), c = store.create({ loginSystemCode: 'sys', originSystemCode: '' })
assert.equal(c.qrCodeStr.includes(c.browserBinding), false)
assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, '').reason, 'qr_binding_required')
assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, 'f'.repeat(64)).ok, false)
assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, c.browserBinding).ok, true)
assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, c.browserBinding).ok, true)
const results = Array.from({ length: 20 }, () => store.consume(c.qrCodeKey, c.qrCodeValue, c.browserBinding))
assert.equal(results.filter(r => r.ok).length, 1)
assert.equal(results.filter(r => r.reason === 'qr_replay').length, 19)
})
test('QR expiry and missing-credential success fail closed', () => {
const store = new QrChallengeStore(), c = store.create({ loginSystemCode: 'sys', originSystemCode: '' })
store.rows.get(c.qrCodeKey).expiresAt = Date.now() - 1
assert.equal(store.consume(c.qrCodeKey, c.qrCodeValue, c.browserBinding).reason, 'qr_expired')
assert.equal(qrLoginCredentials({ code: { code: '0000' }, bo: { code: '0000' } }), null)
assert.equal(qrLoginCredentials({ code: '0000', bo: { code: '4002' }, other: { account: 'u', token: 't' } }), null)
assert.deepEqual(qrLoginCredentials({ code: '0000', bo: { code: '0000' }, other: { account: 'u', token: 't' } }), { empNo: 'u', token: 't' })
})
test('shell/cron environment derives the owner and revokes on tool result/abort/disposal', () => {
const roles = new RolesStore({}), caps = new TaskCapabilityStore()
const owners = new Map([['s1', 'u']])
const env = createTaskEnvironment({ getOwner: sid => owners.get(sid), rolesStore: roles,
capabilities: caps, getWebServer: () => ({ port: 43210 }) })
const controller = new AbortController()
const execution = { token: {}, agent: { session: { header: { id: 's1' } } }, signal: controller.signal,
arguments: { empNo: 'other', env: { UDS_TASK_CAPABILITY: 'forged' } } }
let values = env.contributor.resolve(execution)
let cap = caps.verify(values.DSH_UDS_TASK_CAPABILITY, { dshSessionId: 's1' })
assert.equal(cap.empNo, 'u'); assert.deepEqual(cap.scopes, ['outbound'])
assert.equal(values.DSH_UDS_AUTH_BASE, 'http://127.0.0.1:43210/uds-auth')
assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY, { dshSessionId: 's2' }), null)
env.release({ token: execution.token }); assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null)
values = env.contributor.resolve(execution); controller.abort()
assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null)
const next = { token: {}, agent: execution.agent, signal: new AbortController().signal }
values = env.contributor.resolve(next); env.dispose()
assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null)
assert.equal(env.contributor.resolve({ token: {}, agent: { session: { header: { id: 'missing' } } } }).DSH_UDS_TASK_CAPABILITY, '')
})
test('WS tickets authorize only GET with a real upgrade on the Gateway mux entry', () => {
const bridge = new SessionBridgeStore({}), cap = bridge.mintWsTicket({ empNo: 'u', bearer: 'b'.repeat(32) })
const headers = { upgrade: 'websocket', connection: 'Upgrade' }
const good = { method: 'GET', url: '/api/remote.mux?udsWsTicket=' + cap.ticket, headers }
// Wrong path / method must not consume. Missing Connection is allowed (0.3.9 Desktop).
for (const bad of [
{ ...good, url: '/foreign?udsWsTicket=' + cap.ticket },
{ ...good, method: 'POST' },
{ ...good, headers: { connection: 'Upgrade' } },
]) {
assert.equal(extractSessionBearer(bad, { sessionBridge: bridge }), null)
}
assert.equal(
extractSessionBearer(
{ ...good, headers: { upgrade: 'websocket' } },
{ sessionBridge: bridge },
)?.via,
'ws_ticket',
)
const cap2 = bridge.mintWsTicket({ empNo: 'u', bearer: 'b'.repeat(32) })
const good2 = { method: 'GET', url: '/api/remote.mux?udsWsTicket=' + cap2.ticket, headers }
assert.equal(extractSessionBearer(good2, { sessionBridge: bridge })?.via, 'ws_ticket')
assert.equal(extractSessionBearer(good2, { sessionBridge: bridge }), null)
})
test('Host/CSRF/upgrade matrix rejects hostile authority, Origin and browser cross-site signals', () => {
const req = { method: 'POST', headers: { host: 'localhost:3000', origin: 'http://localhost:3000' }, socket: { remoteAddress: '127.0.0.1' } }
assert.equal(checkRequestOrigin(req).ok, true)
for (const headers of [{ ...req.headers, host: 'evil.invalid:3000' }, { ...req.headers, origin: 'http://evil.invalid:3000' },
{ ...req.headers, origin: 'file://localhost:3000' }, { ...req.headers, origin: 'http://localhost:3000/path' },
{ ...req.headers, host: 'localhost:3000/evil' }, { ...req.headers, 'sec-fetch-site': 'cross-site' }]) {
assert.equal(checkRequestOrigin({ ...req, method: 'GET', headers }, { upgrade: true }).ok, false)
}
assert.equal(checkRequestOrigin({ ...req, headers: { host: '[::1]:3000', origin: 'null' }, socket: { remoteAddress: '::1' } }).ok, true)
assert.equal(checkRequestOrigin({ ...req, headers: { host: 'localhost:3000', origin: 'dsh-app://app' } }).ok, true)
assert.equal(checkRequestOrigin({ ...req, headers: { host: 'localhost:3000', origin: 'dsh-app://foreign' } }).ok, false)
})
test('legacy migration rolls back partial writes, resumes identical files and never overwrites conflicts', async t => {
const root = await temporary(t), src = join(root, 'src'), dst = join(root, 'dst')
await mkdir(src); await mkdir(dst)
await writeFile(join(src, 'roles.json'), '{"roles":{"boss":"super_admin"}}')
await writeFile(join(src, 'session-owners.json'), '{"owners":{"s":"boss"}}')
let calls = 0
const { copyFile } = await import('node:fs/promises')
await assert.rejects(migrateState(dst, src, { error() {} }, { copyFile: async (...args) => {
if (++calls === 2) throw Object.assign(new Error('disk error'), { code: 'EIO' })
return copyFile(...args)
} }), /disk error/)
assert.deepEqual(await readdir(dst), [])
await copyFile(join(src, 'roles.json'), join(dst, 'roles.json'))
assert.equal((await migrateState(dst, src, { info() {} })).migrated, true)
assert.equal((await migrateState(dst, src)).reason, 'already')
const conflict = join(root, 'conflict'); await mkdir(conflict)
await writeFile(join(conflict, 'roles.json'), '{"roles":{}}')
await assert.rejects(migrateState(conflict, src), { code: 'legacy_migrate_conflict' })
assert.deepEqual(await readdir(conflict), ['roles.json'])
})
test('failed atomic rename cleans temporary secret buffers from disk', async t => {
const dir = await temporary(t), dest = join(dir, 'directory'); await mkdir(dest)
await assert.rejects(atomicWriteFile(dest, 'secret'))
assert.deepEqual(await readdir(dir), ['directory'])
})
test('profile applies and verifies actual platform permissions on a disposable directory', async t => {
const dir = await temporary(t); await writeFile(join(dir, 'roles.json'), '{}')
const result = await protectProfileDirectory(dir)
if (process.platform === 'win32') {
assert.equal(result.protected, true); assert.deepEqual(result.principals, ['current-user', 'SYSTEM'])
} else {
assert.equal((await lstat(dir)).mode & 0o777, 0o700)
assert.equal((await lstat(join(dir, 'roles.json'))).mode & 0o777, 0o600)
}
})
test('actual Desktop fetch/WS wrapper preserves headers and only attaches credentials to the owned authority', async () => {
const src = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
const calls = [], sockets = [], bridge = { empNo: 'administrator', token: 'b'.repeat(32), kind: 'sealed_box' }
const window = { fetch: async (input, opts) => { calls.push({ input, opts }); return {} },
WebSocket: function(url) { sockets.push(url) }, localStorage: { getItem: () => JSON.stringify(bridge) },
__ModuleLoader__: { load({ factory }) { factory(() => ({ createElement() {} })) } } }
const location = { protocol: 'dsh-app:', hostname: 'app', port: '', href: 'dsh-app://app/' }
const ticketOpens = []
const context = { window, location, URL, Headers, console, XMLHttpRequest: function() {
this.open = (method, url) => { ticketOpens.push(String(url)) }
this.setRequestHeader = () => {}; this.send = () => {}
this.status = 200; this.responseText = '{"ticket":"once"}'
} }
runInNewContext(src, context)
// 0.3.9: without streamBaseUrl, only loopback is trusted (not arbitrary hosts).
await window.fetch('http://example.com/api/remote')
assert.equal(calls.at(-1).opts, undefined, 'boot not ready must not trust non-loopback')
await window.fetch('http://127.0.0.1:1111/api/remote')
assert.equal(calls.at(-1).opts.headers['X-UDS-Bridge-Token'], bridge.token, 'loopback Gateway trusted before transport ready')
context.__DSH_TRANSPORT__ = { streamBaseUrl: 'http://127.0.0.1:43210/' }
await window.fetch('dsh-app://foreign/api/remote'); assert.equal(calls.at(-1).opts, undefined)
await window.fetch('http://127.0.0.1:43211/api/remote'); assert.equal(calls.at(-1).opts, undefined)
await window.fetch({ url: 'http://127.0.0.1:43210/api/remote', headers: new Headers({ 'X-Original': 'keep' }) })
assert.equal(calls.at(-1).opts.headers['X-UDS-Bridge-Token'], bridge.token)
assert.equal(calls.at(-1).opts.headers['x-original'], 'keep')
assert.equal(calls.at(-1).opts.redirect, 'error')
new window.WebSocket('ws://127.0.0.1:43210/api/remote.mux')
assert.equal(new URL(sockets.at(-1)).searchParams.get('udsWsTicket'), 'once')
assert.equal(sockets.at(-1).includes(bridge.token), false)
assert.ok(
ticketOpens[0] === '/uds-auth/api/ws-ticket'
|| ticketOpens.some((u) => u.startsWith('http://127.0.0.1:43210/uds-auth/api/ws-ticket')),
'Desktop ticket mint must hit relative /uds-auth or Host streamBaseUrl',
)
new window.WebSocket('ws://127.0.0.1:43210/foreign')
assert.equal(new URL(sockets.at(-1)).search, '')
})

View file

@ -0,0 +1,130 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { RolesStore, ROLES } from '../lib/roles.js'
import {
classifyAuthKind,
identityFromSessionRecord,
createSessionAccess,
} from '../lib/dsh-acl.js'
import {
buildLocalAdminUserContext,
sealLocalAdminBox,
isLocalAdminBoxConfigured,
} from '../lib/local-admin.js'
import { MemoryStore } from '../lib/session/memory-store.js'
import { UserWorkspaceStore } from '../lib/workspace-provision.js'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
test('A08: sealed_box identity survives when emergency password is disabled', async () => {
process.env.UDS_AUTH_LOCAL_ADMIN_BOX = sealLocalAdminBox('test-passphrase-ok')
assert.equal(isLocalAdminBoxConfigured(), true)
const roles = new RolesStore({})
roles._fallbackPasswordHash = null
roles._prefs.set('administrator', { viewAllSessions: true })
assert.equal(roles.isFallbackEnabled(), false)
const store = new MemoryStore()
const { bearer } = await store.create(buildLocalAdminUserContext(), 600)
const session = store.getByBearerSync(bearer)
assert.equal(session.kind, 'sealed_box')
const id = identityFromSessionRecord(session, roles)
assert.ok(id)
assert.equal(id.kind, 'sealed_box')
assert.equal(id.permissions.canCreateWorkspace, true)
assert.equal(id.permissions.canViewAllSessions, true)
// Incomplete ALS identity must still classify via live session row fields.
assert.equal(classifyAuthKind(session, 'administrator'), 'sealed_box')
assert.equal(
classifyAuthKind({ empNo: 'administrator' }, 'administrator'),
'fallback',
)
})
test('sealed_box admin still sees shared workspaces when view-all is on', async () => {
process.env.UDS_AUTH_LOCAL_ADMIN_BOX = sealLocalAdminBox('test-passphrase-ok')
const roles = new RolesStore({})
roles._fallbackPasswordHash = null
roles._prefs.set('administrator', { viewAllSessions: true })
const store = new MemoryStore()
const { bearer } = await store.create(buildLocalAdminUserContext(), 600)
const identity = identityFromSessionRecord(store.getByBearerSync(bearer), roles)
const uw = new UserWorkspaceStore({})
uw.set('administrator', {
path: 'C:\\Users\\zhout\\.dsh\\user-workspaces\\administrator',
workspaceId: 'ws-admin',
})
const access = createSessionAccess({
sessionAcl: { getOwner: () => null },
userWorkspaces: uw,
getWorkspaceRoot: () => 'C:\\Users\\zhout\\.dsh\\user-workspaces',
rolesStore: roles,
getWorkspaceRegistry: () => ({ list: () => [] }),
})
assert.equal(access.canSeeAll(identity), true)
assert.equal(
access.isVisibleWorkspace(identity, { id: 'ws-harness', path: 'D:\\project\\harness' }),
true,
)
})
test('assertPrincipalLive prefers session.kind sealed_box over bare administrator identity', () => {
const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(src, /classifyAuthKind\(sessionRow \|\| identity/)
assert.match(src, /incomplete ALS identities/)
assert.doesNotMatch(
src,
/emp === 'administrator' && kind !== 'uds'/,
)
})
test('Desktop WS ticket mint prefetches via fetch then sync cache', () => {
const src = readFileSync(join(root, 'lib/client.js'), 'utf8')
assert.match(src, /prefetchWsTickets/)
assert.match(src, /reconnectWithWsTicket/)
assert.match(src, /_wsTicketCache/)
assert.match(src, /udsBridgeToken/)
assert.match(src, /data-uds-session-only/)
assert.match(src, /getDesktopTransportBase\(\)/)
assert.match(src, /new URL\('\/uds-auth\/api\/ws-ticket', transport\)/)
assert.match(src, /WS ticket mint failed/)
assert.match(src, /__udsAuthWsAuthedOnce/)
// Reconnect storm must be coalesced — second ticketed reconnect kills the stream.
assert.match(src, /_softReconnectQuietUntil/)
assert.match(src, /WorkspaceStateStream/)
assert.match(src, /!window\.__udsAuthWsAuthedOnce/)
})
test('workspace follow yields empty baseline for anonymous (Desktop must not die)', () => {
const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(src, /emptyWorkspaceBaseline/)
assert.match(src, /holdUntilAbort/)
assert.match(src, /ended before its opening snapshot/)
assert.match(src, /未分组/)
// Must not early-return with zero frames for anonymous browsers.
assert.doesNotMatch(src, /if \(!empOf\(identity\)\) return\n\s*\/\/ R04/)
})
test('classifyAuthKind keeps local-admin-unlock as sealed_box', () => {
assert.equal(
classifyAuthKind({ authMode: 'local-admin-unlock', empNo: 'administrator' }),
'sealed_box',
)
assert.equal(
classifyAuthKind({ kind: 'sealed_box', empNo: 'administrator' }),
'sealed_box',
)
assert.equal(
classifyAuthKind({ kind: 'fallback', empNo: 'administrator' }),
'fallback',
)
assert.equal(ROLES.FALLBACK_ADMIN, 'fallback_admin')
})

View file

@ -0,0 +1,129 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { MemoryStore } from '../lib/session/memory-store.js'
import { createSessionStore } from '../lib/session/factory.js'
const quiet = { info() {}, warn() {} }
async function freshDir() {
return mkdtemp(join(tmpdir(), 'uds-sessions-'))
}
async function openStore(dir) {
const store = new MemoryStore({ file: join(dir, 'sessions.json'), dataDir: dir, logger: quiet })
await store.init()
return store
}
test('sessions survive a Host restart (emergency login stays logged in)', async () => {
const dir = await freshDir()
try {
const a = await openStore(dir)
const { bearer, sessionId } = await a.create({
empNo: 'administrator',
authMode: 'local-admin-unlock',
displayName: 'Local Admin',
}, 1800)
await a.flush()
await a.clear()
const b = await openStore(dir)
const row = b.getByBearerSync(bearer)
assert.equal(row?.sessionId, sessionId)
assert.equal(row?.empNo, 'administrator')
assert.equal(row?.kind, 'sealed_box')
assert.equal(row?.userData.displayName, 'Local Admin')
assert.equal((await b.getProfile('administrator'))?.displayName, 'Local Admin')
assert.equal(b.getByBearerSync('f'.repeat(64)), null)
await b.clear()
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('disk holds only the bearer hash; userData (UAC token) is encrypted', async () => {
const dir = await freshDir()
try {
const a = await openStore(dir)
const { bearer } = await a.create({ empNo: '1001', token: 'UAC-SECRET-TOKEN', email: 'x@example.test' }, 1800)
await a.flush()
const disk = await readFile(join(dir, 'sessions.json'), 'utf-8')
assert.equal(disk.includes(bearer), false, 'raw bearer never written')
assert.equal(disk.includes('UAC-SECRET-TOKEN'), false, 'token not in clear')
assert.equal(disk.includes('x@example.test'), false, 'profile fields not in clear')
await a.clear()
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('expired and tampered rows are dropped on load', async () => {
const dir = await freshDir()
try {
const a = await openStore(dir)
const live = await a.create({ empNo: 'u1' }, 1800)
const dead = await a.create({ empNo: 'u2' }, 1800)
const forged = await a.create({ empNo: 'u3' }, 1800)
await a.flush()
await a.clear()
const file = join(dir, 'sessions.json')
const data = JSON.parse(await readFile(file, 'utf-8'))
for (const row of data.sessions) {
if (row.sessionId === dead.sessionId) row.expiresAt = Date.now() - 1
if (row.sessionId === forged.sessionId) row.userData = row.userData.slice(0, -4) + 'AAAA'
}
await writeFile(file, JSON.stringify(data))
const b = await openStore(dir)
assert.ok(b.getByBearerSync(live.bearer))
assert.equal(b.getByBearerSync(dead.bearer), null)
assert.equal(b.getByBearerSync(forged.bearer), null)
await b.clear()
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('revocation (logout) is persisted', async () => {
const dir = await freshDir()
try {
const a = await openStore(dir)
const { bearer } = await a.create({ empNo: 'administrator', authMode: 'fallback-password' }, 1800)
await a.revokeBearer(bearer)
await a.flush()
await a.clear()
const b = await openStore(dir)
assert.equal(b.getByBearerSync(bearer), null)
await b.clear()
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('factory wires persistence; no file configured keeps pure in-memory behavior', async () => {
const dir = await freshDir()
try {
const persisted = await createSessionStore({ storeType: 'memory' }, {
file: join(dir, 'sessions.json'), dataDir: dir, logger: quiet,
})
const { bearer } = await persisted.create({ empNo: 'u1' }, 1800)
await persisted.flush()
await persisted.clear()
const again = await createSessionStore({ storeType: 'memory' }, {
file: join(dir, 'sessions.json'), dataDir: dir, logger: quiet,
})
assert.ok(again.getByBearerSync(bearer))
await again.clear()
const plain = await createSessionStore({ storeType: 'memory' })
await plain.create({ empNo: 'u1' }, 1800)
await plain.flush()
await plain.clear()
} finally {
await rm(dir, { recursive: true, force: true })
}
})

View file

@ -0,0 +1,69 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { MemoryStore } from '../lib/session/memory-store.js'
import { SESSION_ABSOLUTE_MAX_SECONDS } from '../lib/session/store.js'
import { patchWebServerWithIdentity } from '../lib/dsh-acl.js'
test('session absolute lifetime is 7 days, independent of the 30-min idle TTL', async () => {
assert.equal(SESSION_ABSOLUTE_MAX_SECONDS, 7 * 24 * 60 * 60)
const store = new MemoryStore()
const before = Date.now()
const { sessionId, record } = await store.create({ empNo: 'administrator', authMode: 'local-admin-unlock' }, 1800)
assert.ok(record.absoluteExpiresAt >= before + SESSION_ABSOLUTE_MAX_SECONDS * 1000)
assert.ok(record.expiresAt <= Date.now() + 1800 * 1000)
// Simulate 25 minutes passing, then activity: idle expiry must slide past the old 30-min mark.
const row = store._sessions.get(sessionId)
row.createdAt -= 25 * 60_000
row.expiresAt -= 25 * 60_000
row.absoluteExpiresAt -= 25 * 60_000
const touched = await store.touch(sessionId, 1800)
// Old min(ttl, 7d) cap left only ~5 minutes here; now the full idle window is restored.
assert.ok(touched.expiresAt > Date.now() + 29 * 60_000, 'idle TTL slides past the old 30-min hard stop')
})
function fakeServer() {
return {
exact: new Map(),
prefixes: new Map(),
upgrades: new Map(),
register(route) { this.prefixes.set(route.path, route) },
}
}
function fakeRes() {
return {
status: null,
body: null,
writeHead(code) { this.status = code },
end(body) { this.body = body },
}
}
test('/api/* requests wait for the gateway ACL during startup instead of 403', async () => {
const server = fakeServer()
let ready = false
let handled = 0
server.register({ kind: 'prefix', path: '/api', handler: async () => { handled++ } })
patchWebServerWithIdentity(server, async () => null, () => null, () => (
ready ? { ok: true } : { ok: false, reason: 'gateway_acl_not_ready' }
))
setTimeout(() => { ready = true }, 150)
const res = fakeRes()
const req = { url: '/api/settings/describe', method: 'POST', headers: { host: '127.0.0.1:1' } }
await server.prefixes.get('/api').handler(req, res)
assert.equal(handled, 1, 'request proceeded once the ACL was installed')
assert.equal(res.status, null)
})
test('other origin/host rejections still fail immediately', async () => {
const server = fakeServer()
let handled = 0
server.register({ kind: 'prefix', path: '/api', handler: async () => { handled++ } })
patchWebServerWithIdentity(server, async () => null, () => null, () => ({ ok: false, reason: 'host_not_trusted' }))
const res = fakeRes()
const started = Date.now()
await server.prefixes.get('/api').handler({ url: '/api/x', method: 'POST', headers: {} }, res)
assert.equal(res.status, 403)
assert.equal(handled, 0)
assert.ok(Date.now() - started < 1000)
})

View file

@ -0,0 +1,86 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { SessionBridgeStore } from '../lib/session-bridge.js'
import { extractSessionBearer } from '../lib/session/request-auth.js'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
test('T01: WS ticket is single-use and extractSessionBearer consumes it', () => {
const bridge = new SessionBridgeStore({})
const minted = bridge.mintWsTicket({ empNo: 'A001', bearer: 'b'.repeat(32), ttlMs: 60_000 })
assert.ok(minted.ticket)
const req = {
method: 'GET', url: '/api/remote.mux?udsWsTicket=' + minted.ticket,
headers: { upgrade: 'websocket', connection: 'keep-alive, Upgrade' },
}
const first = extractSessionBearer(req, { sessionBridge: bridge })
assert.equal(first?.via, 'ws_ticket')
assert.equal(first?.bearer, 'b'.repeat(32))
const replay = extractSessionBearer(req, { sessionBridge: bridge })
assert.equal(replay, null)
assert.equal(bridge.consumeWsTicket(minted.ticket), null)
})
test('T01/0.3.9: Upgrade: websocket alone is enough (Connection may be absent on Desktop)', () => {
const bridge = new SessionBridgeStore({})
const minted = bridge.mintWsTicket({ empNo: 'A001', bearer: 'b'.repeat(32), ttlMs: 60_000 })
const req = {
method: 'GET', url: '/api/remote.mux?udsWsTicket=' + minted.ticket,
headers: { upgrade: 'websocket' },
}
const first = extractSessionBearer(req, { sessionBridge: bridge })
assert.equal(first?.via, 'ws_ticket')
assert.equal(first?.bearer, 'b'.repeat(32))
})
test('D07: WS ticket is ignored on normal HTTP (no upgrade)', () => {
const bridge = new SessionBridgeStore({})
const minted = bridge.mintWsTicket({ empNo: 'A001', bearer: 'b'.repeat(32), ttlMs: 60_000 })
const req = { url: '/uds-auth/api/config?udsWsTicket=' + minted.ticket, headers: {} }
assert.equal(extractSessionBearer(req, { sessionBridge: bridge }), null)
assert.ok(bridge.consumeWsTicket(minted.ticket))
})
test('T01: client prefers udsWsTicket; loopback bridge query is Desktop fallback', () => {
const src = readFileSync(join(root, 'lib/client.js'), 'utf8')
assert.match(src, /udsWsTicket/)
assert.match(src, /mintWsTicketSync/)
assert.match(src, /udsBridgeToken/)
assert.match(src, /0\.3\.9 Desktop path/)
const auth = readFileSync(join(root, 'lib/session/request-auth.js'), 'utf8')
assert.match(auth, /bridge_query/)
assert.match(auth, /requestIsLoopback/)
})
test('Desktop loopback WS upgrade accepts udsBridge query when ticket missing', () => {
const bridge = new SessionBridgeStore({})
const token = 'c'.repeat(32)
bridge.mint({ empNo: 'administrator', kind: 'sealed_box', token })
const req = {
method: 'GET',
url: '/api/remote.mux?udsBridgeEmpNo=administrator&udsBridgeToken=' + token,
headers: { upgrade: 'websocket' },
socket: { remoteAddress: '127.0.0.1' },
}
const got = extractSessionBearer(req, { sessionBridge: bridge })
assert.equal(got?.via, 'bridge_query')
assert.equal(got?.bearer, token)
// Off-loopback must not accept bridge query (even with upgrade).
const remote = {
...req,
socket: { remoteAddress: '8.8.8.8' },
}
assert.equal(extractSessionBearer(remote, { sessionBridge: bridge }), null)
})
test('T03: user-info rejects query token; QR client adopts a local session without SSO cookies', () => {
const index = readFileSync(join(root, 'lib/index.js'), 'utf8')
const client = readFileSync(join(root, 'lib/client.js'), 'utf8')
assert.match(index, /token_in_query_forbidden/)
assert.ok(client.includes('adoptAuthSession(auth)'))
assert.ok(!client.includes("setCookie('PORTALSSOCookie', token"))
assert.doesNotMatch(client, /user-info\?empNo=/)
})