dsh-webui-oauth 0.6.0 → 0.6.2

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,72 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.6.2
6
+
7
+ 修复设置页在**切走再切回**时不回显已有配置的问题。
8
+
9
+ ### 修复:切页返回后配置项全空,须手动刷新才显示
10
+
11
+ - **现象**:在 WebUI 里切到别的页面、再切回设置 → 身份认证,用户名、OIDC 入口、
12
+ APP ID 等输入框全部为空;手动刷新整页才恢复正常。
13
+ - **原因**:回填逻辑被一个**模块级**变量 `loadedOnce` 包着("只回填一次")。
14
+ 模块变量只在整页刷新时重置;而 SPA 内切换页面时组件会重新挂载(局部 state 归零)
15
+ 却不会复位该变量,于是整块回填被跳过。
16
+ - **修复**:改为组件内的 `useRef` 记录"本次挂载是否已回填"。组件卸载即失效,
17
+ 切回来会重新回填;同一次挂载内它仍能阻止轮询把用户正在编辑的内容冲掉。
18
+
19
+ ### 回显规则(明确化)
20
+
21
+ 除**密钥**外一律预填:
22
+
23
+ | 字段 | 是否回显 |
24
+ | --- | --- |
25
+ | 用户名 | ✅ |
26
+ | OIDC 入口(issuer) | ✅ |
27
+ | OIDC APP ID(clientId) | ✅ |
28
+ | Scope | ✅ |
29
+ | Redirect Base | ✅ |
30
+ | 完整回调地址 | ✅(0.6.1 起) |
31
+ | **Client Secret** | ❌ **永不回显**(服务端不返回;输入框留空 = 保持不变) |
32
+
33
+ ### 测试
34
+
35
+ - 新增 `test/client-prefill.test.mjs`(11 项):用一个还原了关键 React 语义
36
+ (尤其是 `useRef` 在同一挂载期内返回同一对象、卸载后换新对象)的替身直接驱动
37
+ 真实组件源码,覆盖首次进入、切走切回、连续三次切换、clientSecret 永不回显。
38
+ - 已确认这些用例能抓出修复前的行为(回退旧写法时 4 项 FAIL)。
39
+ - 全量 335 项断言通过。
40
+
41
+ ## 0.6.1
42
+
43
+ 修复 OIDC 配置保存被误拒、以及设置页看不到回调地址的问题。
44
+
45
+ ### 修复:再次保存 OIDC 配置总是失败
46
+
47
+ - **现象**:改完 issuer / scope / Redirect Base 点保存,返回
48
+ `{"ok":false,"error":"oidc-invalid","reason":"clientSecret 须为非空字符串"}`,配置存不下去。
49
+ - **原因**:设置页的 clientSecret 输入框每次打开都是空的(密钥不回传前端,这是有意的),
50
+ 而服务端把"空串"也当成非法值拒掉——与它自己「留空保留原值」的语义、以及界面提示直接矛盾。
51
+ - **修复**:空串/全空白按"未提供"处理,保留原值;只有**类型不对**才拒绝。
52
+ 同时补上真正的边界:**首次启用**(此前没有 secret)时留空仍然拒绝并说明原因,
53
+ 避免存下一个没有 secret 的配置、之后表现为莫名其妙的 `oidc-not-configured`。
54
+
55
+ ### 新增:设置页显示回调地址
56
+
57
+ - 原生 OIDC 要求 `redirect_uri` 与 IdP 登记值**精确匹配**,少一个字符都会被拒。
58
+ 此前界面只给路径常量,部署者得自己拼出完整地址(含 scheme/host/port)才能登记。
59
+ - 现在设置页在 Redirect Base 下方直接给出**完整回调地址**(如
60
+ `https://dsh.example.com/dsh-webui-oauth/oidc/callback`),可原样抄进 IdP;
61
+ 未填 Redirect Base 也无 `publicBaseUrl` 时,明确提示"无法确定"而不是猜一个错的。
62
+ - `/status` 新增 `oidc.redirectUri` 与 `oidc.callbackPath`。
63
+
64
+ ### 测试
65
+
66
+ - `test/oidc-bind.test.mjs` 扩充到 35 项:覆盖"首次启用须填 secret"、"再次保存留空被接受
67
+ 且保留原值"、"留空不得清成空串"、"secret 类型错误仍拒绝"、"回调地址为完整 URL"、
68
+ "无法确定时返回 null 不猜"。
69
+ - 全量 324 项断言通过。
70
+
5
71
  ## 0.6.0
6
72
 
7
73
  新增 **OIDC 身份绑定**:配置 OIDC 不再等于谁都能登录,必须显式绑定一个 IdP 身份。
package/index.js CHANGED
@@ -2036,6 +2036,36 @@ export async function apply(ctx) {
2036
2036
  const r = await readOidcConfig(ctx, creds)
2037
2037
  return r.oidc
2038
2038
  }
2039
+
2040
+ /**
2041
+ * 算出「本部署的 OIDC 回调地址」,供设置页展示、供部署者填进 IdP 的允许列表。
2042
+ *
2043
+ * 原生 OIDC 要求 redirect_uri 与登记值**精确匹配**(含 scheme/host/port),
2044
+ * 少一个字符都会被 IdP 拒绝,所以必须把完整地址明确告诉部署者,不能只给路径。
2045
+ *
2046
+ * 取值优先级与实际授权流程一致(见 startOidcAuthorize):
2047
+ * 1. oidc.redirectBase —— 部署者显式声明,反代场景下的确定性答案;
2048
+ * 2. remote-web-ui.publicBaseUrl —— 同一份配置的复用;
2049
+ * 3. 都没有 → 返回 null,前端提示"需先填 Redirect Base 或 publicBaseUrl"。
2050
+ * 这里【不做】浏览器 origin 推断:设置页是服务端渲染时算的,拿不到将来的请求 Host,
2051
+ * 猜一个反而会让人把错的地址填进 IdP。
2052
+ *
2053
+ * @returns {string|null} 形如 https://dsh.example.com/dsh-webui-oauth/oidc/callback
2054
+ */
2055
+ function resolveOidcRedirectUri(ctx2, oidcCfg) {
2056
+ try {
2057
+ let base = null
2058
+ if (oidcCfg && typeof oidcCfg.redirectBase === 'string' && oidcCfg.redirectBase.trim()) {
2059
+ base = oidcCfg.redirectBase.trim()
2060
+ } else {
2061
+ base = configuredOrigin(ctx2)
2062
+ }
2063
+ if (!base) return null
2064
+ return base.replace(/\/$/, '') + OIDC_CALLBACK_PATH
2065
+ } catch (e) {
2066
+ return null
2067
+ }
2068
+ }
2039
2069
  async function oidcEnabled(creds) {
2040
2070
  const o = await oidcOf(creds)
2041
2071
  return !!(o && o.enabled === true && typeof o.issuer === 'string' && typeof o.clientId === 'string' && typeof o.clientSecret === 'string')
@@ -2237,8 +2267,13 @@ export async function apply(ctx) {
2237
2267
  trustBrowserOrigin: oidcCfg.trustBrowserOrigin !== false,
2238
2268
  bound: isOidcBound(creds),
2239
2269
  boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null,
2270
+ // 提供给 IdP 登记用的确切回调地址。原生 OIDC 要求 redirect_uri 精确匹配,
2271
+ // 部署者必须把它抄进 IdP 的允许列表,因此这里直接算好给他。
2272
+ // 不能只给路径:IdP 侧需要完整的 scheme + host + port。
2273
+ redirectUri: resolveOidcRedirectUri(ctx, oidcCfg),
2274
+ callbackPath: OIDC_CALLBACK_PATH,
2240
2275
  }
2241
- : { enabled: false, bound: isOidcBound(creds), boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null },
2276
+ : { enabled: false, bound: isOidcBound(creds), boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null, redirectUri: null, callbackPath: OIDC_CALLBACK_PATH },
2242
2277
  })
2243
2278
  } catch (e) {
2244
2279
  sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
@@ -2344,11 +2379,27 @@ export async function apply(ctx) {
2344
2379
  sendJson(res, 200, { ok: false, error: 'oidc-invalid', reason: 'trustBrowserOrigin 须为 boolean' })
2345
2380
  return
2346
2381
  }
2347
- if (oidcIn.clientSecret !== undefined && (typeof oidcIn.clientSecret !== 'string' || !oidcIn.clientSecret.trim())) {
2348
- await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:clientSecret 须为非空字符串(或留空保留旧值)' })
2349
- sendJson(res, 200, { ok: false, error: 'oidc-invalid', reason: 'clientSecret 须为非空字符串' })
2382
+ // clientSecret:留空表示「保留原值」(见下方合并逻辑与 UI 提示)。
2383
+ // 早先这里把空串也当非法值拒掉,导致"第二次保存配置"必然失败——
2384
+ // 而设置页的输入框每次打开都是空的(secret 不回传前端),
2385
+ // 于是「改了 issuer/scope 再保存」这条路根本走不通,用户只能反复重填 secret。
2386
+ // 只有【类型不对】才算非法;空串/全空白按"未提供"处理。
2387
+ if (oidcIn.clientSecret !== undefined && typeof oidcIn.clientSecret !== 'string') {
2388
+ await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:clientSecret 须为字符串' })
2389
+ sendJson(res, 200, { ok: false, error: 'oidc-invalid', reason: 'clientSecret 须为字符串' })
2350
2390
  return
2351
2391
  }
2392
+ // 首次配置(尚无旧 secret)时必须提供:否则会存下一个没有 secret 的配置,
2393
+ // oidcEnabled() 判定为未启用,用户会看到莫名其妙的 oidc-not-configured。
2394
+ if (oidcIn.enabled === true && !String(oidcIn.clientSecret || '').trim()) {
2395
+ const prev = (await readOidcConfig(ctx, creds)).oidc
2396
+ const hasPrev = !!(prev && typeof prev.clientSecret === 'string' && prev.clientSecret.trim())
2397
+ if (!hasPrev) {
2398
+ await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:首次启用必须提供 clientSecret' })
2399
+ sendJson(res, 200, { ok: false, error: 'oidc-invalid', reason: '首次启用 OIDC 必须填写 clientSecret' })
2400
+ return
2401
+ }
2402
+ }
2352
2403
  }
2353
2404
  // 合并 OIDC 配置:oidcIn 提供时整段替换(secret 留空则保留旧值);否则关闭。
2354
2405
  let oidcOut = null
package/lib/client.js CHANGED
@@ -61,7 +61,18 @@ window.__ModuleLoader__.load({
61
61
  }
62
62
 
63
63
  // ---------------- 设置页(settings.section) ----------------
64
- let loadedOnce = false;
64
+ //
65
+ // 变量命名说明:这里**不再**用模块级的"只回填一次"开关。
66
+ // 曾经的写法是 `let loadedOnce = false` 放在模块作用域,配合
67
+ // `if (!loadedOnce) { ...回填... }`。问题在于模块变量只在【整页刷新】
68
+ // 时重置:用户切到别的页面、再切回设置页时,React 会重新挂载组件
69
+ // (局部 state 归零),但 loadedOnce 仍是 true,于是整块回填被跳过,
70
+ // 表现为"切页回来字段全空、手动刷新才正常"。
71
+ //
72
+ // 正确做法:把"是否已回填"放进组件自身的 state 生命周期。
73
+ // 用一个 ref 记录本次挂载是否已灌过值 —— 组件卸载即失效,
74
+ // 下次挂载自然会重新回填;同时它仍然防止轮询(load 定时刷新)
75
+ // 把用户正在编辑的内容冲掉。
65
76
  function AuthSettings() {
66
77
  const [status, setStatus] = react.useState(null);
67
78
  const [username, setUsername] = react.useState('');
@@ -86,6 +97,13 @@ window.__ModuleLoader__.load({
86
97
  const [oidcBoundHint, setOidcBoundHint] = react.useState('');
87
98
  const [unbindPw, setUnbindPw] = react.useState('');
88
99
  const [bindMsg, setBindMsg] = react.useState(null);
100
+ // 服务端算好的完整回调地址(含 scheme/host/port)。原生 OIDC 要求
101
+ // redirect_uri 与 IdP 登记值精确匹配,少一个字符都会被拒,所以要显示全。
102
+ const [oidcRedirectUri, setOidcRedirectUri] = react.useState('');
103
+ // 本次挂载是否已灌过初始值。放在组件内(而非模块级):
104
+ // 组件卸载即失效,切页回来会重新回填;同一次挂载内它仍然阻止
105
+ // 轮询把用户正在编辑的内容冲掉。
106
+ const prefilled = react.useRef(false);
89
107
 
90
108
  react.useEffect(() => {
91
109
  let alive = true;
@@ -94,19 +112,24 @@ window.__ModuleLoader__.load({
94
112
  if (!s || s.error) return;
95
113
  setStatus(s);
96
114
  setTtl(String(typeof s.ttl === 'number' ? s.ttl : 12));
97
- if (!loadedOnce) {
98
- loadedOnce = true;
115
+ if (!prefilled.current) {
116
+ prefilled.current = true;
117
+ // 回显规则:除了密钥,其余全部预填。
118
+ // - 用户名、OIDC 入口(issuer)、APP ID(clientId)、Scope、
119
+ // Redirect Base —— 都是非敏感且用户需要看到当前值的。
120
+ // - clientSecret 刻意【不】回显:服务端根本不回传它
121
+ // (见 status 实现),输入框留空即代表"保持不变"。
99
122
  setUsername(s.username || '');
100
123
  const o = (s && s.oidc) || null;
101
124
  setOidcEnabled(!!(o && o.enabled));
102
125
  setOidcIssuer((o && o.issuer) || '');
103
126
  setOidcTrustBrowser(!o || o.trustBrowserOrigin !== false);
104
- // clientSecret 不回传(敏感),clientId/scope/redirectBase 尽量回填
105
127
  setOidcClientId((o && o.clientId) || '');
106
128
  if (o && o.scope) setOidcScope(o.scope);
107
129
  if (o && o.redirectBase) setOidcRedirectBase(o.redirectBase);
108
130
  setOidcBound(!!(o && o.bound));
109
131
  setOidcBoundHint((o && o.boundSubHint) || '');
132
+ setOidcRedirectUri((o && o.redirectUri) || '');
110
133
  }
111
134
  });
112
135
  load();
@@ -401,6 +424,16 @@ window.__ModuleLoader__.load({
401
424
  react.createElement('label', { className: 'wua-label' }, 'Redirect Base(可选;反代重写 Host 时必填,如 https://dsh.example.com)'),
402
425
  react.createElement('input', { className: 'wua-input', type: 'text', value: oidcRedirectBase, spellCheck: false, onChange: (e) => setOidcRedirectBase(e.target.value) }),
403
426
  ),
427
+ // 回调地址:给部署者抄进 IdP 的允许列表用。
428
+ // 原生 OIDC 要求精确匹配,必须显示完整地址(含协议与端口)。
429
+ react.createElement('div', { className: 'wua-row' },
430
+ react.createElement('label', { className: 'wua-label' }, '回调地址(填入 IdP 的 Redirect URI 允许列表,须精确匹配)'),
431
+ oidcRedirectUri
432
+ ? react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0, wordBreak: 'break-all' } },
433
+ react.createElement('code', null, oidcRedirectUri))
434
+ : react.createElement('p', { className: 'wua-warn' },
435
+ '无法确定回调地址:请先填写上方 Redirect Base(或在本插件的 remote-web-ui 设置里配置 publicBaseUrl)。'),
436
+ ),
404
437
  react.createElement('div', { className: 'wua-row' },
405
438
  react.createElement('label', { className: 'wua-label' },
406
439
  react.createElement('input', { type: 'checkbox', checked: oidcTrustBrowser, onChange: (e) => setOidcTrustBrowser(e.target.checked) }),
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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/jwks-refresh.test.mjs && node test/oidc-bind.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/oidc-bind.test.mjs && node test/client-bundle-id.test.mjs && node test/client-prefill.test.mjs && node test/displace-original.test.mjs"
8
8
  },
9
9
  "exports": {
10
10
  ".": "./index.js",