dsh-feishu-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +125 -0
- package/README.md +90 -0
- package/cordis.patch.yml +10 -0
- package/disable.patch.yml +7 -0
- package/docs/architecture.md +154 -0
- package/docs/release.md +84 -0
- package/enable.patch.yml +9 -0
- package/lib/config.js +80 -0
- package/lib/feishu.js +129 -0
- package/lib/gate.js +493 -0
- package/lib/index.js +196 -0
- package/lib/pages.js +137 -0
- package/lib/session.js +206 -0
- package/lib/urls.js +94 -0
- package/package.json +62 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# AGENTS.md — dsh-feishu-auth
|
|
2
|
+
|
|
3
|
+
给在本仓库里干活的 agent 和维护者。人类读者先看 [README.md](README.md);要改内部机制先看 [docs/architecture.md](docs/architecture.md)。
|
|
4
|
+
|
|
5
|
+
## 这是什么
|
|
6
|
+
|
|
7
|
+
DSH(DeepSeek Harness)Web 界面的飞书 OAuth 登录网关。一个 Cordis 插件:替换运行中 `webServer` 服务的 `match(pathname)` 分发点,除插件自身端点外,对**所有**请求要求一个 HMAC 签名的会话 Cookie。挂在哪个地址就保护哪个地址。
|
|
8
|
+
|
|
9
|
+
两条不变量,任何改动都不得破坏:
|
|
10
|
+
|
|
11
|
+
- **故障关闭(fail closed)**:拿不到凭证、配置矛盾、挂不上分发点时,拒绝一切访问(503 / 拒绝启动),绝不「静默放过」。
|
|
12
|
+
- **故障响亮(fail loud)**:挂载、拒绝、登录、自检失败都要出现在 `dsh web` 终端;自检失败不许当成成功。
|
|
13
|
+
|
|
14
|
+
## 仓库地图
|
|
15
|
+
|
|
16
|
+
| 路径 | 职责 |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `lib/index.js` | 插件入口:配置分析、会话密钥文件、挂载、启动自检、harness 入口解析(`ctx.inject(['connection'])`) |
|
|
19
|
+
| `lib/gate.js` | 网关引擎:分发拦截与层标记、OAuth 端点、拒绝策略、harness 两段式交接 |
|
|
20
|
+
| `lib/feishu.js` | 飞书接口:授权 URL、换 user_access_token、读用户信息 |
|
|
21
|
+
| `lib/session.js` | HMAC 签名 Cookie(会话 / state)、密钥文件读写 |
|
|
22
|
+
| `lib/config.js` | 配置解析与 Cookie 名、TTL 常量 |
|
|
23
|
+
| `lib/urls.js` | Host 规范化、回调地址推导、`next` 开放重定向防护、导航请求判定 |
|
|
24
|
+
| `lib/pages.js` | 提示页(拒绝 / 错误 / 未就绪 / 已登出),全部内联样式 |
|
|
25
|
+
| `enable.patch.yml` / `disable.patch.yml` | 启用 / 停用 overlay |
|
|
26
|
+
| `test/` | `node --test`,零依赖,替身自建 |
|
|
27
|
+
|
|
28
|
+
零运行时依赖:只用 node 内置模块,所以在没 `pnpm install` 过的 profile 里也能直接引用。
|
|
29
|
+
|
|
30
|
+
## 安装与激活
|
|
31
|
+
|
|
32
|
+
仓库放在 `~/.dsh/plugins/dsh-feishu-auth/`。在 `~/.dsh/profiles/web/cordis.patch.yml` 里插入(`name` 相对 profile 目录解析):
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
- insert:
|
|
36
|
+
- id: feishu-auth
|
|
37
|
+
name: ../../plugins/dsh-feishu-auth/lib/index.js
|
|
38
|
+
config:
|
|
39
|
+
appId: !!js process.env.FEISHU_APP_ID
|
|
40
|
+
appSecret: !!js process.env.FEISHU_APP_SECRET
|
|
41
|
+
allowedUsers: []
|
|
42
|
+
sessionMaxAgeDays: 14
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
凭证放 `~/.dsh/.env`(`FEISHU_APP_ID` / `FEISHU_APP_SECRET`,权限 600);`DSH_` 开头的变量不能写进 `.env`,启动器会直接报错。
|
|
46
|
+
|
|
47
|
+
- **启用/停用 overlay**:`--patch <本目录>/enable.patch.yml` 或 `disable.patch.yml`,写在 `--profile web` 一侧(`dsh web` 别名形式不接受 `--patch`)。
|
|
48
|
+
- **不要同时**用 profile 行和 `dsh plugin --profile web add` 安装同一个 id。
|
|
49
|
+
|
|
50
|
+
## 运行与验证
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cd ~/.dsh/plugins/dsh-feishu-auth && node --test # 单元用例
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
改完代码要重启 `dsh web` 才生效(插件模块不热重载)。部署验收清单:
|
|
57
|
+
|
|
58
|
+
| 检查 | 期望 |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| 启动日志 | `飞书登录已挂载 …` + `网关自检通过(未登录 → HTTP 401,已登录 → HTTP 404)`,无 `[error]` |
|
|
61
|
+
| 未登录访问(浏览器式请求) | `302 → /feishu-auth/login?next=…`;`/api` 无 cookie 时是 `401` JSON |
|
|
62
|
+
| 完整登录交接 | `GET /` → `303 /?token=…` → harness 下发 `dsh-auth-*` cookie → 再访问 `/` 得 `200` 且返回真实应用页 |
|
|
63
|
+
| 停用验证 | 探针应从 `302`(网关在岗)变成 `401`(harness 自己的门)——这是确认层真的被摘掉的唯一可靠信号 |
|
|
64
|
+
| `/feishu-auth/status` | `{"gate":"enforce","authenticated":…}` |
|
|
65
|
+
|
|
66
|
+
## 配置项的生效语义
|
|
67
|
+
|
|
68
|
+
| 改动 | 生效方式 |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `allowedUsers`、`sessionMaxAgeDays` | 热生效:保存后日志立刻出现新的「飞书登录已挂载」行 |
|
|
71
|
+
| `appId` / `appSecret`(`~/.dsh/.env`) | 只在启动时读取,**必须重启** |
|
|
72
|
+
| 加 / 删 / 停用整行(`disabled: true`) | 结构变更不热生效,**必须重启**(实测挂载后 45 秒仍未摘除) |
|
|
73
|
+
|
|
74
|
+
名单只在登录回调那一刻判定,所以改名单不会踢掉已登录会话。
|
|
75
|
+
|
|
76
|
+
## 运维速查
|
|
77
|
+
|
|
78
|
+
日志同时进 `dsh web` 终端和内存缓冲,格式:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
feishu-auth[info] 飞书登录已挂载 prefix=/feishu-auth 允许范围=… 会话有效期=14 天
|
|
82
|
+
feishu-auth[info] 网关自检通过(未登录 → HTTP 401,已登录 → HTTP 404)
|
|
83
|
+
feishu-auth[warn] 拒绝未认证请求 GET / host=… from <ip> via <socket> (no-cookie)
|
|
84
|
+
feishu-auth[info] 登录成功 name=… open_id=… tenant=… from …
|
|
85
|
+
feishu-auth[error] 拿不到 harness 的入口地址(connection 服务不可达)…
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
| 现象 | 处理 |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| 所有请求 503 | 凭证不在进程环境里:确认 `~/.dsh/.env` 后重启 |
|
|
91
|
+
| 登录成功后停在 harness 的 401 页 | 网关没拿到 harness 入口地址(日志有 error 行):检查 `ctx.inject(['connection'])` 是否仍被 dsh 支持 |
|
|
92
|
+
| 飞书报 `redirect_uri unmatch` | 回调地址没登记/不一致;临时隧道换域名后必须补登记 |
|
|
93
|
+
| 飞书报 `20010` | 账号不在应用可用范围,或应用版本未发布 |
|
|
94
|
+
| 想立刻放行 | `disable.patch.yml` 启动一次,或给该行加 `disabled: true` 后重启 |
|
|
95
|
+
|
|
96
|
+
会话自述与登出:`GET /feishu-auth/status`、`GET /feishu-auth/logout`。
|
|
97
|
+
|
|
98
|
+
## 开发约束
|
|
99
|
+
|
|
100
|
+
- **绝不用身份比较判断服务成员。** `ctx.webServer` 是 Cordis traceable 服务,成员读取每次都返回新的包装 Proxy:`server.match === 你的函数` 恒为 false。识别自己的层只能靠符号标记,解包靠 `Symbol.for('cordis.original')`。详见架构文档。
|
|
101
|
+
- **挂载/卸载必须幂等。** 热重载时新层可能先于旧层的 disposer 挂上:只允许最新层卸载,发现遗留层要复用它的原始实现而不是往上叠。
|
|
102
|
+
- **新增行为要补用例**,并确认「旧实现下该用例会红」——否则它没锁住任何东西。
|
|
103
|
+
- **不改客户端资产、不注入 DOM**:拦截只发生在 HTTP 层。
|
|
104
|
+
- **提示页只用内联样式**,不能依赖被自己保护的静态资源。
|
|
105
|
+
- 配置解析永不抛错:除凭证外的问题降级为默认值并记录;凭证缺失走故障关闭。
|
|
106
|
+
|
|
107
|
+
## 改动流程
|
|
108
|
+
|
|
109
|
+
1. 改 `lib/`,跑 `node --test`。
|
|
110
|
+
2. 在本机 `dsh web` 实测:启动自检 + 未登录 302 + 完整交接 200;涉及卸载/重载的改动要额外验「停用 → 401、再启用 → 302」。
|
|
111
|
+
3. 提交并推送 `git push origin main`(仓库 `jianghuifr/dsh-feishu-auth`,带 `dsh-plugin` topic)。
|
|
112
|
+
4. 影响用户可见行为或配置语义的改动,同步更新 [README.md](README.md) 和 [AGENTS.md](AGENTS.md)(本文)以及架构文档中的对应事实。
|
|
113
|
+
|
|
114
|
+
## 与 dsh 版本的耦合点
|
|
115
|
+
|
|
116
|
+
升级 dsh 后优先复核这四处,任何一处变了都要同步适配:
|
|
117
|
+
|
|
118
|
+
| 依赖 | 用途 | 失效表现 |
|
|
119
|
+
| --- | --- | --- |
|
|
120
|
+
| `webServer.match(pathname)` 分发点 | 唯一的拦截缝隙 | 插件**拒绝启动**并报错(不会静默放过) |
|
|
121
|
+
| `ctx.inject(['connection'])` | 取 harness 入口地址(两段式交接) | 日志 error;登录后停在 harness 的 401 页 |
|
|
122
|
+
| `dsh-auth-<authority>` Cookie 前缀 | 登出时清掉 harness 自己的 Cookie | 登出后可能被 harness 直接放回 |
|
|
123
|
+
| `/?token=<launch token>` 兑换约定 | 交接第二段 | 同上 |
|
|
124
|
+
|
|
125
|
+
另见架构文档「已知边界与风险」。
|
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# dsh-feishu-auth
|
|
2
|
+
|
|
3
|
+
给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)的 Web 界面加一道**飞书登录**:没通过飞书登录,首页、静态资源、`/api` 全拿不到。
|
|
4
|
+
|
|
5
|
+
## 为什么需要它
|
|
6
|
+
|
|
7
|
+
DSH 本身没有服务器级认证。把 Web 界面通过局域网、端口转发、反代或隧道暴露出去的那一刻,**任何能连上这个地址的人都能操作你的 agent**——读写工作区文件、执行命令、消耗你的 API key。
|
|
8
|
+
|
|
9
|
+
这个插件把「能不能进来」变成「在飞书里能不能登录」:
|
|
10
|
+
|
|
11
|
+
- 保护**你实际访问的那个地址**(LAN IP / 隧道域名 / localhost,任意端口),不用为每个地址单独配置;
|
|
12
|
+
- 登录源就是飞书应用的**可用范围**——谁能用这个应用谁就能进,撤权在飞书后台一次搞定;
|
|
13
|
+
- 拿不到凭证时**拒绝一切访问**(故障关闭),不会悄悄退化成「没保护」。
|
|
14
|
+
|
|
15
|
+
```mermaid
|
|
16
|
+
flowchart LR
|
|
17
|
+
A["浏览器<br/>任意地址"] -->|"未登录"| B["302 飞书授权页<br/>/api 返回 401 JSON"]
|
|
18
|
+
A -->|"已登录"| F["DSH Web UI"]
|
|
19
|
+
B --> C["飞书确认身份"]
|
|
20
|
+
C --> D["签发会话 Cookie"]
|
|
21
|
+
D --> E["交给 DSH 换它自己的 Cookie"]
|
|
22
|
+
E --> F
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 三步上手
|
|
26
|
+
|
|
27
|
+
**1. 飞书后台登记回调地址** — 开发者后台 → 你的应用 → 安全设置 → 重定向 URL,添加一条:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
<你访问用的地址>/feishu-auth/callback
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
例如 `https://xxx.trycloudflare.com/feishu-auth/callback`、`http://192.168.1.23:3080/feishu-auth/callback`。不支持通配符,最多 300 条;访问地址变了要补一条(临时隧道重启换域名就是这种情况)。
|
|
34
|
+
|
|
35
|
+
**2. 放凭证** — 写进 `~/.dsh/.env`(`chmod 600`):
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx
|
|
39
|
+
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**3. 激活并启动** — 从 npm 安装(推荐):
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
dsh plugin --profile web add dsh-feishu-auth
|
|
46
|
+
dsh web --no-open --host 0.0.0.0 --port 3080 --trusted-host <你的隧道域名>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
装完后 profile 的 `dsh.profile.bundles` 会多一行,启动日志出现 `飞书登录已挂载` 和 `网关自检通过` 就绪。
|
|
50
|
+
|
|
51
|
+
> npm 上还没有这个版本、或想跑本地源码?手工放一份仓库到 `~/.dsh/plugins/dsh-feishu-auth/`,再照 [AGENTS.md 的安装与激活](AGENTS.md#安装与激活) 在 profile 里插一行即可。tarball 离线安装也用 `dsh plugin ... add ./dsh-feishu-auth-0.1.0.tgz`。
|
|
52
|
+
|
|
53
|
+
权限(scope)不用申请,`open_id`、`union_id`、`tenant_key`、姓名直接可读。
|
|
54
|
+
|
|
55
|
+
## 配置
|
|
56
|
+
|
|
57
|
+
写在插件行的 `config:` 下,共 4 项:
|
|
58
|
+
|
|
59
|
+
| 配置 | 默认 | 说明 |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `appId` / `appSecret` | 环境变量 | 缺省读 `FEISHU_APP_ID` / `FEISHU_APP_SECRET`;两者都拿不到时**拒绝一切访问** |
|
|
62
|
+
| `allowedUsers` | `[]` | 留空 = 放行「能使用本应用的任意飞书成员」;填了就只放行列出的 `open_id` / `union_id` / `user_id` |
|
|
63
|
+
| `sessionMaxAgeDays` | `14` | 会话有效期(天) |
|
|
64
|
+
|
|
65
|
+
`allowedUsers` 该填什么:被拒的人会在页面上看到**他自己**的 `open_id`,抄进去保存即可——配置项热生效,不用重启。
|
|
66
|
+
|
|
67
|
+
## 常见问题
|
|
68
|
+
|
|
69
|
+
| 现象 | 处理 |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| 所有请求 503「未就绪」 | 进程环境里没有凭证。确认 `~/.dsh/.env`,然后重启 |
|
|
72
|
+
| 飞书报 `redirect_uri unmatch` | 回调地址没登记,或与当前访问地址不一致(隧道换域名了?) |
|
|
73
|
+
| 飞书报 `20010` | 该账号不在应用可用范围内,或应用版本未发布 |
|
|
74
|
+
| 换了地址要重新登录 | 正常:Cookie 按来源隔离,https 隧道与 `http://127.0.0.1` 各算一处 |
|
|
75
|
+
| 被自己关在门外 / 想彻底回退 | 用 `disable.patch.yml` 启动一次(见下),或给那行加 `disabled: true` 后重启 |
|
|
76
|
+
|
|
77
|
+
临时停用(凭证写错、飞书挂了、改错配置把自己锁在外面):
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
dsh --profile web --patch ~/.dsh/plugins/dsh-feishu-auth/disable.patch.yml \
|
|
81
|
+
--no-open --host 0.0.0.0 --port 3080
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 文档
|
|
85
|
+
|
|
86
|
+
- [AGENTS.md](AGENTS.md) — 面向 agent 与维护者:安装激活、运维速查、开发约束、改动流程
|
|
87
|
+
- [docs/architecture.md](docs/architecture.md) — 内部设计:拦截层、两段式交接、Cookie 与会话、失败模式
|
|
88
|
+
- [docs/release.md](docs/release.md) — 分发形态、CI 与发版流程
|
|
89
|
+
|
|
90
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# 组合包层:`dsh plugin --profile <name> add dsh-feishu-auth` 安装本包时应用。
|
|
2
|
+
# 插件行按包名引用(不是相对路径),这样 Node 的模块解析才能找到已安装的代码。
|
|
3
|
+
- insert:
|
|
4
|
+
- id: feishu-auth
|
|
5
|
+
name: dsh-feishu-auth
|
|
6
|
+
config:
|
|
7
|
+
# appId / appSecret 缺省从进程环境读 FEISHU_APP_ID / FEISHU_APP_SECRET;
|
|
8
|
+
# 也可以在这里直接写死(profile 自己的 cordis.patch.yml 可以覆盖这一行)。
|
|
9
|
+
allowedUsers: []
|
|
10
|
+
sessionMaxAgeDays: 14
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Boot the page WITHOUT the Feishu login gate (rescue path, e.g. credentials
|
|
2
|
+
# are wrong and the gate is refusing everyone):
|
|
3
|
+
#
|
|
4
|
+
# dsh --profile web --patch ~/.dsh/plugins/dsh-feishu-auth/disable.patch.yml \
|
|
5
|
+
# --no-open --host 0.0.0.0 --port 3080
|
|
6
|
+
- id: feishu-auth
|
|
7
|
+
disabled: true
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# 架构
|
|
2
|
+
|
|
3
|
+
[dsh-feishu-auth](../README.md) 的内部设计。面向要改 `lib/` 的人;运维与开发流程见 [AGENTS.md](../AGENTS.md)。
|
|
4
|
+
|
|
5
|
+
## 全景
|
|
6
|
+
|
|
7
|
+
一个 Cordis 插件,在插件挂载时替换运行中 `webServer` 服务的 `match(pathname)` 分发点,之后每个 HTTP 请求都先经过网关判定:
|
|
8
|
+
|
|
9
|
+
```mermaid
|
|
10
|
+
flowchart TB
|
|
11
|
+
R["HTTP 请求"] --> OW{"插件自身端点?<br/>/feishu-auth/*"}
|
|
12
|
+
OW -->|"是"| H["login / callback / logout / status"]
|
|
13
|
+
OW -->|"否"| CFG{"配置致命问题?"}
|
|
14
|
+
CFG -->|"是"| FC["503 未就绪页<br/>(故障关闭)"]
|
|
15
|
+
CFG -->|"否"| AU{"有效会话 Cookie?"}
|
|
16
|
+
AU -->|"否"| DN["导航: 302 → 飞书授权页<br/>其它: 401 JSON"]
|
|
17
|
+
AU -->|"是"| HO{"需要 harness 交接?"}
|
|
18
|
+
HO -->|"是"| EX["303 → /?token=…<br/>+ 20s 交接标记"]
|
|
19
|
+
HO -->|"否"| PS["交给 harness 原分发逻辑"]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
「需要 harness 交接」的判定:`GET/HEAD` 导航请求、路径是 `/`、URL 上没有 `token` 参数、请求里没有 `dsh-auth-` 开头的 Cookie,且没有交接标记(见下)。
|
|
23
|
+
|
|
24
|
+
## 拦截层
|
|
25
|
+
|
|
26
|
+
### 为什么是 monkey-patch
|
|
27
|
+
|
|
28
|
+
dsh 当前没有请求级中间件缝隙——`/api` 前缀被 `client-connection` 占用、RPC 拦截器拿不到 headers/cookie/socket、WebSocket 升级不走 HTTP 路由表。要「拦下所有请求」只能替换 `webServer` 的分发点。这是本插件最脆的一处依赖,找不到该分发点时**拒绝启动**而不是静默放过。
|
|
29
|
+
|
|
30
|
+
### 层的身份与幂等卸载
|
|
31
|
+
|
|
32
|
+
`ctx.webServer` 是 Cordis 的 traceable 服务,**函数值成员每次读取都返回一个新的包装 Proxy**(`createShadowMethod`),所以:
|
|
33
|
+
|
|
34
|
+
- `server.match === 自己装的函数` 恒为 false,身份比较式的卸载守卫会静默失效,层永久粘住;
|
|
35
|
+
- 但写入是生效的(proxy 的 set 落到真实实例),拦截本身工作正常。
|
|
36
|
+
|
|
37
|
+
因此层的识别靠**挂在函数对象上的符号标记**(键 `Symbol.for('dsh-feishu-auth.dispatcher')`,值里存真正的原始实现),当前层按**原始服务实例**(`server[Symbol.for('cordis.original')]`)记录在模块级 WeakMap 里。三条规则:
|
|
38
|
+
|
|
39
|
+
1. 安装时若分发点已带本插件标记(历史残留层),复用它的 `original`,不再往上叠;
|
|
40
|
+
2. 卸载时只在自己仍是最新层、且仍位于最外层时才还原;
|
|
41
|
+
3. 中途出现的第三方包装不会被误拆。
|
|
42
|
+
|
|
43
|
+
热重载的实测顺序是**新层先挂、旧层的 disposer 后跑**,规则 2 就是为了这种情况:
|
|
44
|
+
|
|
45
|
+
```mermaid
|
|
46
|
+
sequenceDiagram
|
|
47
|
+
participant L as Loader
|
|
48
|
+
participant G1 as 旧层 L1
|
|
49
|
+
participant G2 as 新层 L2
|
|
50
|
+
participant S as webServer.match
|
|
51
|
+
Note over S: 原始 match
|
|
52
|
+
L->>G1: apply() → install
|
|
53
|
+
G1->>S: match = L1
|
|
54
|
+
L->>G2: 重载 apply() → install
|
|
55
|
+
G2->>S: match = L2(继承 L1 的 original)
|
|
56
|
+
L->>G1: dispose(已不是最新层 → 不动)
|
|
57
|
+
L->>G2: dispose(是最新层 → 还原 original)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 两段式交接
|
|
61
|
+
|
|
62
|
+
飞书登录只签发本插件自己的会话 Cookie。harness 另有一层签名 Cookie(`dsh-auth-<authority>`),只在一个**根请求带上本进程启动令牌**时签发。所以「能打开页面」需要两段都完成:
|
|
63
|
+
|
|
64
|
+
1. 网关把浏览器跳到 `connection.authenticatedUrl()` 给出的 `/?token=<launch token>`;
|
|
65
|
+
2. harness 校验令牌、下发 `dsh-auth-*`,再跳回干净的 `/`。
|
|
66
|
+
|
|
67
|
+
`connection` 服务**只能**经 `ctx.inject(['connection'], cb)` 取(`ctx.get` 返回 undefined,属性访问直接抛错),所以在插件挂载时捕获成 `entryUrlProvider`,每次请求时调用。取不到时打一行 error,并在交接判定里退化为直接放行给 harness(让它自己的 401 页成为终点,避免无休止往返)。
|
|
68
|
+
|
|
69
|
+
**交接标记**(`dsh-feishu-handoff`,20 秒)是死循环的兜底:浏览器拒绝存 harness Cookie 时,`/` 与 `/?token=…` 之间只会来回一次。
|
|
70
|
+
|
|
71
|
+
```mermaid
|
|
72
|
+
sequenceDiagram
|
|
73
|
+
participant B as 浏览器
|
|
74
|
+
participant G as 网关
|
|
75
|
+
participant H as harness
|
|
76
|
+
B->>G: GET /
|
|
77
|
+
G->>B: 302 /feishu-auth/login
|
|
78
|
+
B->>G: GET /feishu-auth/login
|
|
79
|
+
G->>B: 302 飞书授权页(+ state Cookie)
|
|
80
|
+
B->>G: GET /feishu-auth/callback?code=…&state=…
|
|
81
|
+
G->>B: 303 /?token=…(+ 会话 Cookie + 交接标记)
|
|
82
|
+
B->>H: GET /?token=…
|
|
83
|
+
H->>B: 303 /(+ dsh-auth-* Cookie)
|
|
84
|
+
B->>G: GET /(会话 + dsh-auth-*)
|
|
85
|
+
G->>H: 放行
|
|
86
|
+
H->>B: 200 应用页
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Cookie 与会话
|
|
90
|
+
|
|
91
|
+
三个 Cookie,都在 `/` 路径下、`SameSite=Lax`、`HttpOnly`;`Secure` 跟随当前请求的 scheme(https 隧道加,本地 http 不加):
|
|
92
|
+
|
|
93
|
+
| Cookie | 生命周期 | 内容 |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| `dsh-feishu-session` | `sessionMaxAgeDays`(默认 14 天) | `kind=session`、`sub`(open_id)、`name`、`tenant`、`iat`、`exp` |
|
|
96
|
+
| `dsh-feishu-state` | 10 分钟 | `kind=state`、`nonce`、`next`、`redirectUri`、`iat`、`exp` |
|
|
97
|
+
| `dsh-feishu-handoff` | 20 秒 | 交接标记,防往返 |
|
|
98
|
+
|
|
99
|
+
载荷统一是 `v1.<base64url(JSON)>.<base64url(HMAC-SHA256)>`,签名密钥是 `$DSH_HOME/feishu-auth/session-secret`(首次启动生成 32 字节、0600、原子写入;重启不变,所以登录态能跨重启存活)。校验用 `timingSafeEqual`,`state` 用常量时间比较防 CSRF。
|
|
100
|
+
|
|
101
|
+
被拒的账号会看到**他自己**的 `open_id`(便于运维填 `allowedUsers`),不泄露他人信息;所有插值经 `escapeHtml`。
|
|
102
|
+
|
|
103
|
+
## 失败模式
|
|
104
|
+
|
|
105
|
+
| 情形 | 行为 |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| 缺 `appId` / `appSecret` | 故障关闭:所有请求 503「未就绪」页,日志 `[error]` 说明缺什么 |
|
|
108
|
+
| 会话密钥文件不可读写 | 同上(内存里用临时密钥,重启即失效) |
|
|
109
|
+
| `webServer.match` 不存在 | 挂载抛错,插件拒启动——无保护状态不允许运行 |
|
|
110
|
+
| `connection` 服务取不到 | 记 error,交接退化为放行给 harness 的 401 页 |
|
|
111
|
+
| 启动自检失败 | `[error]` 明确报出:未登录请求未被拦,或持有效会话仍被拒 |
|
|
112
|
+
| 配置项(`allowedUsers` / `sessionMaxAgeDays`)非法 | `allowedUsers` 非法 → 致命(避免悄悄放宽到全员);`sessionMaxAgeDays` 非法 → 回落默认值 |
|
|
113
|
+
|
|
114
|
+
自检做两件事:向自己的监听端口发一个未登录探针(必须被拦:302/401/403/503 之一)和一个自签会话探针(必须放行)。
|
|
115
|
+
|
|
116
|
+
## 配置与生效路径
|
|
117
|
+
|
|
118
|
+
`analyzeConfig(raw)` 是唯一入口,返回 `{ config, fatal }`;`Config`(standard-schema)只是把它包给 loader。`fatal` 非空即进入故障关闭模式。
|
|
119
|
+
|
|
120
|
+
生效路径两种:`appId`/`appSecret` 走环境变量(启动时读取,改完必须重启);`allowedUsers`/`sessionMaxAgeDays` 写在 profile patch 行里,patch 层是 live 重载,保存即生效。
|
|
121
|
+
|
|
122
|
+
本包以**组合包**分发:`package.json` 的 `dsh.bundle.patch` 指向 `cordis.patch.yml`,安装后这一层负责插入插件行。用户 profile 自己的 patch 层在组合包层之后应用,可以按 `id` 覆盖它(替换整行 `config`,不是深合并)。
|
|
123
|
+
|
|
124
|
+
## 模块与依赖方向
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
index.js ──► gate.js ──► urls.js ──► (node builtins)
|
|
128
|
+
│ ├──► session.js
|
|
129
|
+
│ ├──► feishu.js ──► urls.js
|
|
130
|
+
│ ├──► pages.js
|
|
131
|
+
│ └──► config.js
|
|
132
|
+
└──► config.js / session.js
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`urls.js`、`config.js`、`session.js` 是纯函数模块(易测);`gate.js` 持有请求处理与拦截状态;`index.js` 只做装配、自检与 harness 服务解析。
|
|
136
|
+
|
|
137
|
+
## 已知边界与风险
|
|
138
|
+
|
|
139
|
+
- **WebSocket(`/api/remote.mux`)不在拦截范围**:网关只拦 HTTP。升级请求由 harness 自己校验它签名的 Cookie,而那个 Cookie 只有走完飞书登录才拿得到——属于间接保护。
|
|
140
|
+
- **`X-Forwarded-For` 未校验**:日志里的 `from <ip> via <socket>` 前半段可被直连方伪造。它只是审计信息,不参与任何放行判断。
|
|
141
|
+
- **没有公开路径白名单**:任何非插件端点都要登录。需要 webhook 之类的免登录路径时要新增配置项。
|
|
142
|
+
- **地址变了要重新登录**:会话 Cookie 按来源隔离,https 隧道与 `http://127.0.0.1` 各算一处。
|
|
143
|
+
- **回调地址不随隧道漂移**:临时隧道换域名后必须在飞书后台补登记,否则 `redirect_uri unmatch`。
|
|
144
|
+
- **dsh 版本耦合点**:`webServer.match` 分发点、`connection` 服务的获取方式、`dsh-auth-` Cookie 前缀、`/?token=` 兑换约定。升级 dsh 后优先复核这四处(见 [AGENTS.md](../AGENTS.md#与-dsh-版本的耦合点))。
|
|
145
|
+
|
|
146
|
+
## 测试策略
|
|
147
|
+
|
|
148
|
+
`node --test`,零依赖。三个层次:
|
|
149
|
+
|
|
150
|
+
- **纯函数**:`session`(签名、篡改、过期、base64url 规范化)、`urls`(Host 规范化、开放重定向、导航判定)、`feishu`(两个响应形态、错误分支)。
|
|
151
|
+
- **网关端到端**:用替身 server/req/res 走真实分发路径——拒绝策略、登录跳转、回调(含 state 不匹配、拒绝名单、上游失败)、交接判定、登出。
|
|
152
|
+
- **拦截层语义**:替身模拟 Cordis「每次读返回新 Proxy」的服务形态,锁住「代理形态下可卸载」「重载顺序」「遗留层收敛」三条不变量。
|
|
153
|
+
|
|
154
|
+
改拦截层或交接逻辑时,新增用例要先在旧实现上跑红,确认它真的锁住了行为。
|
package/docs/release.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# 分发、CI 与发版
|
|
2
|
+
|
|
3
|
+
[README](../README.md) 讲怎么用,[AGENTS.md](../AGENTS.md) 讲怎么改,[架构](architecture.md) 讲内部设计。本文讲这个包怎么分发出去、CI 跑什么、版本怎么发。
|
|
4
|
+
|
|
5
|
+
## 分发形态
|
|
6
|
+
|
|
7
|
+
| 形态 | 用途 | 装载方式 |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| npm 包(组合包) | 常规安装 | `dsh plugin --profile <name> add dsh-feishu-auth` |
|
|
10
|
+
| tarball | 离线 / 内网 | `npm pack` 后 `dsh plugin --profile <name> add ./dsh-feishu-auth-0.1.0.tgz` |
|
|
11
|
+
| 本地源码 | 开发 | 见 [AGENTS.md 的安装与激活](../AGENTS.md#安装与激活) |
|
|
12
|
+
|
|
13
|
+
npm 路径靠 `package.json` 的 `dsh.bundle.patch` 声明自己是组合包,`cordis.patch.yml` 就是它应用的那一层——插件行按**包名**引用,Node 的模块解析才找得到已安装的代码。层语义:后应用的层按 `id` 覆盖前面的行,且 patch 替换整行 `config` 而不是深合并,所以用户 profile 自己的 `cordis.patch.yml` 能覆盖本包给的默认值。
|
|
14
|
+
|
|
15
|
+
`lib/` 是纯 JS,发布包直接带源码,所以从 git 安装也不需要 `prepare` 构建,更没有安装时执行脚本的授权问题。
|
|
16
|
+
|
|
17
|
+
## 开发与校验
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm ci # 只装 devDependencies(eslint);运行时零依赖
|
|
21
|
+
npm run lint
|
|
22
|
+
npm test # node --test,39 个用例
|
|
23
|
+
npm run verify # lint + test —— CI 与 prepublishOnly 跑的就是它
|
|
24
|
+
npm pack --dry-run # 检查发布产物内容(15 个文件)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## CI
|
|
28
|
+
|
|
29
|
+
`.github/workflows/ci.yml`,在 push 到 `main`、PR、手动触发时跑:
|
|
30
|
+
|
|
31
|
+
| job | 内容 |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `lint` | `npm ci` + `npm run lint` |
|
|
34
|
+
| `test` | node 22 / 24 矩阵 + `npm test` |
|
|
35
|
+
| `package` | `npm pack --dry-run` + `npm stage publish --dry-run`(与发版同一条命令,凭据无关) |
|
|
36
|
+
|
|
37
|
+
## 发版:staged publishing + trusted publishing
|
|
38
|
+
|
|
39
|
+
发布走 npm 的**暂存**机制:CI 把版本放进 stage 队列(非公开、不可安装),维护者再用 2FA 批准,版本才上线。这样 CI 里不需要任何长期 token——泄露的 token 也发不出版——代价是每次发版要人工点一次。
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm version patch # 或 minor / major;改 package.json 并生成 vX.Y.Z tag
|
|
43
|
+
git push --follow-tags
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`.github/workflows/release.yml` 依次做四件事:校验 tag 与 `package.json` 版本一致 → `npm run verify` → 查 npm 上是否已有该版本(有则跳过暂存)→ `npm stage publish --access public` → `gh release create --generate-notes`。流水线最后会把「待批准」写进该次运行的 Summary。
|
|
47
|
+
|
|
48
|
+
随后批准上线,二选一:
|
|
49
|
+
|
|
50
|
+
- 网页:npmjs.com → 你的账号 → **Staged Packages** → 选中版本 → Approve(提示 2FA)
|
|
51
|
+
- CLI:`npm stage list` 拿 stage id → `npm stage approve <stage-id>`(需 2FA)
|
|
52
|
+
|
|
53
|
+
批准前可以验货:`npm stage download <stage-id>` 把 tarball 拉下来看,`npm stage reject <stage-id>` 丢弃。
|
|
54
|
+
|
|
55
|
+
### 前提
|
|
56
|
+
|
|
57
|
+
| 前提 | 说明 |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| 账号已开 2FA | staged publishing 的硬要求;`npm stage publish` 不要 2FA,approve 必须过 |
|
|
60
|
+
| 包已存在于 registry | **stage 不支持全新包**,首次发布必须手工 `npm publish` |
|
|
61
|
+
| trusted publisher 已配置 | npmjs.com 该包 → Settings → Trusted Publishing → 添加 GitHub Actions 发布者:`jianghuifr` / `dsh-feishu-auth` / workflow `release.yml`,权限限制为 **stage-only** |
|
|
62
|
+
|
|
63
|
+
配成 stage-only 后,该 workflow 发起的 `npm publish` 会被 registry 拒绝,只有 `npm stage publish` 被接受。
|
|
64
|
+
|
|
65
|
+
### 首次发布(只需一次,手工)
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
cd ~/.dsh/plugins/dsh-feishu-auth
|
|
69
|
+
npm login --registry https://registry.npmjs.org
|
|
70
|
+
npm publish --access public --registry https://registry.npmjs.org
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- 必须显式指定 registry:本机 npm 默认源是镜像站,不加会发到镜像。
|
|
74
|
+
- 手工发布的 0.1.0 不带 provenance(provenance 需要 CI 的 OIDC);从 0.1.1 起走流水线自动带。
|
|
75
|
+
|
|
76
|
+
## 版本号与 npm CLI
|
|
77
|
+
|
|
78
|
+
`npm stage` 需要 npm CLI ≥ 11.15,本机是 11.6.2,所以本机要用 `npx npm@latest stage ...`;`ci.yml` 与 `release.yml` 里都显式 `npm install -g npm@latest`,不受 runner 自带版本影响。
|
|
79
|
+
|
|
80
|
+
包名 `dsh-feishu-auth` 未被占用(2026-09-13 查 registry 返回 404)。首次发布前确认 npm 账号已开 2FA。
|
|
81
|
+
|
|
82
|
+
## 依赖维护
|
|
83
|
+
|
|
84
|
+
`.github/dependabot.yml` 每周检查 npm 与 GitHub Actions 依赖并开 PR;合并前跑 `npm run verify`。运行时零依赖,所以 dependabot 只碰 devDependencies 与 action 版本。
|
package/enable.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Kept so an existing launch command that passes this overlay keeps working:
|
|
2
|
+
#
|
|
3
|
+
# dsh --profile web --patch ~/.dsh/plugins/dsh-feishu-auth/enable.patch.yml \
|
|
4
|
+
# --no-open --host 0.0.0.0 --port 3080
|
|
5
|
+
#
|
|
6
|
+
# The profile row is enabled by default, so this file is a no-op. To boot the
|
|
7
|
+
# page WITHOUT the login gate, use disable.patch.yml instead.
|
|
8
|
+
- id: feishu-auth
|
|
9
|
+
disabled: false
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin config: four flat keys, and the fail-closed analysis the entry runs.
|
|
3
|
+
*
|
|
4
|
+
* The validator never throws. A config that cannot authenticate anyone still
|
|
5
|
+
* installs the gate, and the gate then refuses every request with an
|
|
6
|
+
* explanation instead of leaving the page exposed.
|
|
7
|
+
* @module dsh-feishu-auth/config
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Cookie carrying the signed browser session. */
|
|
11
|
+
export const SESSION_COOKIE = 'dsh-feishu-session';
|
|
12
|
+
/** Cookie carrying the one-shot OAuth state. */
|
|
13
|
+
export const STATE_COOKIE = 'dsh-feishu-state';
|
|
14
|
+
/** Marker bounding the harness handoff to one attempt per exchange. */
|
|
15
|
+
export const HANDOFF_COOKIE = 'dsh-feishu-handoff';
|
|
16
|
+
/** OAuth state lifetime: enough for a slow consent screen, no longer. */
|
|
17
|
+
export const STATE_TTL_MS = 10 * 60 * 1000;
|
|
18
|
+
/** How long the handoff marker lives. */
|
|
19
|
+
export const HANDOFF_TTL_SECONDS = 20;
|
|
20
|
+
/** Every endpoint this plugin owns lives under this prefix. */
|
|
21
|
+
export const PATH_PREFIX = '/feishu-auth';
|
|
22
|
+
/** Browser-session lifetime in days. */
|
|
23
|
+
export const DEFAULT_SESSION_MAX_AGE_DAYS = 14;
|
|
24
|
+
|
|
25
|
+
function readNonEmptyString(value) {
|
|
26
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve raw plugin config into the shape the gate consumes.
|
|
31
|
+
*
|
|
32
|
+
* Credentials fall back to `FEISHU_APP_ID` / `FEISHU_APP_SECRET`, which is how
|
|
33
|
+
* this deployment keeps the secret in `$DSH_HOME/.env` rather than in a config
|
|
34
|
+
* file. A malformed `allowedUsers` is fatal on purpose: silently dropping it
|
|
35
|
+
* would widen access to every user who can complete the Feishu login.
|
|
36
|
+
* @param raw - the config object the loader passed (or nothing).
|
|
37
|
+
* @returns `{ config, fatal }` — `fatal` forces the gate's fail-closed mode.
|
|
38
|
+
*/
|
|
39
|
+
export function analyzeConfig(raw) {
|
|
40
|
+
const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
41
|
+
const fatal = [];
|
|
42
|
+
|
|
43
|
+
const appId = readNonEmptyString(source.appId) ?? readNonEmptyString(process.env.FEISHU_APP_ID) ?? '';
|
|
44
|
+
const appSecret = readNonEmptyString(source.appSecret) ?? readNonEmptyString(process.env.FEISHU_APP_SECRET) ?? '';
|
|
45
|
+
if (appId === '') fatal.push('未配置 appId,也没有环境变量 FEISHU_APP_ID');
|
|
46
|
+
if (appSecret === '') fatal.push('未配置 appSecret,也没有环境变量 FEISHU_APP_SECRET');
|
|
47
|
+
|
|
48
|
+
let allowedUsers = [];
|
|
49
|
+
if (source.allowedUsers !== undefined && source.allowedUsers !== null) {
|
|
50
|
+
const entries = Array.isArray(source.allowedUsers) ? source.allowedUsers : undefined;
|
|
51
|
+
const cleaned = entries?.map(readNonEmptyString).filter((value) => value !== undefined);
|
|
52
|
+
if (cleaned === undefined || cleaned.length !== entries.length) {
|
|
53
|
+
fatal.push('allowedUsers 必须是字符串数组(例如 [ou_xxx, ou_yyy])');
|
|
54
|
+
} else {
|
|
55
|
+
allowedUsers = [...new Set(cleaned)];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rawDays = source.sessionMaxAgeDays;
|
|
60
|
+
const sessionMaxAgeDays =
|
|
61
|
+
Number.isInteger(rawDays) && rawDays >= 1 && rawDays <= 365 ? rawDays : DEFAULT_SESSION_MAX_AGE_DAYS;
|
|
62
|
+
|
|
63
|
+
return { config: { appId, appSecret, allowedUsers, sessionMaxAgeDays }, fatal };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The standard-schema validator cordis calls before the plugin starts. */
|
|
67
|
+
export const Config = {
|
|
68
|
+
'~standard': {
|
|
69
|
+
version: 1,
|
|
70
|
+
vendor: 'dsh-feishu-auth',
|
|
71
|
+
/**
|
|
72
|
+
* Validate and normalize plugin config.
|
|
73
|
+
* @param value - the raw config from the patch row.
|
|
74
|
+
* @returns the normalized config as the schema's value.
|
|
75
|
+
*/
|
|
76
|
+
validate(value) {
|
|
77
|
+
return { value: analyzeConfig(value).config };
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|