dsh-feishu-auth 0.1.2 → 0.2.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 +10 -5
- package/README.md +6 -0
- package/docs/architecture.md +55 -22
- package/docs/release.md +4 -2
- package/lib/client.js +103 -0
- package/lib/gate.js +73 -34
- package/lib/pages.js +49 -0
- package/package.json +7 -1
package/AGENTS.md
CHANGED
|
@@ -19,6 +19,7 @@ DSH(DeepSeek Harness)Web 界面的飞书 OAuth 登录网关。一个 Cordis
|
|
|
19
19
|
| `lib/gate.js` | 网关引擎:分发拦截与层标记、OAuth 端点、拒绝策略、harness 两段式交接 |
|
|
20
20
|
| `lib/feishu.js` | 飞书接口:授权 URL、换 user_access_token、读用户信息 |
|
|
21
21
|
| `lib/session.js` | HMAC 签名 Cookie(会话 / state)、密钥文件读写 |
|
|
22
|
+
| `lib/client.js` | 浏览器半边:设置面板「退出登录」入口,注册进插槽 `settings.action` |
|
|
22
23
|
| `lib/config.js` | 配置解析与 Cookie 名、TTL 常量 |
|
|
23
24
|
| `lib/urls.js` | Host 规范化、回调地址推导、`next` 开放重定向防护、导航请求判定 |
|
|
24
25
|
| `lib/pages.js` | 提示页(拒绝 / 错误 / 未就绪 / 已登出),全部内联样式 |
|
|
@@ -60,9 +61,10 @@ cd ~/.dsh/plugins/dsh-feishu-auth && node --test # 单元用例
|
|
|
60
61
|
| --- | --- |
|
|
61
62
|
| 启动日志 | `飞书登录已挂载 …` + `网关自检通过(未登录 → HTTP 401,已登录 → HTTP 404)`,无 `[error]` |
|
|
62
63
|
| 未登录访问(浏览器式请求) | `302 → /feishu-auth/login?next=…`;`/api` 无 cookie 时是 `401` JSON |
|
|
63
|
-
| 完整登录交接 | `GET /` → `303 /?token=…` →
|
|
64
|
+
| 完整登录交接 | `GET /` → harness 回 401 时给 **200 同站重进页**(自动跳回 `/`)→ 再访问 `/` 得 `200` 真实应用页;harness 不回 401 时走 `303 /?token=…` → 下发 `dsh-auth-*` → 再访问 `/` 得 `200` |
|
|
64
65
|
| 停用验证 | 探针应从 `302`(网关在岗)变成 `401`(harness 自己的门)——这是确认层真的被摘掉的唯一可靠信号 |
|
|
65
66
|
| `/feishu-auth/status` | `{"gate":"enforce","authenticated":…}` |
|
|
67
|
+
| 设置页入口 | 打开设置面板:头部「关闭」左边出现「退出登录」;点击后落到「已退出登录」页,浏览器里本插件与 harness 的 Cookie 都清空 |
|
|
66
68
|
|
|
67
69
|
## 配置项的生效语义
|
|
68
70
|
|
|
@@ -89,7 +91,7 @@ feishu-auth[error] 拿不到 harness 的入口地址(connection 服务不可
|
|
|
89
91
|
| 现象 | 处理 |
|
|
90
92
|
| --- | --- |
|
|
91
93
|
| 所有请求 503 | 凭证不在进程环境里:确认 `~/.dsh/.env` 后重启 |
|
|
92
|
-
|
|
|
94
|
+
| 页面打不开(0.1.3 之前会停在 harness 的 401 页) | 网关自己收尾:先给同站重进页、再换新凭据,两轮都不行才给「还差一步」页并打 warn。若日志里完全没有恢复行、只有 `[error]` 说拿不到入口地址 → 检查 `ctx.inject(['connection'])` 是否仍被 dsh 支持 |
|
|
93
95
|
| 飞书报 `redirect_uri unmatch` | 回调地址没登记/不一致;临时隧道换域名后必须补登记 |
|
|
94
96
|
| 飞书报 `20010` | 账号不在应用可用范围,或应用版本未发布 |
|
|
95
97
|
| 想立刻放行 | `disable.patch.yml` 启动一次,或给该行加 `disabled: true` 后重启 |
|
|
@@ -101,7 +103,8 @@ feishu-auth[error] 拿不到 harness 的入口地址(connection 服务不可
|
|
|
101
103
|
- **绝不用身份比较判断服务成员。** `ctx.webServer` 是 Cordis traceable 服务,成员读取每次都返回新的包装 Proxy:`server.match === 你的函数` 恒为 false。识别自己的层只能靠符号标记,解包靠 `Symbol.for('cordis.original')`。详见架构文档。
|
|
102
104
|
- **挂载/卸载必须幂等。** 热重载时新层可能先于旧层的 disposer 挂上:只允许最新层卸载,发现遗留层要复用它的原始实现而不是往上叠。
|
|
103
105
|
- **新增行为要补用例**,并确认「旧实现下该用例会红」——否则它没锁住任何东西。
|
|
104
|
-
-
|
|
106
|
+
- **不注入 DOM、不改 harness 的客户端资产**:要出现在界面上只走 dsh 的插槽(`lib/client.js`,见架构文档「客户端半边」),不要往页面里塞元素。
|
|
107
|
+
- **浏览器半边必须保持「注册工厂」形态**:`lib/client.js` 顶层只允许 `window.__ModuleLoader__.load({ id, factory })`;`require` 与一切副作用都必须进 factory,否则运行时直接抛。
|
|
105
108
|
- **提示页只用内联样式**,不能依赖被自己保护的静态资源。
|
|
106
109
|
- 配置解析永不抛错:除凭证外的问题降级为默认值并记录;凭证缺失走故障关闭。
|
|
107
110
|
|
|
@@ -116,13 +119,15 @@ feishu-auth[error] 拿不到 harness 的入口地址(connection 服务不可
|
|
|
116
119
|
|
|
117
120
|
## 与 dsh 版本的耦合点
|
|
118
121
|
|
|
119
|
-
升级 dsh
|
|
122
|
+
升级 dsh 后优先复核这几处,任何一处变了都要同步适配:
|
|
120
123
|
|
|
121
124
|
| 依赖 | 用途 | 失效表现 |
|
|
122
125
|
| --- | --- | --- |
|
|
123
126
|
| `webServer.match(pathname)` 分发点 | 唯一的拦截缝隙 | 插件**拒绝启动**并报错(不会静默放过) |
|
|
124
|
-
| `ctx.inject(['connection'])` | 取 harness 入口地址(两段式交接) | 日志 error
|
|
127
|
+
| `ctx.inject(['connection'])` | 取 harness 入口地址(两段式交接) | 日志 error;只剩同站重进这一步可救,跨站链那类场景会落到「还差一步」页 |
|
|
125
128
|
| `dsh-auth-<authority>` Cookie 前缀 | 登出时清掉 harness 自己的 Cookie | 登出后可能被 harness 直接放回 |
|
|
126
129
|
| `/?token=<launch token>` 兑换约定 | 交接第二段 | 同上 |
|
|
130
|
+
| `dsh.client` + `exports["./client"]` 契约 | 浏览器半边的发现与托管(`/plugins/??<id>/client.js`) | 设置页里没有「退出登录」入口,`/plugins/…` 404 |
|
|
131
|
+
| 插槽名 `settings.action`(`ctx.slots`) | 入口在设置面板里的位置 | 入口不出现;需换槽名并核对 `settings.*` 插槽目录 |
|
|
127
132
|
|
|
128
133
|
另见架构文档「已知边界与风险」。
|
package/README.md
CHANGED
|
@@ -64,6 +64,12 @@ dsh web --no-open --host 0.0.0.0 --port 3080 --trusted-host <你的隧道域名>
|
|
|
64
64
|
|
|
65
65
|
`allowedUsers` 该填什么:被拒的人会在页面上看到**他自己**的 `open_id`,抄进去保存即可——配置项热生效,不用重启。
|
|
66
66
|
|
|
67
|
+
## 退出登录
|
|
68
|
+
|
|
69
|
+
**设置面板右上角有一个「退出登录」**(`关闭` 按钮左边)。它调 `/feishu-auth/logout`,会清掉本插件的会话 Cookie **和** harness 自己的 `dsh-auth-*`——后者是关键,只清前者的话 harness 会立刻把人放回去。
|
|
70
|
+
|
|
71
|
+
标签页停在旧的 harness 报错页上时,按一次刷新即可恢复:网关会自己把浏览器接回来(同站重进 → 必要时换一份新凭据),**不需要重新登录**。
|
|
72
|
+
|
|
67
73
|
## 常见问题
|
|
68
74
|
|
|
69
75
|
| 现象 | 处理 |
|
package/docs/architecture.md
CHANGED
|
@@ -14,16 +14,26 @@ flowchart TB
|
|
|
14
14
|
CFG -->|"是"| FC["503 未就绪页<br/>(故障关闭)"]
|
|
15
15
|
CFG -->|"否"| AU{"有效会话 Cookie?"}
|
|
16
16
|
AU -->|"否"| DN["导航: 302 → 飞书授权页<br/>其它: 401 JSON"]
|
|
17
|
-
AU -->|"是"| HO{"需要 harness
|
|
18
|
-
HO -->|"是"| EX["303 → /?token=…<br/>+
|
|
19
|
-
HO -->|"否"| PS["交给 harness
|
|
20
|
-
PS -->|"harness 回 401
|
|
17
|
+
AU -->|"是"| HO{"需要 harness 交接?<br/>(请求前判定)"}
|
|
18
|
+
HO -->|"是"| EX["303 → /?token=…<br/>+ 阶梯 0"]
|
|
19
|
+
HO -->|"否"| PS["交给 harness 原分发逻辑<br/>并盯住它的回答"]
|
|
20
|
+
PS -->|"harness 回 401"| LD{"恢复阶梯"}
|
|
21
|
+
LD -->|"阶梯 0"| EN["200 同站重进页<br/>+ 阶梯 1"]
|
|
22
|
+
LD -->|"阶梯 1"| EX2["303 → /?token=…<br/>+ 阶梯 2"]
|
|
23
|
+
LD -->|"阶梯 2"| ST["200「还差一步」页<br/>+ 清阶梯"]
|
|
24
|
+
EN --> PS
|
|
25
|
+
EX2 --> PS
|
|
21
26
|
```
|
|
22
27
|
|
|
23
28
|
「需要 harness 交接」有两条触发路径:
|
|
24
29
|
|
|
25
|
-
1. **请求前判定**:`GET/HEAD` 导航请求、路径是 `/`、URL 上没有 `token` 参数、请求里没有 `dsh-auth-` 开头的 Cookie
|
|
26
|
-
2.
|
|
30
|
+
1. **请求前判定**:`GET/HEAD` 导航请求、路径是 `/`、URL 上没有 `token` 参数、请求里没有 `dsh-auth-` 开头的 Cookie,且没有阶梯 Cookie。够用即走 `/?token=…`。
|
|
31
|
+
2. **响应后判定(恢复阶梯)**:判定 1 只能看到 `dsh-auth-*` **存不存在**,看不到浏览器到底交没交上来。有两种状态会让已登录的页面请求仍被 harness 打回 401:
|
|
32
|
+
|
|
33
|
+
- **凭据作废**:harness 只在「根请求携带本进程启动令牌」时签发 `dsh-auth-*`,进程一重启,浏览器手里那份签名就验不过了;
|
|
34
|
+
- **凭据被扣下**:harness 那张 Cookie 是 `SameSite=Strict`,而浏览器若正沿一条**跨站跳转链**走(飞书 OAuth 回调就是,且这条链上的后续跳转都留在链里),链内所有请求都不会带上它——尽管它已经存好了。
|
|
35
|
+
|
|
36
|
+
两种状态的共同可靠信号就是 harness 自己的 401,所以页面入口的响应被 401 打回时,不把它转给用户,而是按阶梯走一步(阶梯值记在 `dsh-feishu-handoff` 里,见「两段式交接与恢复阶梯」)。
|
|
27
37
|
|
|
28
38
|
## 拦截层
|
|
29
39
|
|
|
@@ -61,33 +71,44 @@ sequenceDiagram
|
|
|
61
71
|
L->>G2: dispose(是最新层 → 还原 original)
|
|
62
72
|
```
|
|
63
73
|
|
|
64
|
-
##
|
|
74
|
+
## 两段式交接与恢复阶梯
|
|
65
75
|
|
|
66
76
|
飞书登录只签发本插件自己的会话 Cookie。harness 另有一层签名 Cookie(`dsh-auth-<authority>`),只在一个**根请求带上本进程启动令牌**时签发。所以「能打开页面」需要两段都完成:
|
|
67
77
|
|
|
68
78
|
1. 网关把浏览器跳到 `connection.authenticatedUrl()` 给出的 `/?token=<launch token>`;
|
|
69
79
|
2. harness 校验令牌、下发 `dsh-auth-*`,再跳回干净的 `/`。
|
|
70
80
|
|
|
71
|
-
`connection` 服务**只能**经 `ctx.inject(['connection'], cb)` 取(`ctx.get` 返回 undefined,属性访问直接抛错),所以在插件挂载时捕获成 `entryUrlProvider`,每次请求时调用。取不到时打一行 error
|
|
81
|
+
`connection` 服务**只能**经 `ctx.inject(['connection'], cb)` 取(`ctx.get` 返回 undefined,属性访问直接抛错),所以在插件挂载时捕获成 `entryUrlProvider`,每次请求时调用。取不到时打一行 error,交接判定退化为「不交接」。
|
|
82
|
+
|
|
83
|
+
### 阶梯
|
|
84
|
+
|
|
85
|
+
**`dsh-feishu-handoff`**(20 秒)记的是这台浏览器已经花掉的恢复步数,而不是一个 0/1 标记:
|
|
86
|
+
|
|
87
|
+
| 值 | 含义 | 这一层的回答 |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| 无 | 还没试过 | harness 回 401 → **同站重进页**(200,`location.replace`),记 1 |
|
|
90
|
+
| `0` | 只做过请求前交接 | 同上(同站重进) |
|
|
91
|
+
| `1` | 已同站重进 | harness 回 401 → **`303 /?token=…`**,记 2 |
|
|
92
|
+
| `2` | 重进 + 交接都试过 | harness 回 401 → **「还差一步」页**(200,给按钮与原因),并清掉阶梯 |
|
|
93
|
+
|
|
94
|
+
为什么要「同站重进」这一步:harness 的 `dsh-auth-<authority>` 带 `SameSite=Strict`,而飞书 OAuth 回调落在浏览器眼里是一条**跨站链**——链上所有请求(包括回调后 303 到 `/?token=…`、harness 再 303 回 `/`)都不带 Strict Cookie。于是在 `/` 这一跳被 harness 打回 401,尽管 Cookie 已经存好。此时从**本站域内的文档**发起一次跳转(我们的重进页就是),导航的同站属性成立,Cookie 就带上了——一次跳转、无需重新登录。实测:harness 的墙页面上执行 `location.replace('/')` 即返回应用页。
|
|
72
95
|
|
|
73
|
-
|
|
96
|
+
作废旧凭据(harness 重启)走的是下一步:`/?token=…` 会重新签发一张能验过的 Cookie;这条链从同站重进之后出发,因此也在同站上下文里,新 Cookie 立刻可用。
|
|
74
97
|
|
|
75
|
-
|
|
98
|
+
阶梯尽头(用户浏览器连续两次都不交出凭据,例如无痕窗口或拦截扩展)由插件自己的页面收尾并打 warn——**harness 的 401 页任何时候都不会被直接转给用户**,因为那张页面只写着一个对用户毫无意义的内部 URL。
|
|
76
99
|
|
|
77
100
|
```mermaid
|
|
78
101
|
sequenceDiagram
|
|
79
102
|
participant B as 浏览器
|
|
80
103
|
participant G as 网关
|
|
81
104
|
participant H as harness
|
|
82
|
-
B->>
|
|
83
|
-
|
|
84
|
-
B->>G: GET
|
|
85
|
-
G->>
|
|
86
|
-
B
|
|
87
|
-
G->>B:
|
|
88
|
-
B->>
|
|
89
|
-
H->>B: 303 /(+ dsh-auth-* Cookie)
|
|
90
|
-
B->>G: GET /(会话 + dsh-auth-*)
|
|
105
|
+
B->>H: GET /?token=…(跨站链内)
|
|
106
|
+
H->>B: 303 /(+ dsh-auth-* Strict Cookie,链内被扣下)
|
|
107
|
+
B->>G: GET /(会话有,Strict Cookie 没带上)
|
|
108
|
+
G->>H: 放行
|
|
109
|
+
H->>B: 401 认证墙
|
|
110
|
+
G->>B: 200 同站重进页(吞掉 401,阶梯 1)
|
|
111
|
+
B->>G: GET /(同站导航 → 带上 Strict Cookie)
|
|
91
112
|
G->>H: 放行
|
|
92
113
|
H->>B: 200 应用页
|
|
93
114
|
```
|
|
@@ -100,12 +121,21 @@ sequenceDiagram
|
|
|
100
121
|
| --- | --- | --- |
|
|
101
122
|
| `dsh-feishu-session` | `sessionMaxAgeDays`(默认 14 天) | `kind=session`、`sub`(open_id)、`name`、`tenant`、`iat`、`exp` |
|
|
102
123
|
| `dsh-feishu-state` | 10 分钟 | `kind=state`、`nonce`、`next`、`redirectUri`、`iat`、`exp` |
|
|
103
|
-
| `dsh-feishu-handoff` | 20 秒 |
|
|
124
|
+
| `dsh-feishu-handoff` | 20 秒 | 恢复阶梯步数(`1` / `2`),防往返 |
|
|
104
125
|
|
|
105
126
|
载荷统一是 `v1.<base64url(JSON)>.<base64url(HMAC-SHA256)>`,签名密钥是 `$DSH_HOME/feishu-auth/session-secret`(首次启动生成 32 字节、0600、原子写入;重启不变,所以登录态能跨重启存活)。校验用 `timingSafeEqual`,`state` 用常量时间比较防 CSRF。
|
|
106
127
|
|
|
107
128
|
被拒的账号会看到**他自己**的 `open_id`(便于运维填 `allowedUsers`),不泄露他人信息;所有插值经 `escapeHtml`。
|
|
108
129
|
|
|
130
|
+
## 客户端半边
|
|
131
|
+
|
|
132
|
+
设置面板里的「退出登录」入口由 `lib/client.js` 提供——插件唯一的浏览器侧代码,同时消费两个外部契约:
|
|
133
|
+
|
|
134
|
+
- **dsh 的客户端插件机制**(`@deepseek-ai/dsh-client-modules`):包在 `package.json` 里声明 `dsh.client`(`platform: web`、`immediately`),产物挂在 `exports["./client"]`。服务端扫描**所有**这样声明的包(不限于 `@deepseek-ai/*`),按模块图顺序合成 combo 脚本,浏览器侧由 `window.__ModuleLoader__` 惰性执行。产物必须是**注册工厂形态**:文件顶层只调用 `window.__ModuleLoader__.load({ id, factory })`,不得有任何副作用或 `require`(运行时会抛 `requested external … before the module system existed`)。本插件手写这个包装,所以仓库仍然零依赖、零构建。
|
|
135
|
+
- **插槽 API**(`ctx.slots`):`inject(name, …)` 等槽出现后再 `register`。用的是 `settings.action`——「content-column header, before Close」,`kind: list`、`replaceRisk: none`,因此我们的条目是**加**进去的,不替换任何既有 UI。`settings.action` 由设置面板条目在挂载时声明,所以必须先 `inject` 再注册。
|
|
136
|
+
|
|
137
|
+
登出本身仍归服务端:入口只是一个指向 `/feishu-auth/logout` 的普通链接,由网关清掉自己的会话 Cookie **和** harness 的 `dsh-auth-*`——少了后者,harness 会当场上门把人放回去。
|
|
138
|
+
|
|
109
139
|
## 失败模式
|
|
110
140
|
|
|
111
141
|
| 情形 | 行为 |
|
|
@@ -113,8 +143,11 @@ sequenceDiagram
|
|
|
113
143
|
| 缺 `appId` / `appSecret` | 故障关闭:所有请求 503「未就绪」页,日志 `[error]` 说明缺什么 |
|
|
114
144
|
| 会话密钥文件不可读写 | 同上(内存里用临时密钥,重启即失效) |
|
|
115
145
|
| `webServer.match` 不存在 | 挂载抛错,插件拒启动——无保护状态不允许运行 |
|
|
116
|
-
| `connection` 服务取不到 | 记 error
|
|
117
|
-
| harness 重启后浏览器仍带旧 `dsh-auth-*` | 响应后判定接管:harness 回 401 →
|
|
146
|
+
| `connection` 服务取不到 | 记 error;不交接,页面入口的 401 由恢复阶梯兜住(同站重进 → 插件自己的页面) |
|
|
147
|
+
| harness 重启后浏览器仍带旧 `dsh-auth-*` | 响应后判定接管:harness 回 401 → 阶梯(同站重进 → 再交接一次)→ 用户无感恢复 |
|
|
148
|
+
| 浏览器沿跨站链到达(飞书 OAuth 回调) | 同一条阶梯的第一步就是为此设计的:同站重进一次即带上 `SameSite=Strict` 的 `dsh-auth-*` |
|
|
149
|
+
| 浏览器两次都不交凭据(无痕窗口 / 拦截扩展) | 阶梯走完 → 插件自己的「还差一步」页 + 一行 warn,不把 harness 的 401 页转给用户 |
|
|
150
|
+
| 客户端 bundle 没被服务(`/plugins/…` 404) | 设置面板里当然也没有入口:核对 `package.json` 的 `dsh.client` + `exports["./client"]`、文件存在,以及件里注册的 `id` 与包名一致(运行时会对不上就抛) |
|
|
118
151
|
| 启动自检失败 | `[error]` 明确报出:未登录请求未被拦,或持有效会话仍被拒 |
|
|
119
152
|
| 配置项(`allowedUsers` / `sessionMaxAgeDays`)非法 | `allowedUsers` 非法 → 致命(避免悄悄放宽到全员);`sessionMaxAgeDays` 非法 → 回落默认值 |
|
|
120
153
|
|
package/docs/release.md
CHANGED
|
@@ -19,11 +19,13 @@ npm 路径靠 `package.json` 的 `dsh.bundle.patch` 声明自己是组合包,`
|
|
|
19
19
|
```bash
|
|
20
20
|
npm ci # 只装 devDependencies(eslint);运行时零依赖
|
|
21
21
|
npm run lint
|
|
22
|
-
npm test # node --test,
|
|
22
|
+
npm test # node --test,50 个用例
|
|
23
23
|
npm run verify # lint + test —— CI 与 prepublishOnly 跑的就是它
|
|
24
|
-
npm pack --dry-run # 检查发布产物内容(
|
|
24
|
+
npm pack --dry-run # 检查发布产物内容(16 个文件)
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
`lib/client.js`(浏览器半边)是**手写**的 loader 包装,不是构建产物:文件顶层只允许 `window.__ModuleLoader__.load({ id, factory })`,任何顶层 `require` 或副作用都会被运行时拒绝。改动它的规范见 [docs/architecture.md 的「客户端半边」](architecture.md#客户端半边)。
|
|
28
|
+
|
|
27
29
|
## CI
|
|
28
30
|
|
|
29
31
|
`.github/workflows/ci.yml`,在 push 到 `main`、PR、手动触发时跑:
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of the gate: one「退出登录」entry in the settings panel.
|
|
3
|
+
*
|
|
4
|
+
* The server half owns `/feishu-auth/logout`, which clears this gate's session
|
|
5
|
+
* cookie AND the harness's own `dsh-auth-*`, so this half only has to put a link
|
|
6
|
+
* to it in front of the user.
|
|
7
|
+
*
|
|
8
|
+
* Two contracts come from dsh, and both are load-bearing:
|
|
9
|
+
*
|
|
10
|
+
* - The file is a **client bundle**: it registers a factory through
|
|
11
|
+
* `window.__ModuleLoader__.load({ id, factory })` and does nothing else at the
|
|
12
|
+
* top level. `@deepseek-ai/dsh-client-modules` serves it at
|
|
13
|
+
* `/plugins/dsh-feishu-auth/client.js` (it discovers any package declaring
|
|
14
|
+
* `dsh.client` plus `exports["./client"]`, not just `@deepseek-ai/*`), and the
|
|
15
|
+
* runtime rejects a bundle that requests an external before the module system
|
|
16
|
+
* exists. Hence: no top-level `require`, no top-level side effects.
|
|
17
|
+
* - The seat is the slot API's, not ours: `ctx.slots.inject(name, …)` waits for
|
|
18
|
+
* the slot to exist — `settings.action` is declared by the settings panel
|
|
19
|
+
* entry, so it only exists while that panel is mounted — and `register` adds
|
|
20
|
+
* an entry beside the shipped ones (`id` of our own, so nothing is replaced).
|
|
21
|
+
*
|
|
22
|
+
* Styles stay inline: a client bundle may inject CSS from inside its factory
|
|
23
|
+
* closure, but one button does not justify a stylesheet.
|
|
24
|
+
* @module dsh-feishu-auth/client
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
window.__ModuleLoader__.load({
|
|
28
|
+
id: 'dsh-feishu-auth',
|
|
29
|
+
factory: (require) => {
|
|
30
|
+
const module = { exports: {} };
|
|
31
|
+
const exports = module.exports;
|
|
32
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
33
|
+
const React = require('react');
|
|
34
|
+
|
|
35
|
+
/** The seat: content-column header actions, rendered before the Close button. */
|
|
36
|
+
const SLOT = 'settings.action';
|
|
37
|
+
/** This entry's id inside that slot — ours, so the shipped entries stay. */
|
|
38
|
+
const ENTRY_ID = 'feishu-logout';
|
|
39
|
+
/** Where the server half signs this browser out. */
|
|
40
|
+
const LOGOUT_PATH = '/feishu-auth/logout';
|
|
41
|
+
|
|
42
|
+
/** Resting and hover looks, kept close to the shipped header actions. */
|
|
43
|
+
const REST = {
|
|
44
|
+
color: 'var(--dsw-alias-label-secondary, #6b6b70)',
|
|
45
|
+
background: 'transparent',
|
|
46
|
+
};
|
|
47
|
+
const HOVER = {
|
|
48
|
+
color: 'var(--dsw-alias-label-primary, #1d1d1f)',
|
|
49
|
+
background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.12))',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The entry itself: a plain link, so a middle-click or a reload-free
|
|
54
|
+
* navigation both reach the server half.
|
|
55
|
+
* @returns the anchor element.
|
|
56
|
+
*/
|
|
57
|
+
function LogoutAction() {
|
|
58
|
+
const [state, setState] = React.useState(REST);
|
|
59
|
+
return React.createElement(
|
|
60
|
+
'a',
|
|
61
|
+
{
|
|
62
|
+
href: LOGOUT_PATH,
|
|
63
|
+
title: '清掉这台浏览器上的登录凭据',
|
|
64
|
+
style: {
|
|
65
|
+
...state,
|
|
66
|
+
display: 'inline-flex',
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
height: '28px',
|
|
69
|
+
padding: '0 10px',
|
|
70
|
+
border: '0',
|
|
71
|
+
borderRadius: '8px',
|
|
72
|
+
font: 'inherit',
|
|
73
|
+
fontSize: '13px',
|
|
74
|
+
lineHeight: '18px',
|
|
75
|
+
textDecoration: 'none',
|
|
76
|
+
whiteSpace: 'nowrap',
|
|
77
|
+
cursor: 'pointer',
|
|
78
|
+
transition: 'color .12s, background .12s',
|
|
79
|
+
},
|
|
80
|
+
onMouseEnter: () => setState(HOVER),
|
|
81
|
+
onMouseLeave: () => setState(REST),
|
|
82
|
+
},
|
|
83
|
+
'退出登录',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const inject = ['slots'];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Register the entry, waiting for the settings panel to declare the slot.
|
|
91
|
+
* @param ctx - client plugin context.
|
|
92
|
+
*/
|
|
93
|
+
function apply(ctx) {
|
|
94
|
+
ctx.slots.inject(SLOT, () =>
|
|
95
|
+
ctx.slots.register({ name: SLOT, id: ENTRY_ID, order: 100, label: '退出登录' }, LogoutAction),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
exports.apply = apply;
|
|
100
|
+
exports.inject = inject;
|
|
101
|
+
return module.exports;
|
|
102
|
+
},
|
|
103
|
+
});
|
package/lib/gate.js
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
STATE_TTL_MS,
|
|
27
27
|
} from './config.js';
|
|
28
28
|
import { buildAuthorizeUrl, exchangeCode, fetchUserInfo } from './feishu.js';
|
|
29
|
-
import { renderDenied, renderError, renderLoggedOut, renderMisconfigured } from './pages.js';
|
|
29
|
+
import { renderDenied, renderEntering, renderError, renderLoggedOut, renderMisconfigured, renderRecoveryStuck } from './pages.js';
|
|
30
30
|
import { cookieNames, expiredCookie, generateToken, readCookie, safeEqual, serializeCookie, signPayload, verifyPayload } from './session.js';
|
|
31
31
|
import { clientAddress, isNavigationRequest, normalizeAuthority, requestBaseUrl, sanitizeNext } from './urls.js';
|
|
32
32
|
|
|
@@ -163,9 +163,7 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
|
|
|
163
163
|
* otherwise land on the harness's 401 page with no way forward.
|
|
164
164
|
*
|
|
165
165
|
* The `token` guard keeps the exchange request itself from looping, and the
|
|
166
|
-
*
|
|
167
|
-
* harness cookie — without it, such a browser would bounce between `/` and
|
|
168
|
-
* `/?token=…` forever.
|
|
166
|
+
* ladder cookie bounds the attempt (see {@link passthroughWatchingForAuthWall}).
|
|
169
167
|
*/
|
|
170
168
|
function needsHarnessHandoff(req, url) {
|
|
171
169
|
if (url.pathname !== '/') return false;
|
|
@@ -178,20 +176,39 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
|
|
|
178
176
|
}
|
|
179
177
|
|
|
180
178
|
/**
|
|
181
|
-
*
|
|
179
|
+
* How many recovery steps this browser has already spent, from the ladder
|
|
180
|
+
* cookie: 0 when nothing has been tried (or only the pre-flight handoff),
|
|
181
|
+
* 1 after the same-site re-entry, 2 after a handoff that followed it.
|
|
182
182
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
183
|
+
* An unparsable value (for example the `1` a browser kept from an older
|
|
184
|
+
* release) counts as one step spent, which only makes the ladder move on
|
|
185
|
+
* sooner — never a loop.
|
|
185
186
|
*/
|
|
186
|
-
function
|
|
187
|
+
function recoveryStep(req) {
|
|
188
|
+
const raw = readCookie(req?.headers?.cookie, HANDOFF_COOKIE);
|
|
189
|
+
if (typeof raw !== 'string') return 0;
|
|
190
|
+
const step = Number.parseInt(raw, 10);
|
|
191
|
+
return Number.isFinite(step) === true && step > 0 ? step : 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Send the browser through the harness's launch-token exchange. */
|
|
195
|
+
function handoffToHarness(req, res, target, step) {
|
|
187
196
|
redirect(res, 303, target, {
|
|
188
|
-
'set-cookie': serializeCookie(HANDOFF_COOKIE,
|
|
197
|
+
'set-cookie': serializeCookie(HANDOFF_COOKIE, String(step), {
|
|
189
198
|
maxAgeSeconds: HANDOFF_TTL_SECONDS,
|
|
190
199
|
secure: isSecure(requestBaseUrl(req)),
|
|
191
200
|
}),
|
|
192
201
|
});
|
|
193
202
|
}
|
|
194
203
|
|
|
204
|
+
/** Record a spent step without answering: used by the same-site re-entry page. */
|
|
205
|
+
function entryCookie(req, step) {
|
|
206
|
+
return serializeCookie(HANDOFF_COOKIE, String(step), {
|
|
207
|
+
maxAgeSeconds: HANDOFF_TTL_SECONDS,
|
|
208
|
+
secure: isSecure(requestBaseUrl(req)),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
195
212
|
/** Deliver the request to the harness exactly as it would have been delivered. */
|
|
196
213
|
async function passthrough(req, res, route, server) {
|
|
197
214
|
const target = typeof route?.handler === 'function' ? route.handler : server.fallback;
|
|
@@ -214,16 +231,24 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
|
|
|
214
231
|
* Deliver a page navigation to the harness while watching for its auth wall.
|
|
215
232
|
*
|
|
216
233
|
* {@link needsHarnessHandoff} can only observe that a `dsh-auth-*` cookie
|
|
217
|
-
* *exists
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
234
|
+
* *exists*; whether the browser actually hands it over is another matter.
|
|
235
|
+
* Two states produce the harness's wall on an authenticated page request:
|
|
236
|
+
*
|
|
237
|
+
* 1. The cookie is stale — it was minted by a previous harness process, so
|
|
238
|
+
* its signature no longer verifies.
|
|
239
|
+
* 2. The cookie is fine but withheld: the harness mints it with
|
|
240
|
+
* `SameSite=Strict`, and a browser that is following a cross-site chain
|
|
241
|
+
* (the Feishu OAuth redirect, and every hop after it in that chain) does
|
|
242
|
+
* not send it for the rest of that chain.
|
|
221
243
|
*
|
|
222
|
-
* The
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
244
|
+
* The wall itself is the dependable signal for both, so it is swallowed and
|
|
245
|
+
* answered with one recovery step, never forwarded: serving the harness's
|
|
246
|
+
* page would show the user an error that names an internal URL they cannot
|
|
247
|
+
* act on. Step 1 re-enters from this origin (a same-site navigation, which
|
|
248
|
+
* sends the Strict cookie — this alone fixes case 2), step 2 runs the
|
|
249
|
+
* launch-token exchange (which mints a fresh cookie — case 1), and a browser
|
|
250
|
+
* that still walls after both gets a page that explains itself instead of a
|
|
251
|
+
* third round.
|
|
227
252
|
*/
|
|
228
253
|
async function passthroughWatchingForAuthWall(req, res, route, server, requested) {
|
|
229
254
|
const target = typeof route?.handler === 'function' ? route.handler : server.fallback;
|
|
@@ -233,16 +258,10 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
|
|
|
233
258
|
return;
|
|
234
259
|
}
|
|
235
260
|
const entryTarget = entryLocation(req, requested);
|
|
236
|
-
const
|
|
237
|
-
entryTarget !== requested &&
|
|
238
|
-
cookieNames(req?.headers?.cookie).includes(HANDOFF_COOKIE) !== true;
|
|
239
|
-
if (canHandOff !== true) {
|
|
240
|
-
await target(req, res);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
261
|
+
const step = recoveryStep(req);
|
|
243
262
|
|
|
244
|
-
// Swallow the auth wall before any of it reaches the socket, so the
|
|
245
|
-
// still be written on the very same response.
|
|
263
|
+
// Swallow the auth wall before any of it reaches the socket, so the
|
|
264
|
+
// recovery answer can still be written on the very same response.
|
|
246
265
|
const originalWriteHead = res.writeHead;
|
|
247
266
|
const originalWrite = res.write;
|
|
248
267
|
const originalEnd = res.end;
|
|
@@ -270,7 +289,26 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
|
|
|
270
289
|
} finally {
|
|
271
290
|
restore();
|
|
272
291
|
}
|
|
273
|
-
if (walled
|
|
292
|
+
if (walled !== true) return;
|
|
293
|
+
|
|
294
|
+
const canHandOff = entryTarget !== requested;
|
|
295
|
+
if (step <= 0) {
|
|
296
|
+
log.info(`harness 拒绝了页面请求(凭据未送达或被判失效),让浏览器从本站重新进入 ${requested}`);
|
|
297
|
+
sendHtml(res, 200, renderEntering({ target: requested }), { 'set-cookie': entryCookie(req, 1) });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (step === 1 && canHandOff === true) {
|
|
301
|
+
log.info('重新进入后仍被拒绝,改走 harness 的入口地址换取新凭据。');
|
|
302
|
+
handoffToHarness(req, res, entryTarget, 2);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
log.warn(
|
|
306
|
+
`这台浏览器连续两次都没把 DSH 的凭据交上来(${requested}):请检查是否处于无痕窗口或 Cookie 被拦截;` +
|
|
307
|
+
'登录态本身有效,换个普通窗口即可访问。',
|
|
308
|
+
);
|
|
309
|
+
sendHtml(res, 200, renderRecoveryStuck({ target: requested, loginPath: `${prefix}${LOGIN_PATH}` }), {
|
|
310
|
+
'set-cookie': expiredCookie(HANDOFF_COOKIE, { secure: isSecure(requestBaseUrl(req)) }),
|
|
311
|
+
});
|
|
274
312
|
}
|
|
275
313
|
|
|
276
314
|
/** Refuse an unauthenticated request: redirect browsers, 401 everything else. */
|
|
@@ -505,16 +543,17 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
|
|
|
505
543
|
const requested = `${url.pathname}${url.search}`;
|
|
506
544
|
if (needsHarnessHandoff(req, url)) {
|
|
507
545
|
const target = entryLocation(req, requested);
|
|
508
|
-
// A resolver that is missing or throws degrades to the requested path
|
|
509
|
-
//
|
|
510
|
-
// through to the harness instead: its own 401 page is terminal.
|
|
546
|
+
// A resolver that is missing or throws degrades to the requested path:
|
|
547
|
+
// hand off only when there is somewhere to hand off to.
|
|
511
548
|
if (target !== requested) {
|
|
512
|
-
handoffToHarness(req, res, target);
|
|
549
|
+
handoffToHarness(req, res, target, 0);
|
|
513
550
|
return;
|
|
514
551
|
}
|
|
515
552
|
}
|
|
516
|
-
// A
|
|
517
|
-
//
|
|
553
|
+
// A `dsh-auth-*` cookie in the request proves nothing about what the
|
|
554
|
+
// harness will accept, and a cookie the browser stores but withholds (see
|
|
555
|
+
// passthroughWatchingForAuthWall) is invisible from here — so the page
|
|
556
|
+
// entry point always checks what the harness actually answers.
|
|
518
557
|
if (isPageNavigation(req, url)) {
|
|
519
558
|
await passthroughWatchingForAuthWall(req, res, route, server, requested);
|
|
520
559
|
return;
|
package/lib/pages.js
CHANGED
|
@@ -80,6 +80,55 @@ function facts(rows) {
|
|
|
80
80
|
return items === '' ? '' : `<dl>${items}</dl>`;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Quote a value for a `<script>` context, so a URL built from the request can
|
|
85
|
+
* never close the tag or start a new statement.
|
|
86
|
+
* @param value - untrusted text.
|
|
87
|
+
* @returns a JavaScript string literal.
|
|
88
|
+
*/
|
|
89
|
+
function jsString(value) {
|
|
90
|
+
return JSON.stringify(String(value ?? '')).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The page that re-enters the harness from this origin.
|
|
95
|
+
*
|
|
96
|
+
* The harness mints `dsh-auth-<authority>` with `SameSite=Strict`. A browser
|
|
97
|
+
* that arrives through a cross-site chain — the Feishu OAuth redirect is one,
|
|
98
|
+
* and the redirects that follow it stay in that chain — therefore does not send
|
|
99
|
+
* that cookie on the later hops of the same chain, and the harness answers its
|
|
100
|
+
* auth wall although the cookie is already stored. A navigation started from a
|
|
101
|
+
* document on this origin is same-site, so one extra hop carries the cookie.
|
|
102
|
+
* @param options - the path to re-enter.
|
|
103
|
+
* @returns the HTML document.
|
|
104
|
+
*/
|
|
105
|
+
export function renderEntering({ target }) {
|
|
106
|
+
const body = `
|
|
107
|
+
<p>已通过飞书验证,正在进入…</p>
|
|
108
|
+
<p class="note">如果没有自动跳转,<a href="${escapeHtml(target)}">点这里继续</a>。</p>
|
|
109
|
+
<script>location.replace(${jsString(target)});</script>`;
|
|
110
|
+
return renderPage({ title: '正在进入 · DSH', heading: '正在进入 DSH', body });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The page shown when the recovery ladder is exhausted: the browser stored
|
|
115
|
+
* neither this gate's cookie nor the harness's, so no redirect can help and the
|
|
116
|
+
* harness's own wall (which names an internal URL the operator would have to
|
|
117
|
+
* dig out) is replaced with an actionable page.
|
|
118
|
+
* @param options - the path to retry and the login path.
|
|
119
|
+
* @returns the HTML document.
|
|
120
|
+
*/
|
|
121
|
+
export function renderRecoveryStuck({ target, loginPath }) {
|
|
122
|
+
const body = `
|
|
123
|
+
<p>这台浏览器没有把 DSH 自己的凭据交给服务器,自动恢复已经试过两轮。</p>
|
|
124
|
+
<p class="note">常见原因:无痕/隐私模式、拦下本站 Cookie 的拦截扩展、或浏览器把这次访问当成第三方上下文。换个普通窗口通常即可。</p>
|
|
125
|
+
<div class="actions">
|
|
126
|
+
<a class="button" href="${escapeHtml(target)}">再试一次</a>
|
|
127
|
+
<a class="button" href="${escapeHtml(loginPath)}">重新登录</a>
|
|
128
|
+
</div>`;
|
|
129
|
+
return renderPage({ title: '还差一步 · DSH', heading: '还差一步', body, tone: 'error' });
|
|
130
|
+
}
|
|
131
|
+
|
|
83
132
|
/**
|
|
84
133
|
* The page an authenticated-but-unauthorized account sees: its own identifiers
|
|
85
134
|
* and nothing about anyone else, so the operator can allowlist the right value.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-feishu-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Feishu (Lark) OAuth login gate for the DeepSeek Harness web GUI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"main": "lib/index.js",
|
|
19
19
|
"exports": {
|
|
20
20
|
".": "./lib/index.js",
|
|
21
|
+
"./client": "./lib/client.js",
|
|
21
22
|
"./package.json": "./package.json"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
@@ -40,6 +41,11 @@
|
|
|
40
41
|
"dsh": {
|
|
41
42
|
"bundle": {
|
|
42
43
|
"patch": "./cordis.patch.yml"
|
|
44
|
+
},
|
|
45
|
+
"client": {
|
|
46
|
+
"inject": [],
|
|
47
|
+
"platform": "web",
|
|
48
|
+
"immediately": true
|
|
43
49
|
}
|
|
44
50
|
},
|
|
45
51
|
"scripts": {
|