dsh-feishu-auth 0.1.1 → 0.1.3
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 +3 -0
- package/docs/architecture.md +48 -18
- package/docs/release.md +5 -5
- package/lib/gate.js +139 -14
- package/lib/pages.js +49 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -24,6 +24,7 @@ DSH(DeepSeek Harness)Web 界面的飞书 OAuth 登录网关。一个 Cordis
|
|
|
24
24
|
| `lib/pages.js` | 提示页(拒绝 / 错误 / 未就绪 / 已登出),全部内联样式 |
|
|
25
25
|
| `enable.patch.yml` / `disable.patch.yml` | 启用 / 停用 overlay |
|
|
26
26
|
| `test/` | `node --test`,零依赖,替身自建 |
|
|
27
|
+
| `docs/architecture.md` / `docs/release.md` | 内部设计(拦截层身份、两段式交接)与发版流程(staged + trusted publishing) |
|
|
27
28
|
|
|
28
29
|
零运行时依赖:只用 node 内置模块,所以在没 `pnpm install` 过的 profile 里也能直接引用。
|
|
29
30
|
|
|
@@ -110,6 +111,8 @@ feishu-auth[error] 拿不到 harness 的入口地址(connection 服务不可
|
|
|
110
111
|
2. 在本机 `dsh web` 实测:启动自检 + 未登录 302 + 完整交接 200;涉及卸载/重载的改动要额外验「停用 → 401、再启用 → 302」。
|
|
111
112
|
3. 提交并推送 `git push origin main`(仓库 `jianghuifr/dsh-feishu-auth`,带 `dsh-plugin` topic)。
|
|
112
113
|
4. 影响用户可见行为或配置语义的改动,同步更新 [README.md](README.md) 和 [AGENTS.md](AGENTS.md)(本文)以及架构文档中的对应事实。
|
|
114
|
+
5. 发版:`npm version patch` → 推 tag → CI 走 `npm stage publish`(OIDC,无 token)→ 在 npmjs 批准后上线。详见 [docs/release.md](docs/release.md)。
|
|
115
|
+
6. 合并改动 `.github/workflows/` 的 PR 时,执行合并的凭据必须带 `workflow` scope(GitHub 对 OAuth App / PAT 的硬限制,与改动内容无关):用 gh CLI 就先 `gh auth refresh -s workflow` 补授权,或改用网页合并,或本地应用同样改动后直接推 `main`。
|
|
113
116
|
|
|
114
117
|
## 与 dsh 版本的耦合点
|
|
115
118
|
|
package/docs/architecture.md
CHANGED
|
@@ -14,12 +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
|
|
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
|
|
20
26
|
```
|
|
21
27
|
|
|
22
|
-
「需要 harness
|
|
28
|
+
「需要 harness 交接」有两条触发路径:
|
|
29
|
+
|
|
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` 里,见「两段式交接与恢复阶梯」)。
|
|
23
37
|
|
|
24
38
|
## 拦截层
|
|
25
39
|
|
|
@@ -57,31 +71,44 @@ sequenceDiagram
|
|
|
57
71
|
L->>G2: dispose(是最新层 → 还原 original)
|
|
58
72
|
```
|
|
59
73
|
|
|
60
|
-
##
|
|
74
|
+
## 两段式交接与恢复阶梯
|
|
61
75
|
|
|
62
76
|
飞书登录只签发本插件自己的会话 Cookie。harness 另有一层签名 Cookie(`dsh-auth-<authority>`),只在一个**根请求带上本进程启动令牌**时签发。所以「能打开页面」需要两段都完成:
|
|
63
77
|
|
|
64
78
|
1. 网关把浏览器跳到 `connection.authenticatedUrl()` 给出的 `/?token=<launch token>`;
|
|
65
79
|
2. harness 校验令牌、下发 `dsh-auth-*`,再跳回干净的 `/`。
|
|
66
80
|
|
|
67
|
-
`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 标记:
|
|
68
86
|
|
|
69
|
-
|
|
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('/')` 即返回应用页。
|
|
95
|
+
|
|
96
|
+
作废旧凭据(harness 重启)走的是下一步:`/?token=…` 会重新签发一张能验过的 Cookie;这条链从同站重进之后出发,因此也在同站上下文里,新 Cookie 立刻可用。
|
|
97
|
+
|
|
98
|
+
阶梯尽头(用户浏览器连续两次都不交出凭据,例如无痕窗口或拦截扩展)由插件自己的页面收尾并打 warn——**harness 的 401 页任何时候都不会被直接转给用户**,因为那张页面只写着一个对用户毫无意义的内部 URL。
|
|
70
99
|
|
|
71
100
|
```mermaid
|
|
72
101
|
sequenceDiagram
|
|
73
102
|
participant B as 浏览器
|
|
74
103
|
participant G as 网关
|
|
75
104
|
participant H as harness
|
|
76
|
-
B->>
|
|
77
|
-
|
|
78
|
-
B->>G: GET
|
|
79
|
-
G->>
|
|
80
|
-
B
|
|
81
|
-
G->>B:
|
|
82
|
-
B->>
|
|
83
|
-
H->>B: 303 /(+ dsh-auth-* Cookie)
|
|
84
|
-
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)
|
|
85
112
|
G->>H: 放行
|
|
86
113
|
H->>B: 200 应用页
|
|
87
114
|
```
|
|
@@ -94,7 +121,7 @@ sequenceDiagram
|
|
|
94
121
|
| --- | --- | --- |
|
|
95
122
|
| `dsh-feishu-session` | `sessionMaxAgeDays`(默认 14 天) | `kind=session`、`sub`(open_id)、`name`、`tenant`、`iat`、`exp` |
|
|
96
123
|
| `dsh-feishu-state` | 10 分钟 | `kind=state`、`nonce`、`next`、`redirectUri`、`iat`、`exp` |
|
|
97
|
-
| `dsh-feishu-handoff` | 20 秒 |
|
|
124
|
+
| `dsh-feishu-handoff` | 20 秒 | 恢复阶梯步数(`1` / `2`),防往返 |
|
|
98
125
|
|
|
99
126
|
载荷统一是 `v1.<base64url(JSON)>.<base64url(HMAC-SHA256)>`,签名密钥是 `$DSH_HOME/feishu-auth/session-secret`(首次启动生成 32 字节、0600、原子写入;重启不变,所以登录态能跨重启存活)。校验用 `timingSafeEqual`,`state` 用常量时间比较防 CSRF。
|
|
100
127
|
|
|
@@ -107,7 +134,10 @@ sequenceDiagram
|
|
|
107
134
|
| 缺 `appId` / `appSecret` | 故障关闭:所有请求 503「未就绪」页,日志 `[error]` 说明缺什么 |
|
|
108
135
|
| 会话密钥文件不可读写 | 同上(内存里用临时密钥,重启即失效) |
|
|
109
136
|
| `webServer.match` 不存在 | 挂载抛错,插件拒启动——无保护状态不允许运行 |
|
|
110
|
-
| `connection` 服务取不到 | 记 error
|
|
137
|
+
| `connection` 服务取不到 | 记 error;不交接,页面入口的 401 由恢复阶梯兜住(同站重进 → 插件自己的页面) |
|
|
138
|
+
| harness 重启后浏览器仍带旧 `dsh-auth-*` | 响应后判定接管:harness 回 401 → 阶梯(同站重进 → 再交接一次)→ 用户无感恢复 |
|
|
139
|
+
| 浏览器沿跨站链到达(飞书 OAuth 回调) | 同一条阶梯的第一步就是为此设计的:同站重进一次即带上 `SameSite=Strict` 的 `dsh-auth-*` |
|
|
140
|
+
| 浏览器两次都不交凭据(无痕窗口 / 拦截扩展) | 阶梯走完 → 插件自己的「还差一步」页 + 一行 warn,不把 harness 的 401 页转给用户 |
|
|
111
141
|
| 启动自检失败 | `[error]` 明确报出:未登录请求未被拦,或持有效会话仍被拒 |
|
|
112
142
|
| 配置项(`allowedUsers` / `sessionMaxAgeDays`)非法 | `allowedUsers` 非法 → 致命(避免悄悄放宽到全员);`sessionMaxAgeDays` 非法 → 回落默认值 |
|
|
113
143
|
|
package/docs/release.md
CHANGED
|
@@ -19,7 +19,7 @@ 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,45 个用例
|
|
23
23
|
npm run verify # lint + test —— CI 与 prepublishOnly 跑的就是它
|
|
24
24
|
npm pack --dry-run # 检查发布产物内容(15 个文件)
|
|
25
25
|
```
|
|
@@ -48,7 +48,7 @@ git push --follow-tags
|
|
|
48
48
|
随后批准上线,二选一:
|
|
49
49
|
|
|
50
50
|
- 网页:npmjs.com → 你的账号 → **Staged Packages** → 选中版本 → Approve(提示 2FA)
|
|
51
|
-
- CLI:`npm stage list` 拿 stage id → `npm stage approve <stage-id>`(需 2FA
|
|
51
|
+
- CLI:`npm stage list` 拿 stage id → `npm stage approve <stage-id>`(需 2FA;npm 会要求到 `https://www.npmjs.com/auth/cli/…` 做一次浏览器认证,链接一次性且约几分钟过期,过期就重跑命令拿新链接)
|
|
52
52
|
|
|
53
53
|
批准前可以验货:`npm stage download <stage-id>` 把 tarball 拉下来看,`npm stage reject <stage-id>` 丢弃。
|
|
54
54
|
|
|
@@ -67,17 +67,17 @@ git push --follow-tags
|
|
|
67
67
|
`stage` 不支持全新包,所以建包这一次必须手工做,之后一律走上面的 staged 流程。
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
|
|
70
|
+
# 在你自己的仓库副本里执行
|
|
71
71
|
npm login --registry https://registry.npmjs.org
|
|
72
72
|
npm publish --access public --registry https://registry.npmjs.org
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
-
|
|
75
|
+
- 若你的 npm 默认源是镜像(国内常见配置),必须显式带 `--registry https://registry.npmjs.org`,否则会发到镜像上。
|
|
76
76
|
- 手工发布的 0.1.0 不带 provenance(provenance 需要 CI 的 OIDC);从 0.1.1 起走流水线自动带。
|
|
77
77
|
|
|
78
78
|
## 版本号与 npm CLI
|
|
79
79
|
|
|
80
|
-
`npm stage` 需要 npm CLI ≥ 11.15
|
|
80
|
+
`npm stage` 需要 npm CLI ≥ 11.15:本地版本不够时用 `npx npm@latest stage ...`。`ci.yml` 与 `release.yml` 里都显式 `npm install -g npm@latest`,不受 runner 自带版本影响。
|
|
81
81
|
|
|
82
82
|
包名 `dsh-feishu-auth` 已发布(`0.1.0`,2026-09-13;此前查官方 registry 为 404 即未占用)。此后发版一律走 staged 流程,账号需保持 2FA 开启。
|
|
83
83
|
|
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;
|
|
@@ -177,6 +175,40 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
|
|
|
177
175
|
return names.some((name) => name.startsWith('dsh-auth-')) !== true;
|
|
178
176
|
}
|
|
179
177
|
|
|
178
|
+
/**
|
|
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
|
+
*
|
|
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.
|
|
186
|
+
*/
|
|
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) {
|
|
196
|
+
redirect(res, 303, target, {
|
|
197
|
+
'set-cookie': serializeCookie(HANDOFF_COOKIE, String(step), {
|
|
198
|
+
maxAgeSeconds: HANDOFF_TTL_SECONDS,
|
|
199
|
+
secure: isSecure(requestBaseUrl(req)),
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
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
|
+
|
|
180
212
|
/** Deliver the request to the harness exactly as it would have been delivered. */
|
|
181
213
|
async function passthrough(req, res, route, server) {
|
|
182
214
|
const target = typeof route?.handler === 'function' ? route.handler : server.fallback;
|
|
@@ -188,6 +220,97 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
|
|
|
188
220
|
await target(req, res);
|
|
189
221
|
}
|
|
190
222
|
|
|
223
|
+
/** Whether this request is the harness's own page entry point. */
|
|
224
|
+
function isPageNavigation(req, url) {
|
|
225
|
+
if (url.pathname !== '/') return false;
|
|
226
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
|
227
|
+
return isNavigationRequest(req) === true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Deliver a page navigation to the harness while watching for its auth wall.
|
|
232
|
+
*
|
|
233
|
+
* {@link needsHarnessHandoff} can only observe that a `dsh-auth-*` cookie
|
|
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.
|
|
243
|
+
*
|
|
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.
|
|
252
|
+
*/
|
|
253
|
+
async function passthroughWatchingForAuthWall(req, res, route, server, requested) {
|
|
254
|
+
const target = typeof route?.handler === 'function' ? route.handler : server.fallback;
|
|
255
|
+
if (typeof target !== 'function') {
|
|
256
|
+
res.writeHead(404);
|
|
257
|
+
res.end();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const entryTarget = entryLocation(req, requested);
|
|
261
|
+
const step = recoveryStep(req);
|
|
262
|
+
|
|
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.
|
|
265
|
+
const originalWriteHead = res.writeHead;
|
|
266
|
+
const originalWrite = res.write;
|
|
267
|
+
const originalEnd = res.end;
|
|
268
|
+
let walled = false;
|
|
269
|
+
const restore = () => {
|
|
270
|
+
res.writeHead = originalWriteHead;
|
|
271
|
+
res.write = originalWrite;
|
|
272
|
+
res.end = originalEnd;
|
|
273
|
+
};
|
|
274
|
+
res.writeHead = function (status, ...rest) {
|
|
275
|
+
if (status === 401) {
|
|
276
|
+
walled = true;
|
|
277
|
+
return this;
|
|
278
|
+
}
|
|
279
|
+
return originalWriteHead.apply(this, [status, ...rest]);
|
|
280
|
+
};
|
|
281
|
+
res.write = function (...args) {
|
|
282
|
+
return walled === true ? true : originalWrite.apply(this, args);
|
|
283
|
+
};
|
|
284
|
+
res.end = function (...args) {
|
|
285
|
+
return walled === true ? this : originalEnd.apply(this, args);
|
|
286
|
+
};
|
|
287
|
+
try {
|
|
288
|
+
await target(req, res);
|
|
289
|
+
} finally {
|
|
290
|
+
restore();
|
|
291
|
+
}
|
|
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
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
191
314
|
/** Refuse an unauthenticated request: redirect browsers, 401 everything else. */
|
|
192
315
|
function deny(req, res, url, principal) {
|
|
193
316
|
if (mode === 'misconfigured') {
|
|
@@ -417,22 +540,24 @@ export function createGate({ config, secret, fatalProblems = [], entryUrl, fetch
|
|
|
417
540
|
deny(req, res, url, principal);
|
|
418
541
|
return;
|
|
419
542
|
}
|
|
543
|
+
const requested = `${url.pathname}${url.search}`;
|
|
420
544
|
if (needsHarnessHandoff(req, url)) {
|
|
421
|
-
const requested = `${url.pathname}${url.search}`;
|
|
422
545
|
const target = entryLocation(req, requested);
|
|
423
|
-
// A resolver that is missing or throws degrades to the requested path
|
|
424
|
-
//
|
|
425
|
-
// 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.
|
|
426
548
|
if (target !== requested) {
|
|
427
|
-
|
|
428
|
-
'set-cookie': serializeCookie(HANDOFF_COOKIE, '1', {
|
|
429
|
-
maxAgeSeconds: HANDOFF_TTL_SECONDS,
|
|
430
|
-
secure: isSecure(requestBaseUrl(req)),
|
|
431
|
-
}),
|
|
432
|
-
});
|
|
549
|
+
handoffToHarness(req, res, target, 0);
|
|
433
550
|
return;
|
|
434
551
|
}
|
|
435
552
|
}
|
|
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.
|
|
557
|
+
if (isPageNavigation(req, url)) {
|
|
558
|
+
await passthroughWatchingForAuthWall(req, res, route, server, requested);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
436
561
|
await passthrough(req, res, route, server);
|
|
437
562
|
}
|
|
438
563
|
|
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.
|