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 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
 
@@ -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/>+ 20s 交接标记"]
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 交接」的判定:`GET/HEAD` 导航请求、路径是 `/`、URL 上没有 `token` 参数、请求里没有 `dsh-auth-` 开头的 Cookie,且没有交接标记(见下)。
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,并在交接判定里退化为直接放行给 harness(让它自己的 401 页成为终点,避免无休止往返)。
81
+ `connection` 服务**只能**经 `ctx.inject(['connection'], cb)` 取(`ctx.get` 返回 undefined,属性访问直接抛错),所以在插件挂载时捕获成 `entryUrlProvider`,每次请求时调用。取不到时打一行 error,交接判定退化为「不交接」。
82
+
83
+ ### 阶梯
84
+
85
+ **`dsh-feishu-handoff`**(20 秒)记的是这台浏览器已经花掉的恢复步数,而不是一个 0/1 标记:
68
86
 
69
- **交接标记**(`dsh-feishu-handoff`,20 秒)是死循环的兜底:浏览器拒绝存 harness Cookie 时,`/` `/?token=…` 之间只会来回一次。
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->>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-*)
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,交接退化为放行给 harness 401 |
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,39 个用例
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
- cd ~/.dsh/plugins/dsh-feishu-auth
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
- - 必须显式指定 registry:本机 npm 默认源是镜像站,不加会发到镜像。
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,本机是 11.6.2,所以本机要用 `npx npm@latest stage ...`;`ci.yml` 与 `release.yml` 里都显式 `npm install -g npm@latest`,不受 runner 自带版本影响。
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
- * handoff marker bounds the attempt when a browser refuses to store the
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
- // Redirecting to it would repeat this exact state forever, so fall
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
- redirect(res, 303, target, {
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-feishu-auth",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Feishu (Lark) OAuth login gate for the DeepSeek Harness web GUI.",
5
5
  "keywords": [
6
6
  "dsh",