dsh-webui-oauth 0.4.3 → 0.5.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/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # 变更记录
2
+
3
+ 本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
+
5
+ ## 0.5.0
6
+
7
+ 安全加固与正确性修复。**建议所有用户升级**,其中「原版共存」与「算法绑定」两项在特定部署下会导致严重问题。
8
+
9
+ ### 破坏性:与原版 `dsh-webui-auth` 双装将拒绝启动
10
+
11
+ - 本插件**不再**在运行时接管原版。两者抢占同一批 `webServer` 路由,运行时无法安全共存:
12
+ 实测三种做法(dispose fiber / disable 条目 / 放任共存)分别导致**进程启动失败**或
13
+ **本插件静默失效(端点全 404)**。
14
+ - 现在检测到原版仍启用时,插件**直接拒绝启动**(dsh 退出码 1)并打印修复指令,而不是降级运行——
15
+ 避免"以为上了锁、实际没生效"这种最难排查的形态。
16
+ - **升级动作**:把原版从 profile 的 `dsh.profile.bundles` 与 `dependencies` 中移除,
17
+ 或在其补丁层写 `disabled: true`。详见 README「与原版的关系」。
18
+ - 注意:0.4.x 曾用「自动停用原版」实现接管,该机制已被证明不可行并**完全移除**。
19
+
20
+ ### 安全:id_token 验签算法绑定
21
+
22
+ - **修复算法混淆(中危)**:验签算法此前取自 `(jwk.alg || header.alg)`,而 IdP 几乎都会在
23
+ JWKS 写 `alg`,等价于让 `jwk.alg` 覆盖 `header.alg`。后果是用 P-256 密钥真实签名后,
24
+ 仅把头部改成 `ES384`/`ES512` 就能通过验证("自称 ES512、实为 P-256")。
25
+ 现以 `header.alg` 为唯一真源,与 `jwk.alg` 冲突即拒。
26
+ - 算法名改为**显式白名单**(原 `startsWith` 前缀匹配会让 `ES999`/`RS999` 命中对应 kty 的 key)。
27
+ - 校验 JWKS 的 `use` / `key_ops`,加密专用 key 不再被选中验签。
28
+
29
+ ### 修复:client bundle 注册 id 不匹配
30
+
31
+ - `lib/client.js` 以 `id: 'dsh-webui-auth'` 注册,而 bundle 注入的行名是 `dsh-webui-oauth`,
32
+ 导致设置页插件面板报 `loaded without registering "dsh-webui-oauth" via __ModuleLoader__.load`。
33
+ 现已对齐。新增 `test/client-bundle-id.test.mjs` 防回归。
34
+
35
+ ### 修复:bundle 说明过期
36
+
37
+ - `cordis.patch.yml` 的注释仍在描述已被移除的"自动顶掉原版"行为,现更正为 fail-fast 语义。
38
+
39
+ ### 测试
40
+
41
+ - 新增 `test/oidc-ec.test.mjs`(31 项):ES256/384/512 真实签名验签、alg 绑定、算法名白名单、
42
+ `use`/`key_ops`、算法混淆。其中 alg 绑定与前缀匹配的用例已确认能在修复前版本上失败。
43
+ - 新增 `test/client-bundle-id.test.mjs`(5 项)。
44
+ - 全量 274 项断言通过。
45
+
46
+ ### 文档
47
+
48
+ - 新增「签名算法支持」章节(EC 实测证据)与「全流程实测结果」章节(本地账号 / OIDC / 反代改写 Host 三种场景)。
49
+ - 修正一处不实注释:原称"ES256 因曲线小偶尔误中",实测 DER 模式验 raw 签名 300 次零误中。
50
+
51
+ ## 0.4.x 及更早
52
+
53
+ 早期版本的变更(OIDC SSO 引入、数据目录迁移、跳转 scheme 修正等)见文末「相关」与 git 历史。
package/README.en.md CHANGED
@@ -4,19 +4,69 @@ English | [中文](README.md)
4
4
 
5
5
  A persistent WebUI authentication plugin for DeepSeek Harness. Once you create an account/password in **Settings → 身份认证 (Authentication)** or via the first-run login page, **unauthenticated browsers cannot load any WebUI resource, call any API, or open any realtime connection** — authentication is enforced at the HTTP/transport layer and cannot be bypassed through browser devtools. Both local password accounts and **OIDC/SSO single sign-on** are supported.
6
6
 
7
- > **This plugin is an enhanced replacement for [Yuuz12/dsh-webui-auth](https://github.com/Yuuz12/dsh-webui-auth)** (adding OIDC SSO and more). If both are installed, **this plugin disables the original at startup** (see "Relationship to the original"), so you never end up with two gates; credentials and the data directory keep the original location, so **existing accounts and sessions keep working**.
7
+ > **This plugin is an enhanced replacement for [Yuuz12/dsh-webui-auth](https://github.com/Yuuz12/dsh-webui-auth)** (adding OIDC SSO and more). **After installing it you must remove the original from your profile's bundle list** (see "Relationship to the original") — running both at once makes them fight over the same routes, and no coexistence mode works; on detecting that conflict this plugin **refuses to boot** (dsh exits with code 1 and prints the fix) rather than degrading. Credentials and the data directory keep the original location, so **existing accounts and sessions keep working**.
8
+
9
+ ## Relationship to the original (pick one — never enable both)
10
+
11
+ The original `dsh-webui-auth` and this plugin both claim **the same set of routes** through `ctx.webServer.register()` (the `""`, `/api` and `/plugins` prefixes plus the `/api/remote.mux` upgrade route). The webServer route table is **first-come-first-served**: registering the same `(kind, path)` twice throws `duplicate ... route`, and route entries **carry no owner information** (only `{kind, path, handler}`), so there is no way to tell afterwards who registered a given route.
12
+
13
+ **Runtime displacement is therefore architecturally impossible.** This plugin used to try two variants; both were falsified on an isolated instance:
14
+
15
+ | Attempted approach | Measured result |
16
+ | --- | --- |
17
+ | Runtime `dispose` of the original's fibers | The original's `apply` hits `INACTIVE_EFFECT`; **the whole dsh process fails to start** |
18
+ | Runtime `disable` of the original's entry | The original's routes are already registered, so this plugin's registration hits `duplicate prefix route`; **the process fails to start** |
19
+ | Do nothing, let both coexist | The process starts, but **only the original takes effect** — every endpoint of this plugin 404s (silent failure, the hardest kind to diagnose) |
20
+ | **Remove the original at config level** (recommended) | Starts normally; this plugin is fully in effect |
21
+ | **Install only this plugin** (recommended) | Starts normally; this plugin is fully in effect |
22
+
23
+ The first two are load-order independent (both orders reproduce): whoever registers first wins, and the latecomer throws and is rejected by the boot audit.
24
+
25
+ **The right fix**: after installing this plugin, drop the original from the profile's dependencies and bundle list. Edit `$DSH_HOME/profiles/<name>/package.json`:
26
+
27
+ ```jsonc
28
+ {
29
+ "dependencies": {
30
+ // remove this line:
31
+ // "dsh-webui-auth": "^0.3.5",
32
+ "dsh-webui-oauth": "^0.4.4"
33
+ },
34
+ "dsh": {
35
+ "profile": {
36
+ "bundles": [
37
+ "@deepseek-ai/dsh-base",
38
+ "@deepseek-ai/dsh-web-app",
39
+ // remove this line:
40
+ // "dsh-webui-auth",
41
+ "dsh-webui-oauth"
42
+ ]
43
+ }
44
+ }
45
+ }
46
+ ```
8
47
 
9
- ## Relationship to the original (auto-displacement)
48
+ To keep the dependency but merely mask the original, disable its entry by id in the profile's `cordis.patch.yml`:
10
49
 
11
- The original `dsh-webui-auth` and this plugin both enforce authentication by wrapping `webServer` routes at runtime. If both gates stay alive, every request is checked twice against two independent session stores, and the two logins do not recognise each other (logging in through one gate still leaves the other returning 302/401) — the visible symptom is a login page that keeps redirecting.
50
+ ```yaml
51
+ - id: dsh-webui-auth
52
+ disabled: true
53
+ ```
12
54
 
13
- So at startup (before installing its own gate) this plugin walks the cordis plugin registry, finds any loaded original by plugin name, and **disposes all of its fibers**. The original is itself reversible: disposing it undoes its route wrapping and endpoint registration, after which this plugin owns the whole gate. The host log shows:
55
+ **A double install fails the boot outright (fail-fast).** If the original is still enabled, this plugin **throws at the very start of `apply`**, so dsh exits with code 1 and prints a single readable fix instruction to stderr:
14
56
 
15
57
  ```
16
- [dsh-webui-oauth] displaced original plugin(s): dsh-webui-auth — this plugin takes over the auth gate; credentials/data directory are shared.
58
+ [dsh-webui-oauth] 检测到原版 dsh-webui-auth 同时启用,拒绝启动。两者抢占同一批路由(webServer 路由表先到先得),
59
+ 实测无法共存:本插件的登录页与闸门会完全失效(端点 404),或直接导致启动失败。
60
+ 修复:从 profile 的 dsh.profile.bundles 与 dependencies 中移除 dsh-webui-auth,
61
+ 或在其补丁层写 disabled: true,然后重启。
17
62
  ```
18
63
 
19
- - **Matching is by plugin name only** — no other plugin is touched; if the original is absent, startup continues silently.
64
+ **Why refuse to boot instead of degrading**: measurements show there is no usable coexistence mode (see the last two rows of the table above). "The process starts but gate ownership is undefined" is the most dangerous shape of all — the operator believes the door is locked while every endpoint of this plugin 404s. Rather than serve traffic in a half-broken state, this plugin blocks the problem at startup and states the fix.
65
+
66
+ (Cordis's default logger only fills an in-memory buffer and never reaches stdout/stderr, hence the explicit stderr write — that line is the only readable clue for the failed boot, so `docker logs` / `journalctl` must show it.)
67
+
68
+ - **Matching is by plugin name only** — no other plugin is touched; when the original is absent this plugin stays completely silent.
69
+ - **It never mutates anyone else's lifecycle**: this plugin does not `dispose` or `disable` any sibling entry or fiber.
20
70
  - **Data carries over seamlessly**: the data directory (`.dsh-webui-auth/`) and credentials file (`dsh-webui-auth.json`) **deliberately keep their original names**, so accounts created by the original work without migration.
21
71
  - **Adopting the original's data directory**: when this plugin has no credentials of its own yet, it looks for an original data directory that already holds credentials and **reuses it directly** (not copies — copying would produce two credential sets that diverge as soon as one side changes the password):
22
72
  1. the sibling directory `../dsh-webui-auth/` (the usual case for source / `link:` installs);
@@ -51,7 +101,7 @@ Authentication is enforced in four layers, all implemented by **wrapping the web
51
101
  - `trustBrowserOrigin` defaults to `true` when omitted (legacy behavior); an explicit `false` makes both sites **ignore** the browser-influenced origin and use `publicBaseUrl`. With no `publicBaseUrl` either, the token redirect falls back to the request Host / loopback, and OIDC fails closed with an error.
52
102
  - The old `oidc.trustBrowserOrigin` is still accepted: when the settings section does not set the switch explicitly, it is used as the fallback, so existing deployments migrate smoothly.
53
103
  - **Host-rewriting reverse proxies**: when the proxy rewrites `Host` to a loopback address (`127.0.0.1:3080`, `::1`, `localhost`, `0.0.0.0`) and sends nothing else, the plugin **cannot** recover the browser-side address from the request, so it treats the address as proxy-rewritten and uses the configured `publicBaseUrl` directly (the legacy "declare the public address in settings.yaml" advice was ineffective in that shape, because `publicBaseUrl` was only trusted on an exact Host match). If the proxy can send the original host, `X-Forwarded-Host` (or RFC 7239 `Forwarded: host=`) is preferred over the request Host for building the redirect.
54
- - **Optional OIDC SSO (0.4.0)**: standards-based authorization-code + PKCE with a **confidential client (client_secret required)**, security-benchmarked against Logto. Once configured, the login page shows an "SSO single sign-on" button; after IdP authentication the plugin creates a local session keyed by the IdP `sub` (reusing the session TTL and persistence). The **redirect_uri base is set by `oidc.redirectBase`**; by default the browser is trusted (it trusts the front-end `location.origin` — under a host-rewriting reverse proxy the browser's public address is correct), ****constrained by the unifed switch above****; when disabled only the configured `redirectBase` is used; if neither is available it fails closed with an error. Supports RSA/EC/EdDSA-signed id_token validation (JWKS + iss/aud/exp/iat/nbf/nonce checks) and simple local logout (clears the local session only, no IdP SLO).
104
+ - **Optional OIDC SSO (0.4.0)**: standards-based authorization-code + PKCE with a **confidential client (client_secret required)**, security-benchmarked against Logto. Once configured, the login page shows an "SSO single sign-on" button; after IdP authentication the plugin creates a local session keyed by the IdP `sub` (reusing the session TTL and persistence). The **redirect_uri base is set by `oidc.redirectBase`**; by default the browser is trusted (it trusts the front-end `location.origin` — under a host-rewriting reverse proxy the browser's public address is correct), ****constrained by the unifed switch above****; when disabled only the configured `redirectBase` is used; if neither is available it fails closed with an error. id_token signatures support **RSA (RS256/384/512, PS256/384/512), EC (ES256/384/512) and EdDSA (Ed25519)**, with iss/aud/exp/iat/nbf/nonce and JWKS(kid) validation (see "Signature algorithm support" below). Logout is the simple variant: it clears the local session only.
55
105
  - **Scheme-adaptive post-login redirect (0.3.3, fixes #6 / #7)**: after a successful login the plugin sends the browser to the core's token-bearing root URL. Its **authority now comes from the request Host** (no longer hardcoded to `127.0.0.1`) and its **scheme follows the protocol the request actually used**, resolved in this order: ① the operator's explicit `remote-web-ui.publicBaseUrl` (**trusted only when its host/port matches the incoming Host** — otherwise a LAN direct hit would be redirected to the public address and lose the freshly issued session cookie to a cross-origin hop); ② the standard proxies headers `X-Forwarded-Proto` (leftmost value) or RFC 7239 `Forwarded: proto=`; ③ a TLS-terminating socket (`socket.encrypted`); ④ fallback `http`. It deliberately does **not** guess "non-IP hostname ⇒ https", which would turn plain-http intranet access (`http://nas.local:3080`) into a dead https link. The login page adds a one-way client-side fallback: an https page receiving a **same-origin** `http://` redirect upgrades it to `https://` (never downgrades, never rewrites another origin).
56
106
 
57
107
  Sessions are **server-side and persisted to disk** (`sessions.jsonl`, survive a DSH restart, expire server-side), carried by an `HttpOnly; SameSite=Lax` cookie (`dsh_wua_session`) that JS cannot read; changing the password **revokes every other session**.
@@ -185,12 +235,115 @@ Both the login page and the "Settings → 身份认证 (Authentication)" setting
185
235
  - The login/setup endpoints are intentionally public (the entry point of authentication): `/dsh-webui-oauth/login` and `/dsh-webui-oauth/setup` (the latter protected by the setup token).
186
236
  - **OIDC SSO**: authorization-code + PKCE + client_secret (confidential client, no public-client PKCE); state guards CSRF, nonce guards replay, redirect_uri is exact-matched (base + fixed path), id_token is JWKS-verified with iss/aud/exp/iat/nbf/nonce checks, and only HTTPS endpoints are accepted. Only the `sanitizeSub`-filtered `sub` is recorded in the audit log. The OIDC client config (including the secret) lives in `dsh-webui-oauth.json` in the data directory, kept apart from the account credentials.
187
237
 
238
+ ## Signature algorithm support (id_token verification)
239
+
240
+ | Family | Algorithms | JWKS `kty` | Status |
241
+ | --- | --- | --- | --- |
242
+ | RSA | RS256 / RS384 / RS512 | `RSA` | ✅ |
243
+ | RSA-PSS | PS256 / PS384 / PS512 | `RSA` | ✅ |
244
+ | **ECDSA** | **ES256** (P-256) / **ES384** (P-384) / **ES512** (P-521) | `EC` | ✅ |
245
+ | EdDSA | Ed25519 | `OKP` | ✅ |
246
+
247
+ **EC support is fully working, not merely documented.** All three curves are verified with real signatures:
248
+
249
+ - **Unit level** (`test/oidc-ec.test.mjs`, 16 assertions): each curve generates a native key and signs for real → verification passes; a tampered payload or a foreign-key forgery → rejected; JWKS lookup by `kid` selects the EC key; and it still works when the JWKS **omits `alg`** (some IdPs do).
250
+ - **End to end**: a purpose-built IdP signed id_tokens with ES256 / ES384 / ES512 respectively, each driven through the full authorization-code + PKCE flow — all three logged in successfully (audit `oidc_login_success`).
251
+
252
+ **Implementation note**: an ECDSA JWT signature is JOSE raw `R||S` (ieee-p1363), whereas Node's `crypto.verify` **parses DER by default**. Without an explicit `dsaEncoding: 'ieee-p1363'`, ES384/ES512 simply fail to verify, while ES256's shorter curve means the occasional mis-parse can appear to pass — an extremely hard bug to chase. This plugin handles that path explicitly and covers it with regression tests.
253
+
254
+ **Algorithm-confusion protection (hardened in 0.5.0)**:
255
+
256
+ 1. **The verification algorithm comes only from the token header; the JWKS `alg` is merely a consistency constraint.** The earlier code read `(jwk.alg || header.alg)`, and since virtually every IdP writes `alg` in its JWKS, that let `jwk.alg` override `header.alg` and made the header field inert. Measured consequence: a token genuinely signed with a P-256 key still verified after relabelling the header `ES384`/`ES512` — "claims ES512, is actually P-256". It now takes `header.alg` as the single source of truth and rejects any conflict with `jwk.alg`.
257
+ 2. **Algorithm names go through an explicit allowlist** (no more `startsWith` prefix matching). Previously fabricated names like `ES999`/`RS999` could select a key of the matching `kty` — blocked today by an upper-layer allowlist, but that meant resting security on "another function happens to catch it".
258
+ 3. **`kty` is still checked after a `kid` hit**, a mismatch is rejected, and there is **no fallback to "pick any key"** (a named `kid` with no match returns `null` → `no-signing-key`).
259
+ 4. **JWKS `use` / `key_ops` are validated**: an encryption-only key (`use=enc` or `key_ops=[encrypt]`) is never selected for verification.
260
+ 5. **`alg=none` and `HS*` are rejected** (including a real forgery attempt that uses a public key as the HMAC secret).
261
+
262
+ Each item has regression tests; the cases for the first two are confirmed to fail reliably (7 FAILs) against the pre-fix build, so they are not vacuous assertions.
263
+
264
+ **Choosing an algorithm**: IdPs default to RS256 most of the time; if yours supports EC, ES256 produces shorter signatures and verifies faster, which suits mobile clients and high-throughput deployments.
265
+
266
+ ## End-to-end test results (isolated instance)
267
+
268
+ Measured on an isolated dsh instance (its own `$DSH_HOME` and profile, fully separate from the production image) running this repository's plugin, against a **purpose-built minimal OIDC provider** (discovery / jwks / authorize / token, RS256 signing with PKCE S256 verification).
269
+
270
+ ### A. Local account flow
271
+
272
+ | Step | Request | Measured result |
273
+ | --- | --- | --- |
274
+ | Unauthenticated `/` | GET | **302 → /dsh-webui-oauth/login** (gate active) |
275
+ | Unauthenticated `/api` | GET | **401** |
276
+ | Login page | GET `/dsh-webui-oauth/login` | **200** (the only public page) |
277
+ | Wrong setup token | POST `/setup` | `{"ok":false,"error":"setup-token-required"}` |
278
+ | Weak password | POST `/setup` | `{"ok":false,"error":"weak-password","reason":"length"}` |
279
+ | Correct setup token | POST `/setup` | `{"ok":true}` + `dsh_wua_session` cookie (HttpOnly; SameSite=Lax; Max-Age=43200) |
280
+ | Repeat setup | POST `/setup` | `{"ok":false,"error":"already-configured"}` |
281
+ | Wrong password | POST `/login` | `{"ok":false,"error":"invalid"}` |
282
+ | Correct password | POST `/login` | `{"ok":true}` + session cookie |
283
+
284
+ **The two-cookie handoff (important)**: with a plugin session, visiting `/` returns a 302 to the core's launch-token URL (`/?token=…`) so the browser completes the core's own `dsh-auth-*` cookie exchange. Measured:
285
+
286
+ - `/api` with only the plugin cookie → **401** (the core has not claimed this browser yet);
287
+ - `/api` after the token exchange → **404** (authenticated; the path simply does not exist).
288
+
289
+ The plugin gate and the core's BrowserAuth are **two independent doors** — both are required. This is by design, not a fault.
290
+
291
+ ### B. OIDC / SSO flow
292
+
293
+ Run end-to-end against a minimal IdP (`https://127.0.0.1:14443`, self-signed CA):
294
+
295
+ 1. `GET /dsh-webui-oauth/oidc/login?base=<browser origin>` → **302** to the IdP `/authorize`, carrying `code_challenge` + `code_challenge_method=S256`, and setting the `dsh_wua_oidc_state` cookie (HttpOnly, 600s);
296
+ 2. the IdP validates client_id / response_type / PKCE, then **302**s back to `redirect_uri?code=…&state=…`;
297
+ 3. `GET /dsh-webui-oauth/oidc/callback` → the plugin exchanges the code with its `code_verifier`, verifies the id_token signature → **200** plus a session cookie, clearing the state cookie;
298
+ 4. the audit log records `oidc_login_success` with the IdP `sub` (measured: `lab-user-001`).
299
+
300
+ The IdP log confirms the full round trip: `discovery → jwks → authorize (issued code) → token (issued id_token)`.
301
+
302
+ Security properties verified (all correctly rejected):
303
+
304
+ | Attack | Measured |
305
+ | --- | --- |
306
+ | Mismatched state | `{"ok":false,"error":"oidc-invalid-state"}` |
307
+ | Missing state cookie (login CSRF / session fixation) | `{"ok":false,"error":"oidc-invalid-state"}` |
308
+ | Authorization-code replay | `{"ok":false,"error":"oidc-invalid-state"}` |
309
+
310
+ ### C. Redirects when a reverse proxy rewrites Host (the important part)
311
+
312
+ Preconditions: **no** `remote-web-ui.publicBaseUrl` configured, `trustBrowserOrigin` at its default `true`. Assume the browser really talks to `http://127.0.0.1:14080` while the proxy rewrites Host to the loopback `127.0.0.1:13081` (Caddy's default behaviour).
313
+
314
+ | Scenario | Redirect the plugin emits | Correct? |
315
+ | --- | --- | --- |
316
+ | 1. Direct (Host=13081) | `http://127.0.0.1:13081/?token=…` | ✅ |
317
+ | 2. Host rewritten **+ X-Forwarded-Host=14080** | `http://127.0.0.1:14080/?token=…` | ✅ |
318
+ | 3. Host rewritten + XFH=public.example:8443 + X-Forwarded-Proto=https | `https://dsh.example.com:8443/?token=…` | ✅ |
319
+ | 4. Host rewritten **and no XFH** | `http://127.0.0.1:13081/?token=…` | ❌ points at the internal port |
320
+
321
+ **Conclusion**: when a proxy rewrites Host and does **not** forward the original host header, no correct redirect can be derived without a configured `publicBaseUrl` — the server has no browser-side address at all, and guessing would amount to an open redirect. The browser is sent to `127.0.0.1:13081`; if that port is internal/unexposed the symptom is "login succeeds but the page will not open".
322
+
323
+ **Two fixes, pick either**:
324
+
325
+ - Have the proxy forward the original host header (recommended, least configuration):
326
+ ```nginx
327
+ proxy_set_header X-Forwarded-Host $host; # or $http_host to include the port
328
+ proxy_set_header X-Forwarded-Proto $scheme;
329
+ ```
330
+ Caddy forwards `X-Forwarded-Host` by default; if you preserve Host instead (e.g. a `DSH_PRESERVE_HOST`-style switch), the case collapses into "direct" and is equally correct.
331
+ - Or declare the public address explicitly in `settings.yaml`:
332
+ ```yaml
333
+ remote-web-ui:
334
+ publicBaseUrl: 'https://dsh.example.com:8443'
335
+ ```
336
+ Note that `publicBaseUrl` is **only honoured when its authority matches the request Host** (to prevent an open redirect). When Host has already been rewritten to loopback the two do not match, so **`publicBaseUrl` cannot rescue this case either** — you must use the first option. This is an easy misconfiguration, hence documenting it here.
337
+
338
+ You also need `--trusted-host <public host:port>`, otherwise a desktop browser hitting `/api` directly gets 403 (see "Known limits").
339
+
188
340
  ## Known limits
189
341
 
190
342
  - **Inherent runtime-wrapping window**: between a route-object replacement (service hot-reload) and the next rescan (≤10s) there is an unprotected window; the fail-closed check on enabling covers the "initially exposed" case, so this window only affects hot-reload during runtime.
191
343
  - **WebSocket and `trustedHosts`**: in reverse-proxy / LAN deployments (non-loopback Host), WS downlinks need the public hostname added to `client-connection.trustedHosts` in the DSH config (see "Architecture").
192
344
  - **Proxy on a different host**: if the reverse proxy is not on the same machine as DSH (non-loopback peer), the proxy headers are not trusted and rate limiting aggregates per proxy IP (degrades to a global bucket).
193
345
  - **HTTPS proxy that sends no protocol header**: the post-login redirect scheme depends on ① `remote-web-ui.publicBaseUrl` or ② an `X-Forwarded-Proto` / `Forwarded` header from the proxy. With neither, it can only fall back to `http` (the browser then handshakes against the TLS port and the page looks frozen after clicking Login). Pick one: declare the public address in `settings.yaml`, or make the proxy send `proxy_set_header X-Forwarded-Proto $scheme;`. Note `publicBaseUrl` only applies when it matches the request Host, which is what keeps LAN direct access from being rewritten to the public address.
346
+ - **Proxy rewrites Host without forwarding the original host header**: the server then has no browser-side address at all, so without a configured `publicBaseUrl` no correct redirect is possible (it points at the internal upstream port); and `publicBaseUrl` does not apply either, because its authority no longer matches. **The only workable fix is to have the proxy send `X-Forwarded-Host`** (Caddy does by default) or to preserve the original Host. See the measured table in section C of "End-to-end test results".
194
347
  - **`--trusted-host` is not optional**: after desktop password login, requests that hit `/api` directly depend on `--trusted-host <public-host:port>`; the remote-web-ui pairing flow (`/remote` channel) does not. Dropping the flag makes every `/api` call return 403.
195
348
  - **Limits of audit pseudonymization**: the HMAC key lives in the same data directory (0600); a local attacker who can read it can brute-force the IP space — pseudonymization protects against "plaintext IPs at rest", not against an attacker with file access.
196
349
  - Sessions live in `sessions.jsonl`: they survive restarts (expiry unchanged); uninstalling/disabling the plugin does not affect credentials.
package/README.md CHANGED
@@ -4,19 +4,69 @@
4
4
 
5
5
  DSH WebUI 身份认证插件(持久化插件)。在「设置 → 身份认证」或首次访问登录页创建账号密码后,**未认证的浏览器无法加载 WebUI 的任何资源、调用任何接口或建立任何实时连接**——认证在 HTTP/传输层强制执行,不可通过浏览器开发者工具绕过。支持本地账号密码与 **OIDC/SSO 单点登录**两种方式。
6
6
 
7
- > **本插件是 [Yuuz12/dsh-webui-auth](https://github.com/Yuuz12/dsh-webui-auth) 的增强替代版**(新增 OIDC SSO 等)。若两者同时安装,**本插件启动时会主动停用原版**(见「与原版的关系」),不会出现双闸门;凭据与数据目录沿用原版位置,**原有账号与登录会话继续有效**。
7
+ > **本插件是 [Yuuz12/dsh-webui-auth](https://github.com/Yuuz12/dsh-webui-auth) 的增强替代版**(新增 OIDC SSO 等)。**安装本插件后必须从 profile 的 bundle 列表里移除原版**(见「与原版的关系」)——两者同时启用会抢占同一批路由,实测无法共存;本插件检测到这种冲突会**直接拒绝启动**(dsh 以退出码 1 结束并打印修复指令),而不是降级运行。凭据与数据目录沿用原版位置,**原有账号与登录会话继续有效**。
8
+
9
+ ## 与原版的关系(必须二选一,不能同时启用)
10
+
11
+ 原版 `dsh-webui-auth` 与本插件都用 `ctx.webServer.register()` 抢占**同一批路由**(前缀 `""`、`/api`、`/plugins` 与 `/api/remote.mux` 升级路由)。webServer 的路由表是**先到先得**:对同一 `(kind, path)` 重复注册会直接抛 `duplicate ... route`,而且路由条目里**不含属主信息**(只存 `{kind, path, handler}`),事后无法判断某条路由是谁注册的。
12
+
13
+ **因此运行时接管在架构上不可行**——本插件曾经尝试过两种方式,都被隔离实例证伪:
14
+
15
+ | 尝试的做法 | 实测结果 |
16
+ | --- | --- |
17
+ | 运行时 `dispose` 原版 fiber | 原版 `apply` 撞 `INACTIVE_EFFECT`,**整个 dsh 进程启动失败** |
18
+ | 运行时 `disable` 原版条目 | 原版路由已注册,本插件注册时撞 `duplicate prefix route`,**进程启动失败** |
19
+ | 什么都不做、两套共存 | 进程能起,但**只有原版生效**,本插件端点全部 404(静默失效,最难排查) |
20
+ | **配置层移除原版**(推荐) | 正常启动,本插件完整生效 |
21
+ | **profile 里只装本插件**(推荐) | 正常启动,本插件完整生效 |
22
+
23
+ 前两种与加载顺序无关(两种顺序都复现):谁先注册谁赢,晚到者抛异常并被启动审计判为失败。
24
+
25
+ **正确做法**:安装本插件后,把原版从 profile 的依赖与 bundle 列表里去掉。编辑 `$DSH_HOME/profiles/<名字>/package.json`:
26
+
27
+ ```jsonc
28
+ {
29
+ "dependencies": {
30
+ // 删掉这一行:
31
+ // "dsh-webui-auth": "^0.3.5",
32
+ "dsh-webui-oauth": "^0.4.4"
33
+ },
34
+ "dsh": {
35
+ "profile": {
36
+ "bundles": [
37
+ "@deepseek-ai/dsh-base",
38
+ "@deepseek-ai/dsh-web-app",
39
+ // 删掉这一行:
40
+ // "dsh-webui-auth",
41
+ "dsh-webui-oauth"
42
+ ]
43
+ }
44
+ }
45
+ }
46
+ ```
8
47
 
9
- ## 与原版的关系(自动顶掉原版)
48
+ 不想动依赖、只想临时屏蔽,也可以在 profile 的 `cordis.patch.yml` 里按 id 禁用原版条目:
10
49
 
11
- 原版 `dsh-webui-auth` 与本插件都通过运行时包装 `webServer` 路由实现认证。若两套闸门同时存活,同一请求会被两套会话各校验一次,且登录态互不相认(在一个闸门登录后,另一个仍返回 302/401),表现为「登录后反复跳转」。
50
+ ```yaml
51
+ - id: dsh-webui-auth
52
+ disabled: true
53
+ ```
12
54
 
13
- 因此本插件在启动时(安装自己的闸门之前)会遍历 cordis 插件注册表,按插件名找到已加载的原版并 **dispose 掉它的全部 fiber**。原版自身是可逆设计,卸载后其路由包装与端点注册会被完整撤销,随后由本插件接管全部闸门。宿主日志会出现:
55
+ **双装会直接启动失败(fail-fast)**:若检测到原版仍处于启用状态,本插件在 `apply` 一开始就**抛错拒绝加载**,dsh 进程随即以退出码 1 结束,并在 stderr 打印一行可读的修复指令:
14
56
 
15
57
  ```
16
- [dsh-webui-oauth] displaced original plugin(s): dsh-webui-auth — this plugin takes over the auth gate; credentials/data directory are shared.
58
+ [dsh-webui-oauth] 检测到原版 dsh-webui-auth 同时启用,拒绝启动。两者抢占同一批路由(webServer 路由表先到先得),
59
+ 实测无法共存:本插件的登录页与闸门会完全失效(端点 404),或直接导致启动失败。
60
+ 修复:从 profile 的 dsh.profile.bundles 与 dependencies 中移除 dsh-webui-auth,
61
+ 或在其补丁层写 disabled: true,然后重启。
17
62
  ```
18
63
 
19
- - **只按插件名匹配**,不会影响任何其他插件;找不到原版时静默继续。
64
+ **为什么是拒绝启动而不是降级运行**:实测已证明双装没有一种可用形态(见上表后两行)。"进程能起但闸门归属不确定"是最危险的形态——运维以为已经上锁,实际本插件的端点全是 404。与其带着半残状态对外服务,不如把问题挡在启动阶段,并给出明确修复指令。
65
+
66
+ (cordis 默认 logger 只写内存缓冲区、不落 stdout/stderr,所以这里额外直写 stderr 兜底,保证 `docker logs` / `journalctl` 一定看得到这条唯一线索。)
67
+
68
+ - **只按插件名匹配**,不会影响任何其他插件;未装原版时完全安静。
69
+ - **绝不改动别人的生命周期**:本插件不 `dispose`、不 `disable` 任何兄弟插件的条目或 fiber。
20
70
  - **数据无缝衔接**:数据目录(`.dsh-webui-auth/`)与凭据文件(`dsh-webui-auth.json`)**刻意沿用原名**,原版创建的账号无需迁移即可登录。
21
71
  - **接管原版数据目录**:当本插件自身尚无凭据时,会依次查找含凭据的原版数据目录并**直接复用**(不是复制——复制会产生两份凭据,改密码只改一边而分叉):
22
72
  1. 同级目录 `../dsh-webui-auth/`(源码 / `link:` 安装最常见);
@@ -68,7 +118,7 @@ DSH WebUI 身份认证插件(持久化插件)。在「设置 → 身份认
68
118
 
69
119
  **已知取舍**:本机浏览器直连 `http://127.0.0.1:3080` 也落进第 2 行,于是开关开着也会跳 `publicBaseUrl`。要同时支持本机直连,只能让反代下发 `X-Forwarded-Host`(走第 1 行)。
70
120
  - **登录后跳转的协议自适应(0.3.3,修 #6 / #7)**:插件登录成功后要把浏览器引导到核心的带 token 根地址,其 **authority 取自本次请求的 Host**(不再写死 `127.0.0.1`),**scheme 按请求实际协议解析**,优先级:① 操作者显式声明 `remote-web-ui.publicBaseUrl`(**仅当其 host/port 与本次请求 Host 一致时**采信——否则局域网直连会被重定向到公网地址、跨源丢掉刚下发的会话 Cookie);② 标准代理头 `X-Forwarded-Proto`(取最左值)或 RFC 7239 `Forwarded: proto=`;③ socket 自身是 TLS(插件直接终结 TLS);④ 兜底 `http`。**刻意不做「非 IP 域名即 https」的猜测**——那会把纯 http 的内网域名访问(`http://nas.local:3080`)打成 https 死链。前端登录页另有一层单向兜底:页面在 https 下收到**同源** `http://` 跳转时自动升级为 `https://`(反向不降级、异源不改写)。
71
- - **可选 OIDC 单点登录(0.4.0)**:标准 OIDC 授权码 + PKCE(机密客户端,须 `client_secret`),以 Logto 为安全基准。配置后登录页出现「SSO 单点登录」按钮,认证通过后以 IdP 的 `sub` 建立本地会话(沿用会话有效期与持久化)。**redirect_uri 的 base 由 `oidc.redirectBase` 决定;默认交由浏览器处理(采信前端 `location.origin`,反代重写 Host 时浏览器看到的公网地址即正确),受上面统一开关约束;关闭时仅用配置的 `redirectBase`;两者都不可用则 fail-closed 报错。** 支持 RSA/EC/EdDSA 签名的 id_token 验证(JWKS + iss/aud/exp/iat/nbf/nonce 校验),简单版登出(仅清本地会话)。
121
+ - **可选 OIDC 单点登录(0.4.0)**:标准 OIDC 授权码 + PKCE(机密客户端,须 `client_secret`),以 Logto 为安全基准。配置后登录页出现「SSO 单点登录」按钮,认证通过后以 IdP 的 `sub` 建立本地会话(沿用会话有效期与持久化)。**redirect_uri 的 base 由 `oidc.redirectBase` 决定;默认交由浏览器处理(采信前端 `location.origin`,反代重写 Host 时浏览器看到的公网地址即正确),受上面统一开关约束;关闭时仅用配置的 `redirectBase`;两者都不可用则 fail-closed 报错。** id_token 签名支持 **RSA(RS256/384/512、PS256/384/512)、EC(ES256/384/512)、EdDSA(Ed25519)**,配合 iss/aud/exp/iat/nbf/nonce 与 JWKS(kid) 校验(见下节「签名算法支持」)。登出为简单版:仅清本地会话。
72
122
 
73
123
  会话为**服务端会话,持久化到磁盘**(`sessions.jsonl`,重启 DSH 不掉线,到期自动失效),由 `HttpOnly; SameSite=Lax` Cookie(`dsh_wua_session`)携带,JS 无法读取;修改密码会**吊销所有其他会话**。
74
124
 
@@ -94,7 +144,7 @@ npx @deepseek-ai/dsh plugin --profile web add dsh-webui-oauth
94
144
 
95
145
  从 npm registry 拉取,加入依赖并追加到 `dsh.profile.bundles` 列表,插件行随组合包层自动插入。
96
146
 
97
- > 若你此前装的是原版 `github:Yuuz12/dsh-webui-auth`,可以直接改装本仓库——本插件会自动顶掉原版并沿用原数据目录,账号无需重建。
147
+ > 若你此前装的是原版 `github:Yuuz12/dsh-webui-auth`,可以直接改装本仓库并沿用原数据目录,账号无需重建——但**请务必把原版从依赖与 `dsh.profile.bundles` 中移除**(见「与原版的关系」),否则两者抢占同一批路由,本插件无法生效。
98
148
 
99
149
  ### 方式三:手动(备用)
100
150
 
@@ -202,12 +252,115 @@ npx @deepseek-ai/dsh plugin --profile web add dsh-webui-oauth
202
252
  - 登录/初始化端点本身公开(认证的必然入口):`/dsh-webui-oauth/login`、`/dsh-webui-oauth/setup`(后者受 setup token 保护)。
203
253
  - **OIDC 单点登录**:authorization_code + PKCE + client_secret(机密客户端,不做纯 PKCE public client);state 防 CSRF、nonce 防重放、redirect_uri 精确匹配(base + 固定路径)、id_token 经 JWKS 验签并校验 iss/aud/azp/exp/iat/nbf/nonce、仅接受 HTTPS 端点;审计只记录经 `sanitizeSub` 过滤的 `sub`。OIDC 客户端配置(含 secret)存于数据目录的 `dsh-webui-oauth.json`,与账号凭据分离。
204
254
 
255
+ ## 签名算法支持(id_token 验签)
256
+
257
+ | 算法族 | 具体算法 | JWKS `kty` | 状态 |
258
+ | --- | --- | --- | --- |
259
+ | RSA | RS256 / RS384 / RS512 | `RSA` | ✅ |
260
+ | RSA-PSS | PS256 / PS384 / PS512 | `RSA` | ✅ |
261
+ | **ECDSA** | **ES256** (P-256) / **ES384** (P-384) / **ES512** (P-521) | `EC` | ✅ |
262
+ | EdDSA | Ed25519 | `OKP` | ✅ |
263
+
264
+ **EC 是完整可用的,不只是"写在文档里"**。三个曲线均已用真实签名端到端验证:
265
+
266
+ - **单元级**(`test/oidc-ec.test.mjs`,16 项):各曲线用 Node 原生生成密钥并真实签名 → 验签通过;篡改 payload、异密钥伪造 → 拒绝;JWKS 按 `kid` 选中 EC key;JWKS **未声明 `alg`** 时同样可用(部分 IdP 会省略该字段)。
267
+ - **端到端**:以自建 IdP 分别用 ES256 / ES384 / ES512 签发 id_token,走完整授权码 + PKCE 流程,三次均登录成功(审计 `oidc_login_success`)。
268
+
269
+ **实现要点**:ECDSA 的 JWT 签名是 JOSE 原始 `R||S`(ieee-p1363),而 Node 的 `crypto.verify` **默认按 DER 解析**。若不显式传 `dsaEncoding: 'ieee-p1363'`,ES384/ES512 会直接验签失败;ES256 因曲线较短,偶发的错误解析有时"看起来能过",极难排查。本插件对该路径有明确处理与回归测试兜底。
270
+
271
+ **算法混淆防护(0.5.0 加固)**:
272
+
273
+ 1. **验签算法只由 token 头部决定,JWKS 的 `alg` 仅作一致性约束**。早期实现写成 `(jwk.alg || header.alg)`,而 IdP 几乎都会在 JWKS 写 `alg`——等于让 `jwk.alg` 覆盖 `header.alg`,`header.alg` 形同虚设。实测后果:用 P-256 密钥真实签名,只把头部改成 `ES384`/`ES512`,token **照样通过验证**("自称 ES512、实为 P-256")。现改为以 `header.alg` 为唯一真源,`jwk.alg` 与之冲突即拒。
274
+ 2. **算法名走显式白名单**(不再用 `startsWith` 前缀匹配)。此前 `ES999`/`RS999` 这类编造的名字能命中对应 `kty` 的 key——今天被上层白名单挡住,但等于把安全性押在"另一个函数恰好兜底"上。
275
+ 3. **`kid` 命中后仍校验 kty 匹配**,不匹配即拒且**不退化为"随便挑一把"**(指定了 kid 却无匹配直接返回 `null` → `no-signing-key`)。
276
+ 4. **校验 JWKS 的 `use` / `key_ops`**:`use=enc` 或 `key_ops=[encrypt]` 的加密专用 key 不会被选中验签。
277
+ 5. **拒绝 `alg=none` 与 `HS*`**(含"用公钥当 HMAC secret"的真实伪造尝试)。
278
+
279
+ 上述每条都有回归测试;其中前两条的用例已确认能在修复前的版本上稳定失败(7 项 FAIL),不是"恒真断言"。
280
+
281
+ **选型建议**:IdP 默认多为 RS256;若你的 IdP 支持 EC,ES256 签名更短、验签更快,适合移动端与高并发场景。
282
+
283
+ ## 全流程实测结果(隔离实例)
284
+
285
+ 以下结果来自隔离 dsh 实例(独立 `$DSH_HOME` + 独立 profile,与生产镜像完全隔离),插件为本仓库版本,配合一个**自建的简易 OIDC 平台**(discovery / jwks / authorize / token,RS256 签名 + PKCE S256 校验)实测。
286
+
287
+ ### A. 本地账号流程
288
+
289
+ | 步骤 | 请求 | 实测结果 |
290
+ | --- | --- | --- |
291
+ | 未认证访问 `/` | GET | **302 → /dsh-webui-oauth/login**(闸门生效) |
292
+ | 未认证访问 `/api` | GET | **401** |
293
+ | 登录页 | GET `/dsh-webui-oauth/login` | **200**(唯一放行的公开页) |
294
+ | 错误 setup token | POST `/setup` | `{"ok":false,"error":"setup-token-required"}` |
295
+ | 弱密码 | POST `/setup` | `{"ok":false,"error":"weak-password","reason":"length"}` |
296
+ | 正确 setup token | POST `/setup` | `{"ok":true}` + 下发 `dsh_wua_session`(HttpOnly; SameSite=Lax; Max-Age=43200) |
297
+ | 重复 setup | POST `/setup` | `{"ok":false,"error":"already-configured"}` |
298
+ | 错误密码登录 | POST `/login` | `{"ok":false,"error":"invalid"}` |
299
+ | 正确密码登录 | POST `/login` | `{"ok":true}` + 会话 Cookie |
300
+
301
+ **双 Cookie 交接(重要)**:拿到插件会话后访问 `/`,插件会 302 到核心的带 token 根 URL(`/?token=…`),完成核心自己的 `dsh-auth-*` Cookie 交换。实测:
302
+
303
+ - 只带插件 Cookie 访问 `/api` → **401**(核心仍未认领该浏览器);
304
+ - 完成 token 交换后再访问 `/api` → **404**(已通过认证,仅是该路径不存在)。
305
+
306
+ 也就是说插件闸门与核心 BrowserAuth 是**两道独立的门**,缺一不可。这是设计行为,不是故障。
307
+
308
+ ### B. OIDC / SSO 流程
309
+
310
+ 用一个自建虚拟 IdP(`https://127.0.0.1:14443`,自签 CA)完整跑通:
311
+
312
+ 1. `GET /dsh-webui-oauth/oidc/login?base=<浏览器origin>` → **302** 到 IdP `/authorize`,参数含 `code_challenge` + `code_challenge_method=S256`,并下发 `dsh_wua_oidc_state` Cookie(HttpOnly,600s);
313
+ 2. IdP 校验 client_id / response_type / PKCE 后 **302** 回 `redirect_uri?code=…&state=…`;
314
+ 3. `GET /dsh-webui-oauth/oidc/callback` → 插件用 `code_verifier` 换 token、验签 id_token → **200** + 下发会话 Cookie、清除 state Cookie;
315
+ 4. 审计记录 `oidc_login_success`,身份为 IdP 的 `sub`(实测 `lab-user-001`)。
316
+
317
+ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code) → token(issued id_token)`。
318
+
319
+ 安全属性同时验证(均被正确拒绝):
320
+
321
+ | 攻击 | 实测 |
322
+ | --- | --- |
323
+ | state 不匹配 | `{"ok":false,"error":"oidc-invalid-state"}` |
324
+ | 缺 state Cookie(登录 CSRF / 会话固定) | `{"ok":false,"error":"oidc-invalid-state"}` |
325
+ | 授权码重放 | `{"ok":false,"error":"oidc-invalid-state"}` |
326
+
327
+ ### C. 反代改写 Host 时的跳转(重点)
328
+
329
+ 前提:**未配置** `remote-web-ui.publicBaseUrl`,`trustBrowserOrigin` 为默认 `true`。假设浏览器实际访问 `http://127.0.0.1:14080`,反代把 Host 改写成回环 `127.0.0.1:13081`(Caddy 的默认行为)。
330
+
331
+ | 场景 | 插件给出的跳转目标 | 是否正确 |
332
+ | --- | --- | --- |
333
+ | 1. 直连(Host=13081) | `http://127.0.0.1:13081/?token=…` | ✅ |
334
+ | 2. Host 被改写 **+ 下发 X-Forwarded-Host=14080** | `http://127.0.0.1:14080/?token=…` | ✅ |
335
+ | 3. Host 被改写 + XFH=公网域名:8443 + X-Forwarded-Proto=https | `https://dsh.example.com:8443/?token=…` | ✅ |
336
+ | 4. Host 被改写 **且不下发 XFH** | `http://127.0.0.1:13081/?token=…` | ❌ 指向内网端口 |
337
+
338
+ **结论**:Host 被反代改写且**不下发原始主机头**时,未配置 `publicBaseUrl` 无法得到正确跳转——服务端从请求里拿不到任何浏览器侧地址,这不是可以靠推断解决的问题(猜错会变成开放重定向)。此时浏览器会被送到 `127.0.0.1:13081`,若那是内网/未暴露端口,就表现为「登录成功但页面打不开」。
339
+
340
+ **两种解法,任选其一**:
341
+
342
+ - 让反代下发原始主机头(推荐,配置最少):
343
+ ```nginx
344
+ proxy_set_header X-Forwarded-Host $host; # 或 $http_host(含端口)
345
+ proxy_set_header X-Forwarded-Proto $scheme;
346
+ ```
347
+ Caddy 默认就会传 `X-Forwarded-Host`;若用 `DSH_PRESERVE_HOST` 类开关保留 Host,则场景直接退化为「直连」,同样正确。
348
+ - 或在 `settings.yaml` 显式声明对外地址:
349
+ ```yaml
350
+ remote-web-ui:
351
+ publicBaseUrl: 'https://dsh.example.com:8443'
352
+ ```
353
+ 注意 `publicBaseUrl` **仅在其 authority 与请求 Host 一致时才被采信**(防开放重定向)。Host 已被改写为回环时二者不一致,因此**该场景下 `publicBaseUrl` 也救不了**——必须走上面第一条。这是实测踩到的细节,容易误配。
354
+
355
+ 另需 `--trusted-host <对外域名:端口>`,否则桌面浏览器直连 `/api` 会 403(见「已知边界」)。
356
+
205
357
  ## 已知边界
206
358
 
207
359
  - **运行时包装的固有窗口**:路由对象被替换(服务热重载)到下一次重扫之间(≤10s)存在未保护窗口;启用认证时的 fail-closed 已挡住「初始裸奔」,此窗口仅影响运行中的热重载场景。
208
360
  - **WS 与 trustedHosts**:反代/局域网(非回环 Host)下,WS 下行需在 dsh 配置 `client-connection.trustedHosts` 中加入对外域名(见「架构」节)。
209
361
  - **反代不同机**:若反代与 DSH 不在同一台机器(对端非回环),代理头不被信任,限流将按代理 IP 聚合(退化为全局桶)。
210
362
  - **HTTPS 反代且未下发协议头**:登录后跳转的 scheme 依赖 ①`remote-web-ui.publicBaseUrl` 或 ②反代下发的 `X-Forwarded-Proto`/`Forwarded`。两者都没有时只能兜底 `http`(此时 https 端口会握手失败、页面「点了没反应」)。请二选一:在 `settings.yaml` 声明对外地址,或让反代 `proxy_set_header X-Forwarded-Proto $scheme;`。注意 `publicBaseUrl` 仅在与请求 Host 一致时生效,用来避免把局域网直连改写到公网。
363
+ - **反代改写 Host 且不下发原始主机头**:此时服务端拿不到浏览器侧地址,未配置 `publicBaseUrl` 得不到正确跳转(会指向内网 upstream 端口);而 `publicBaseUrl` 因 authority 不匹配同样不生效。**唯一可行的解法是让反代下发 `X-Forwarded-Host`**(Caddy 默认下发)或保留原始 Host。详见「全流程实测结果」C 节的实测总表。
211
364
  - **`--trusted-host` 不能省**:桌面浏览器用密码登录后**直连 `/api`** 的请求依赖 `--trusted-host <对外域名:端口>`;而 remote-web-ui 的配对流(`/remote` 通道)不需要它。删掉该参数会导致 `/api` 全 403。
212
365
  - **审计假名化的边界**:HMAC 密钥与审计日志同目录(0600),能读取密钥文件的本地攻击者可对 IP 空间暴力还原;假名化防的是「日志明文落盘」,不是防有文件权限的攻击者。
213
366
  - 会话存于数据目录 `sessions.jsonl`:重启后仍生效(到期时间不变);关闭/卸载插件不影响凭据。
package/cordis.patch.yml CHANGED
@@ -1,6 +1,14 @@
1
- # dsh-webui-oauth 组合包层:安装本 bundle 时插入插件行
2
- # 注意:id 与包名一致;本插件会主动让原版 dsh-webui-auth 失效(见 index.js 的
3
- # "顶掉原版" 逻辑),因此两者同时安装时不会出现双闸门。
1
+ # dsh-webui-oauth 组合包层:安装本 bundle 时插入插件行。
2
+ #
3
+ # 注意:id 与包名一致。
4
+ #
5
+ # 与原版 dsh-webui-auth 的关系(重要):本插件【不会】在运行时接管原版——两者
6
+ # 抢占同一批 webServer 路由,运行时无法安全共存(详见 index.js 顶部长注释与
7
+ # README「与原版的关系」的实测矩阵)。
8
+ #
9
+ # 因此装上本插件后,必须把原版从 profile 的 bundles 与 dependencies 中移除,
10
+ # 或在其补丁层写 disabled: true。若检测到原版仍启用,本插件会【拒绝启动】并
11
+ # 打印修复指令(fail-fast),以免出现"装好了却没生效"的静默失效。
4
12
  - insert:
5
13
  - id: dsh-webui-oauth
6
14
  name: 'dsh-webui-oauth'
package/index.js CHANGED
@@ -982,54 +982,57 @@ function rejectUpgrade401(socket) {
982
982
  try { socket.destroy() } catch (e) { /* ignore */ }
983
983
  }
984
984
 
985
- // ---------------- 顶掉原版 dsh-webui-auth ----------------
985
+ // ---------------- 与原版 dsh-webui-auth 共存的问题 ----------------
986
986
  //
987
- // 本插件(dsh-webui-oauth)是原版 dsh-webui-auth 的增强替代。两者都通过运行时包装
988
- // webServer 路由实现认证:同时加载会出现【双闸门】——同一请求被两套会话校验各拦一次,
989
- // 且两套 /dsh-webui-* 端点并存,登录态互不相认(在一个闸门登录,另一个仍返回 302/401),
990
- // 表现为「登录后反复跳转」。
987
+ // 本插件(dsh-webui-oauth)是原版 dsh-webui-auth 的增强替代,但【不能】在运行时接管它。
991
988
  //
992
- // 因此这里主动停掉已加载的原版:遍历 cordis 插件注册表,按插件名找到原版 runtime,
993
- // 逐个 dispose 它的 fiber。卸掉后原版的 ctx.effect 清理函数会执行,其路由包装与
994
- // 端点注册被完整撤销(原版自身就是可逆设计),随后由本插件接管全部闸门。
989
+ // 原版与本插件都用 ctx.webServer.register() 抢占同一批路由(前缀 ""、/api、
990
+ // /plugins,以及 /api/remote.mux 升级路由)。webServer 的路由表是【先到先得】:
991
+ // register() 对同 (kind, path) 直接抛 "duplicate ... route",而且路由条目里
992
+ // 【不含任何属主信息】——注册进去就只有 path,追不回是谁注册的。
995
993
  //
996
- // 边界:只按【插件名】匹配,不碰任何其他插件;找不到原版时静默继续(正常路径)。
997
- const DISPLACED_PLUGIN_NAMES = ['dsh-webui-auth']
994
+ // 实测矩阵(隔离 dsh 0.1.5-rc.2 实例,双插件同时进 profile bundles):
995
+ // A. 运行时 dispose 原版 fiber → 原版 apply 撞 INACTIVE_EFFECT,整个进程启动失败
996
+ // B. 运行时 disable 原版条目 → 原版路由已注册,本插件注册时撞 duplicate route,启动失败
997
+ // C. 配置层 disabled 原版 → 正常启动(推荐做法)
998
+ // D. profile 里只装本插件 → 正常启动(推荐做法)
999
+ // E. 两者都装且都不动对方 → 进程能起,但【只有原版生效】(本插件端点 404)
1000
+ //
1001
+ // A/B 失败与加载顺序无关(两种顺序都复现):谁先注册谁赢,晚到者抛异常并被
1002
+ // boot 的启动审计判为失败。E 更隐蔽:本插件 apply 确实执行了,但它的路由
1003
+ // 注册全部落空,表现成"装上了却完全不生效"。
1004
+ //
1005
+ // 结论:运行时接管在架构上不可行,本插件【不再】尝试停用原版。
1006
+ // 双装由【配置层】解决(见 README 的迁移说明),运行时只做一件事:
1007
+ // 检测到原版也在跑时,把冲突明确报出来,避免"静默失效"这种最坏的形态。
1008
+ const ORIGINAL_PLUGIN_NAME = 'dsh-webui-auth'
998
1009
 
999
- function displaceOriginalPlugins(ctx, log) {
1000
- const displaced = []
1010
+ /**
1011
+ * 检测原版 dsh-webui-auth 是否也在运行。
1012
+ *
1013
+ * 【为什么必须显式检测】原版与本插件抢同一批路由,先注册者胜。若原版先注册,
1014
+ * 本插件的全部端点会静默落空——进程不报错、日志无异常,但登录页根本不存在。
1015
+ * 这种"看起来装好了、实际没生效"的故障最难排查,所以宁可吵一点也要报出来。
1016
+ *
1017
+ * 只断言可证的事实:原版条目是否【启用并加载】。不猜"谁占住了路由"——
1018
+ * webServer 的路由条目只存 {kind,path,handler},没有属主字段,追不回注册者;
1019
+ * 本插件自己的注册成败则另有精确判据(见 gate.ok()),不需要靠猜。
1020
+ *
1021
+ * @returns {{ loaded: boolean }} loaded=原版条目处于启用状态且已加载。
1022
+ */
1023
+ function detectOriginalPlugin(ctx) {
1024
+ const result = { loaded: false }
1001
1025
  try {
1002
- const registry = ctx.registry
1003
- if (!registry || typeof registry.values !== 'function') return displaced
1004
- // 先收集再 dispose:遍历过程中直接删除会破坏迭代。
1005
- const victims = []
1006
- for (const runtime of registry.values()) {
1007
- const rtName = runtime && runtime.name
1008
- if (typeof rtName === 'string' && DISPLACED_PLUGIN_NAMES.includes(rtName)) {
1009
- victims.push(runtime)
1010
- }
1011
- }
1012
- for (const runtime of victims) {
1013
- // registry.delete(callback) 会 dispose 该插件的全部 fiber;
1014
- // 用 runtime.callback 作为 key(这正是 map 的键)。
1015
- const removed = typeof registry.delete === 'function' ? registry.delete(runtime.callback) : undefined
1016
- const fibers = (removed && removed.fibers) || runtime.fibers
1017
- // 双保险:即使 registry.delete 未生效,也逐个 dispose 掉 fiber。
1018
- if (fibers && typeof fibers[Symbol.iterator] === 'function') {
1019
- for (const fiber of fibers) {
1020
- try {
1021
- if (fiber && typeof fiber.dispose === 'function') fiber.dispose()
1022
- } catch (e) { /* 单个 fiber 清理失败不应阻断接管 */ }
1023
- }
1026
+ const loader = ctx.get('loader')
1027
+ if (loader && typeof loader.entries === 'function') {
1028
+ for (const entry of loader.entries()) {
1029
+ const n = entry && entry.options && entry.options.name
1030
+ // disabled 的条目不会被加载,不构成冲突。
1031
+ if (n === ORIGINAL_PLUGIN_NAME && !entry.disabled) { result.loaded = true; break }
1024
1032
  }
1025
- displaced.push(runtime.name)
1026
1033
  }
1027
- } catch (e) {
1028
- // 注册表结构变化时不能让接管流程崩溃:退化为「不做替换」,
1029
- // 由调用方记录警告,运维可从日志发现双装。
1030
- try { log('displace failed: ' + (e && e.message ? e.message : String(e))) } catch (err) { /* ignore */ }
1031
- }
1032
- return displaced
1034
+ } catch (e) { /* 结构变化:视为未检测到 */ }
1035
+ return result
1033
1036
  }
1034
1037
 
1035
1038
  /**
@@ -1554,8 +1557,21 @@ export function verifyJwtSignature(jwt, jwk) {
1554
1557
  const data = Buffer.from(parts[0] + '.' + parts[1], 'utf8')
1555
1558
  const sig = b64urlDecode(parts[2])
1556
1559
  const header = parseJwt(jwt) && parseJwt(jwt).header
1557
- const alg = (jwk && jwk.alg) || (header && header.alg)
1558
- if (!alg) return false
1560
+ if (!header) return false
1561
+ // 验签算法【只能】由 token 头部决定,且 JWKS 若声明了 alg 就必须与之一致。
1562
+ //
1563
+ // 早先这里写成 (jwk && jwk.alg) || (header && header.alg):由于几乎每个 IdP 都会在
1564
+ // JWKS 里写 alg,等价于让 jwk.alg 覆盖 header.alg,header.alg 形同虚设。后果是
1565
+ // 【声明与实际不符的 token 会被接受】:攻击者拿 P-256 密钥(kid 命中)真实签名,
1566
+ // 只需把 header 写成 ES512,就得到一个"自称 ES512、实为 P-256"的 token 并通过验证。
1567
+ // 实测:同一条 P-256/sha256 签名,header 写 ES256/ES384/ES512 三者全部接受。
1568
+ // 这架空的正是 algMatchesKty/findJwk 想建立的算法混淆防护(只是从 kty 维度漏到
1569
+ // 曲线与哈希维度),下游任何依据 header.alg 的分级/审计都会被欺骗。
1570
+ //
1571
+ // 修法:以 header.alg 为唯一真源;jwk.alg 只作【一致性约束】,冲突即拒。
1572
+ const alg = header.alg
1573
+ if (typeof alg !== 'string' || !alg) return false
1574
+ if (jwk && typeof jwk.alg === 'string' && jwk.alg && jwk.alg !== alg) return false
1559
1575
  let jwkKey = jwk
1560
1576
  if (jwk && jwk.kty === 'EC') {
1561
1577
  jwkKey = { kty: 'EC', crv: jwk.crv, x: jwk.x, y: jwk.y }
@@ -1571,7 +1587,10 @@ export function verifyJwtSignature(jwt, jwk) {
1571
1587
  const digest = jwtAlgToDigest(alg)
1572
1588
  if (!digest) return false
1573
1589
  // ECDSA JWT 签名采用 JOSE/ieee-p1363 原始 R||S 格式,而 crypto.verify 默认期望 DER——
1574
- // 须显式指定 dsaEncoding,否则 ES384/ES512 会验签失败(ES256 因曲线小偶尔误中)。
1590
+ // 须显式指定 dsaEncoding,否则三条曲线都会验签失败。
1591
+ // 实测:用 DER 模式去验 raw 签名,P-256 与 P-521 各 300 次全部拒绝(0 次误中),
1592
+ // 三曲线的 raw(R||S) 长度分别为 64/96/132 字节。原始注释里"ES256 因曲线小偶尔误中"
1593
+ // 的说法不成立,已按实测更正,免得后人据此误判风险。
1575
1594
  if (alg.startsWith('ES')) {
1576
1595
  return cryptoVerify(digest, data, { key, dsaEncoding: 'ieee-p1363' }, sig)
1577
1596
  }
@@ -1585,31 +1604,38 @@ export function verifyJwtSignature(jwt, jwk) {
1585
1604
  // 命中 kid 后仍校验 alg 与 key 类型一致:攻击者可用 RSA key 的 kid 配 ES256 头,
1586
1605
  // 若直接采信会把"该用哪种算法验签"的决定权交给 token 本身(算法混淆的温床)。
1587
1606
  function algMatchesKty(alg, kty) {
1588
- if (alg === 'EdDSA') return kty === 'OKP'
1589
- if (alg.startsWith('RS') || alg.startsWith('PS')) return kty === 'RSA'
1590
- if (alg.startsWith('ES')) return kty === 'EC'
1591
- return false
1607
+ // 显式白名单,而不是 startsWith 前缀匹配。
1608
+ //
1609
+ // 前缀匹配会把编造的算法名一并放行:实测 alg='ES999' / 'RS999' 都能命中对应 kty 的
1610
+ // key('Ed999' 因只比对全等才没中)。今天 validateIdToken 的 jwtAlgToDigest 白名单会
1611
+ // 先拦掉这些名字,所以尚不可利用;但 findJwk/verifyJwtSignature 是导出 API,任何新
1612
+ // 调用方漏掉那层白名单就会直接踩中——安全性不该依赖"另一个函数恰好兜底"。
1613
+ const EXPECTED = {
1614
+ ES256: 'EC', ES384: 'EC', ES512: 'EC',
1615
+ RS256: 'RSA', RS384: 'RSA', RS512: 'RSA',
1616
+ PS256: 'RSA', PS384: 'RSA', PS512: 'RSA',
1617
+ EdDSA: 'OKP',
1618
+ }
1619
+ return EXPECTED[alg] === kty
1592
1620
  }
1593
1621
  export function findJwk(jwks, header) {
1594
1622
  if (!jwks || !Array.isArray(jwks.keys) || !header) return null
1595
1623
  const alg = typeof header.alg === 'string' ? header.alg : ''
1596
1624
  if (!alg) return null // 无 alg 一律拒绝,不做猜测
1597
- const usable = (k) => !!k && typeof k === 'object' && algMatchesKty(alg, k.kty)
1625
+ // use/key_ops 一并校验:JWKS 混放签名与加密密钥时,加密专用 key 不该被选中验签。
1626
+ const usable = (k) => !!k && typeof k === 'object'
1627
+ && algMatchesKty(alg, k.kty)
1628
+ && (k.use === undefined || k.use === 'sig')
1629
+ && (!Array.isArray(k.key_ops) || k.key_ops.includes('verify'))
1598
1630
  if (header.kid) {
1599
1631
  const hit = jwks.keys.find((k) => usable(k) && k.kid === header.kid)
1600
1632
  if (hit) return hit
1601
1633
  return null // 指定了 kid 却无匹配(或类型不符):不再退化为"随便挑一把"
1602
1634
  }
1603
- if (alg.startsWith('RS') || alg.startsWith('PS')) {
1604
- return jwks.keys.find((k) => usable(k)) || null
1605
- }
1606
- if (alg.startsWith('ES')) {
1607
- return jwks.keys.find((k) => usable(k)) || null
1608
- }
1609
- if (alg === 'EdDSA') {
1610
- return jwks.keys.find((k) => usable(k)) || null
1611
- }
1612
- return null
1635
+ // 三个分支体原本完全相同(等价于死代码);alg 的合法性已由 algMatchesKty 的
1636
+ // 白名单把关,这里只需在匹配 kty 的 key 中取首枚。
1637
+ if (!algMatchesKty(alg, 'EC') && !algMatchesKty(alg, 'RSA') && !algMatchesKty(alg, 'OKP')) return null
1638
+ return jwks.keys.find((k) => usable(k)) || null
1613
1639
  }
1614
1640
 
1615
1641
  // 验证 id_token 的声明与签名。返回 { ok, error, payload }。
@@ -1801,14 +1827,28 @@ function makeOidcStateStore(opts) {
1801
1827
  }
1802
1828
 
1803
1829
  export async function apply(ctx) {
1804
- // 顶掉原版 dsh-webui-auth(若已加载):必须在安装本插件闸门之前完成,
1805
- // 否则会出现两套闸门并存的窗口。
1806
- const displaced = displaceOriginalPlugins(ctx, (m) => {
1807
- try { ctx.logger.warn('[dsh-webui-oauth] ' + m) } catch (e) { /* ignore */ }
1808
- })
1809
- if (displaced.length > 0) {
1810
- ctx.logger.info('[dsh-webui-oauth] displaced original plugin(s): ' + displaced.join(', ')
1811
- + ' — this plugin takes over the auth gate; credentials/data directory are shared.')
1830
+ // 双装检测:本插件【不】在运行时接管原版(原因见上方长注释与实测矩阵)。
1831
+ // 检测放在 apply 最前,且【直接抛错】终止加载,而不是只告警:
1832
+ // 实测已证明双装必然产生以下三种坏结果之一("共存"看着能起,但本插件
1833
+ // 的路由注册全部落空、端点 404)。既然没有一种可用,就不该让进程带着
1834
+ // "装好了却没生效"的半残状态继续对外服务——那正是最危险的形态:
1835
+ // 运维以为已上锁,实际只要原版那条路还在跑,闸门归属就是不确定的。
1836
+ // fail-fast 让 dsh 启动即失败并给出明确修复指令,把问题挡在对外服务之前。
1837
+ //
1838
+ // 注意:抛错会让 Cordis 的启动审计(assertEntriesActivated)把本次启动判为
1839
+ // 失败,dsh 进程退出。这是【有意为之】——与其静默降级,不如拒绝启动。
1840
+ const original = detectOriginalPlugin(ctx)
1841
+ if (original.loaded) {
1842
+ const advice = '[dsh-webui-oauth] 检测到原版 ' + ORIGINAL_PLUGIN_NAME + ' 同时启用,拒绝启动。'
1843
+ + '两者抢占同一批路由(webServer 路由表先到先得),实测无法共存:'
1844
+ + '本插件的登录页与闸门会完全失效(端点 404),或直接导致启动失败。'
1845
+ + '修复:从 profile 的 dsh.profile.bundles 与 dependencies 中移除 ' + ORIGINAL_PLUGIN_NAME
1846
+ + ',或在其补丁层写 disabled: true,然后重启。'
1847
+ try { ctx.logger.error(advice) } catch (e) { /* ignore */ }
1848
+ // cordis 的默认 logger 只写内存缓冲区、不落到 stdout/stderr(除非宿主挂了 exporter)。
1849
+ // 这条错误是启动失败的【唯一可读线索】,必须保证 docker logs / journalctl 一定看得到。
1850
+ try { process.stderr.write(advice + '\n') } catch (e) { /* ignore */ }
1851
+ throw new Error(advice)
1812
1852
  }
1813
1853
 
1814
1854
  // H1: 每次启动生成随机 setup token,仅打印到宿主日志。
package/lib/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  window.__ModuleLoader__.load({
2
- id: 'dsh-webui-auth',
2
+ id: 'dsh-webui-oauth',
3
3
  factory: (require) => {
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
@@ -38,12 +38,12 @@ window.__ModuleLoader__.load({
38
38
 
39
39
  function apply(ctx) {
40
40
  const styleEl = document.createElement('style');
41
- styleEl.setAttribute('data-plugin', 'dsh-webui-auth');
41
+ styleEl.setAttribute('data-plugin', 'dsh-webui-oauth');
42
42
  styleEl.textContent = CSS;
43
43
  document.head.appendChild(styleEl);
44
44
  ctx.effect(() => () => {
45
45
  if (styleEl.parentNode !== null) styleEl.parentNode.removeChild(styleEl);
46
- }, 'dsh-webui-auth: styles');
46
+ }, 'dsh-webui-oauth: styles');
47
47
 
48
48
  // ---------------- 同源 HTTP API(会话 cookie 由浏览器自动携带) ----------------
49
49
  function api(path, body) {
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "node test/redirect-scheme.test.mjs && node test/proxy-host.test.mjs && node test/trusted-origin.test.mjs && node test/oidc.test.mjs && node test/oidc-state-ttl.test.mjs && node test/set-cookie-multi.test.mjs && node test/oidc-integration.test.mjs && node test/oidc-config-store.test.mjs && node test/adopt-data-dir.test.mjs && node test/displace-original.test.mjs"
7
+ "test": "node test/redirect-scheme.test.mjs && node test/proxy-host.test.mjs && node test/trusted-origin.test.mjs && node test/oidc.test.mjs && node test/oidc-state-ttl.test.mjs && node test/set-cookie-multi.test.mjs && node test/oidc-integration.test.mjs && node test/oidc-config-store.test.mjs && node test/adopt-data-dir.test.mjs && node test/oidc-ec.test.mjs && node test/client-bundle-id.test.mjs && node test/displace-original.test.mjs"
8
8
  },
9
9
  "exports": {
10
10
  ".": "./index.js",
@@ -18,7 +18,8 @@
18
18
  "lib/client.js",
19
19
  "cordis.patch.yml",
20
20
  "README.md",
21
- "README.en.md"
21
+ "README.en.md",
22
+ "CHANGELOG.md"
22
23
  ],
23
24
  "license": "MIT",
24
25
  "keywords": [