dsh-webui-oauth 0.5.0 → 0.5.1

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 CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.5.1
6
+
7
+ 修复 OIDC 密钥轮换后长时间无法登录的问题。**使用 OIDC/SSO 的部署建议升级。**
8
+
9
+ ### 修复:IdP 轮换签名密钥后,最长 1 小时内无法登录
10
+
11
+ - **现象**:IdP 轮换签名密钥(换 `kid` 或换算法,如 RS256 → ES256)后,所有 OIDC 登录
12
+ 持续失败,审计记录 `id_token 验证失败: no-signing-key`;重启 dsh 才恢复。
13
+ - **原因**:discovery + JWKS 有 1 小时内存缓存,而缓存命中时不校验"当前 token 的 kid
14
+ 是否还在缓存里"。轮换后缓存里那把旧 key 永远匹配不上,且代码不做任何刷新。
15
+ - **修复**:验签失败且错误属于签名类(`no-signing-key` / `bad-signature`)时,绕过缓存
16
+ 强制重取一次 JWKS 再验;成功后回写缓存,并在审计里记一条 `oidc_jwks_refreshed`,
17
+ 便于运维确认确实发生了密钥轮换。正常路径仍走缓存,不会被架空。
18
+ - **实测**:先在 RS256 IdP 登录(建立缓存),仅把 IdP 换成 ES256,同一 dsh 进程内再登录——
19
+ 修复前必失败,修复后自动恢复。
20
+
21
+ ### 其他
22
+
23
+ - `makeDiscoveryCache(fetchImpl)` / `fetchOidcDiscovery(issuer, { fetchImpl })` 新增可选的
24
+ fetch 注入点,仅供测试使用(可在无网络、无 TLS 的环境下完整测试缓存与刷新逻辑)。
25
+ - 新增 `test/jwks-refresh.test.mjs`(14 项):缓存命中、密钥轮换、refresh 失败不污染缓存、
26
+ 跨源 `jwks_uri` 仍被拒。全量 288 项断言通过。
27
+
5
28
  ## 0.5.0
6
29
 
7
30
  安全加固与正确性修复。**建议所有用户升级**,其中「原版共存」与「算法绑定」两项在特定部署下会导致严重问题。
package/README.en.md CHANGED
@@ -345,6 +345,7 @@ Preconditions: **no** `remote-web-ui.publicBaseUrl` configured, `trustBrowserOri
345
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
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".
347
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.
348
+ - **JWKS caching and key rotation**: discovery + JWKS are cached in memory for 1 hour. After an IdP rotates its signing key the cached key no longer matches; the plugin then **automatically** bypasses the cache, re-fetches once and records an `oidc_jwks_refreshed` audit entry (since 0.5.1). The cost is one extra JWKS request per rotation; an IdP that rotates repeatedly within a very short window can make logins occasionally slower.
348
349
  - **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.
349
350
  - Sessions live in `sessions.jsonl`: they survive restarts (expiry unchanged); uninstalling/disabling the plugin does not affect credentials.
350
351
  - Threat model is "browser/network clients": local processes that can read/write the host's memory or files are out of scope.
package/README.md CHANGED
@@ -362,6 +362,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
362
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
363
  - **反代改写 Host 且不下发原始主机头**:此时服务端拿不到浏览器侧地址,未配置 `publicBaseUrl` 得不到正确跳转(会指向内网 upstream 端口);而 `publicBaseUrl` 因 authority 不匹配同样不生效。**唯一可行的解法是让反代下发 `X-Forwarded-Host`**(Caddy 默认下发)或保留原始 Host。详见「全流程实测结果」C 节的实测总表。
364
364
  - **`--trusted-host` 不能省**:桌面浏览器用密码登录后**直连 `/api`** 的请求依赖 `--trusted-host <对外域名:端口>`;而 remote-web-ui 的配对流(`/remote` 通道)不需要它。删掉该参数会导致 `/api` 全 403。
365
+ - **JWKS 缓存与密钥轮换**:discovery + JWKS 有 1 小时内存缓存。IdP 轮换签名密钥后,缓存里那把旧 key 会匹配不上,此时插件**自动**绕过缓存重取一次并记 `oidc_jwks_refreshed` 审计(0.5.1 起)。代价是每次密钥轮换会有一次额外的 JWKS 请求;若 IdP 在极短时间内反复轮换,可能表现为登录偶发变慢。
365
366
  - **审计假名化的边界**:HMAC 密钥与审计日志同目录(0600),能读取密钥文件的本地攻击者可对 IP 空间暴力还原;假名化防的是「日志明文落盘」,不是防有文件权限的攻击者。
366
367
  - 会话存于数据目录 `sessions.jsonl`:重启后仍生效(到期时间不变);关闭/卸载插件不影响凭据。
367
368
  - 威胁模型为「浏览器/网络客户端」:能直接读写宿主进程内存或文件的本地进程不在防护范围内。
package/index.js CHANGED
@@ -200,7 +200,7 @@ async function dummyVerify(password) {
200
200
  }
201
201
 
202
202
  // 供测试与工具脚本使用(Cordis 加载时只消费 name/inject/apply,多余导出无副作用)
203
- export { hashPassword, verifyPassword, auditLog, readAuditEntries, resolveDataDirFrom, DATA_DIR, resolveRedirectScheme, postLoginRedirect, readOidcConfig, writeOidcConfig, configPath, oidcConfigPath, sendJson, makeOidcStateStore }
203
+ export { hashPassword, verifyPassword, auditLog, readAuditEntries, resolveDataDirFrom, DATA_DIR, resolveRedirectScheme, postLoginRedirect, readOidcConfig, writeOidcConfig, configPath, oidcConfigPath, sendJson, makeOidcStateStore, makeDiscoveryCache }
204
204
 
205
205
  // ---------------- 数据目录与文件 ----------------
206
206
 
@@ -1690,13 +1690,15 @@ export function validateIdToken(idToken, opts) {
1690
1690
  // 仅接受 HTTPS issuer。返回 { ok, error, metadata }。
1691
1691
  export async function fetchOidcDiscovery(issuer, opts) {
1692
1692
  try {
1693
+ // opts.fetchImpl 仅用于测试注入(见 makeDiscoveryCache 的说明)。
1694
+ const doFetch = opts && typeof opts.fetchImpl === 'function' ? opts.fetchImpl : (...a) => fetch(...a)
1693
1695
  if (typeof issuer !== 'string' || !issuer.trim()) return { ok: false, error: 'bad-issuer', metadata: null }
1694
1696
  const issuerUrl = new URL(issuer.trim())
1695
1697
  if (issuerUrl.protocol !== 'https:') return { ok: false, error: 'issuer-not-https', metadata: null }
1696
1698
  // RFC 8414 / OIDC Discovery:issuer 路径拼接 .well-known。
1697
1699
  const base = issuerUrl.href.replace(/\/$/, '')
1698
1700
  const url = base + '/.well-known/openid-configuration'
1699
- const res = await fetch(url, {
1701
+ const res = await doFetch(url, {
1700
1702
  headers: { accept: 'application/json', 'user-agent': 'dsh-webui-oauth/0.4.0' },
1701
1703
  // 不跟随重定向:issuer 是部署者配置的信任锚,跟随跳转就把它交给远端决定
1702
1704
  // (可被用来把 discovery 引到任意主机)。需要跳转的部署应直接配置最终 issuer。
@@ -1726,7 +1728,10 @@ export async function fetchOidcDiscovery(issuer, opts) {
1726
1728
  }
1727
1729
 
1728
1730
  // 拉取 JWKS 并缓存(内存 1h,失败则下次重试)。
1729
- function makeDiscoveryCache() {
1731
+ function makeDiscoveryCache(fetchImpl) {
1732
+ // fetchImpl 仅用于测试注入:默认用全局 fetch。这样缓存与刷新逻辑(含密钥轮换
1733
+ // 后的重取)可以在无网络、无 TLS 的环境里被完整测试。
1734
+ const doFetch = typeof fetchImpl === 'function' ? fetchImpl : (...args) => fetch(...args)
1730
1735
  const cache = new Map() // issuer -> { ts, metadata, jwks }
1731
1736
  async function get(issuer) {
1732
1737
  const now = Date.now()
@@ -1737,7 +1742,7 @@ function makeDiscoveryCache() {
1737
1742
  if (hit && now - hit.ts < 3600_000 && hit.metadata) {
1738
1743
  return { ok: true, error: null, metadata: hit.metadata, jwks: hit.jwks }
1739
1744
  }
1740
- const r = await fetchOidcDiscovery(issuer)
1745
+ const r = await fetchOidcDiscovery(issuer, { fetchImpl: doFetch })
1741
1746
  if (!r.ok) return { ...r, jwks: null }
1742
1747
  let jwks = null
1743
1748
  // jwks_uri 必须与 issuer 同源:否则一次 discovery 响应就能把密钥来源指向任意主机
@@ -1748,7 +1753,7 @@ function makeDiscoveryCache() {
1748
1753
  if (jwksUrl.origin !== new URL(issuer).origin) {
1749
1754
  return { ok: false, error: 'jwks-cross-origin', metadata: r.metadata, jwks: null }
1750
1755
  }
1751
- const jres = await fetch(r.metadata.jwks_uri, { headers: { accept: 'application/json' }, redirect: 'error' })
1756
+ const jres = await doFetch(r.metadata.jwks_uri, { headers: { accept: 'application/json' }, redirect: 'error' })
1752
1757
  if (jres.ok) {
1753
1758
  const jtxt = await jres.text()
1754
1759
  const parsed = JSON.parse(jtxt)
@@ -1763,7 +1768,25 @@ function makeDiscoveryCache() {
1763
1768
  cache.set(issuer, { ts: now, metadata: r.metadata, jwks })
1764
1769
  return { ok: true, error: null, metadata: r.metadata, jwks }
1765
1770
  }
1766
- return { get }
1771
+ /**
1772
+ * 跳过缓存强制重新拉取(`get` 的 force 参数)。
1773
+ *
1774
+ * 存在的理由:IdP 轮换签名密钥(kid 换新)后,缓存里那把旧 key 会让
1775
+ * `findJwk` 找不到匹配 → `no-signing-key` / `bad-signature`,
1776
+ * 而缓存 TTL 是 1 小时——等于密钥轮换后最长 1 小时无法登录。
1777
+ * 实测过:同一个进程里把 IdP 的签名算法/密钥换掉,登录立刻开始失败,
1778
+ * 重启进程才恢复(缓存被清空)。
1779
+ *
1780
+ * 调用方仅在【验签失败】且【本次用的是缓存】时才重试一次,避免把
1781
+ * 缓存彻底架空(正常路径仍走缓存,不受影响)。
1782
+ * @param {string} issuer
1783
+ * @returns {Promise<object>} 与 get 同结构
1784
+ */
1785
+ async function refresh(issuer) {
1786
+ cache.delete(issuer)
1787
+ return get(issuer)
1788
+ }
1789
+ return { get, refresh }
1767
1790
  }
1768
1791
 
1769
1792
  /**
@@ -2505,7 +2528,23 @@ export async function apply(ctx) {
2505
2528
  return
2506
2529
  }
2507
2530
  // 验证 id_token
2508
- const vres = validateIdToken(idToken, { issuer: o.issuer, clientId: o.clientId, nonce: st.nonce, jwks: disc.jwks })
2531
+ let vres = validateIdToken(idToken, { issuer: o.issuer, clientId: o.clientId, nonce: st.nonce, jwks: disc.jwks })
2532
+ // IdP 轮换签名密钥(或更换签名算法)后,进程内缓存的 JWKS 会在最长 1 小时内
2533
+ // 找不到 token 的 kid/alg。这类失败是可自愈的,但缓存不失效就让每个用户先坏一小时。
2534
+ // 因此:签名相关失败时强制绕过缓存重取一次 JWKS,再验一次。
2535
+ // 只在签名类错误上重试(no-signing-key / bad-signature);claims 类错误重试没有意义。
2536
+ if (!vres.ok && (vres.error === 'no-signing-key' || vres.error === 'bad-signature')) {
2537
+ const originalError = vres.error // 先记下原错误,后面 vres 会被覆盖
2538
+ const fresh = await oidcDiscovery.refresh(o.issuer)
2539
+ if (fresh.ok && fresh.jwks) {
2540
+ const retry = validateIdToken(idToken, { issuer: o.issuer, clientId: o.clientId, nonce: st.nonce, jwks: fresh.jwks })
2541
+ if (retry.ok) {
2542
+ vres = retry
2543
+ // 审计一次,便于运维发现 IdP 确实发生了密钥轮换(而非每次登录都触发)。
2544
+ await auditLog(ctx, 'oidc_jwks_refreshed', { ip: meta.ip, ua: meta.ua, detail: '缓存 JWKS 未命中,已强制刷新并验签通过(原错误: ' + originalError + ')' })
2545
+ }
2546
+ }
2547
+ }
2509
2548
  if (!vres.ok) {
2510
2549
  await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'id_token 验证失败: ' + vres.error })
2511
2550
  sendJson(res, 200, { ok: false, error: 'oidc-invalid-id-token', detail: vres.error })
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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/oidc-ec.test.mjs && node test/client-bundle-id.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/jwks-refresh.test.mjs && node test/client-bundle-id.test.mjs && node test/displace-original.test.mjs"
8
8
  },
9
9
  "exports": {
10
10
  ".": "./index.js",