dsh-webui-oauth 0.6.0 → 0.6.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,36 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.6.1
6
+
7
+ 修复 OIDC 配置保存被误拒、以及设置页看不到回调地址的问题。
8
+
9
+ ### 修复:再次保存 OIDC 配置总是失败
10
+
11
+ - **现象**:改完 issuer / scope / Redirect Base 点保存,返回
12
+ `{"ok":false,"error":"oidc-invalid","reason":"clientSecret 须为非空字符串"}`,配置存不下去。
13
+ - **原因**:设置页的 clientSecret 输入框每次打开都是空的(密钥不回传前端,这是有意的),
14
+ 而服务端把"空串"也当成非法值拒掉——与它自己「留空保留原值」的语义、以及界面提示直接矛盾。
15
+ - **修复**:空串/全空白按"未提供"处理,保留原值;只有**类型不对**才拒绝。
16
+ 同时补上真正的边界:**首次启用**(此前没有 secret)时留空仍然拒绝并说明原因,
17
+ 避免存下一个没有 secret 的配置、之后表现为莫名其妙的 `oidc-not-configured`。
18
+
19
+ ### 新增:设置页显示回调地址
20
+
21
+ - 原生 OIDC 要求 `redirect_uri` 与 IdP 登记值**精确匹配**,少一个字符都会被拒。
22
+ 此前界面只给路径常量,部署者得自己拼出完整地址(含 scheme/host/port)才能登记。
23
+ - 现在设置页在 Redirect Base 下方直接给出**完整回调地址**(如
24
+ `https://dsh.example.com/dsh-webui-oauth/oidc/callback`),可原样抄进 IdP;
25
+ 未填 Redirect Base 也无 `publicBaseUrl` 时,明确提示"无法确定"而不是猜一个错的。
26
+ - `/status` 新增 `oidc.redirectUri` 与 `oidc.callbackPath`。
27
+
28
+ ### 测试
29
+
30
+ - `test/oidc-bind.test.mjs` 扩充到 35 项:覆盖"首次启用须填 secret"、"再次保存留空被接受
31
+ 且保留原值"、"留空不得清成空串"、"secret 类型错误仍拒绝"、"回调地址为完整 URL"、
32
+ "无法确定时返回 null 不猜"。
33
+ - 全量 324 项断言通过。
34
+
5
35
  ## 0.6.0
6
36
 
7
37
  新增 **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
@@ -86,6 +86,9 @@ window.__ModuleLoader__.load({
86
86
  const [oidcBoundHint, setOidcBoundHint] = react.useState('');
87
87
  const [unbindPw, setUnbindPw] = react.useState('');
88
88
  const [bindMsg, setBindMsg] = react.useState(null);
89
+ // 服务端算好的完整回调地址(含 scheme/host/port)。原生 OIDC 要求
90
+ // redirect_uri 与 IdP 登记值精确匹配,少一个字符都会被拒,所以要显示全。
91
+ const [oidcRedirectUri, setOidcRedirectUri] = react.useState('');
89
92
 
90
93
  react.useEffect(() => {
91
94
  let alive = true;
@@ -107,6 +110,7 @@ window.__ModuleLoader__.load({
107
110
  if (o && o.redirectBase) setOidcRedirectBase(o.redirectBase);
108
111
  setOidcBound(!!(o && o.bound));
109
112
  setOidcBoundHint((o && o.boundSubHint) || '');
113
+ setOidcRedirectUri((o && o.redirectUri) || '');
110
114
  }
111
115
  });
112
116
  load();
@@ -401,6 +405,16 @@ window.__ModuleLoader__.load({
401
405
  react.createElement('label', { className: 'wua-label' }, 'Redirect Base(可选;反代重写 Host 时必填,如 https://dsh.example.com)'),
402
406
  react.createElement('input', { className: 'wua-input', type: 'text', value: oidcRedirectBase, spellCheck: false, onChange: (e) => setOidcRedirectBase(e.target.value) }),
403
407
  ),
408
+ // 回调地址:给部署者抄进 IdP 的允许列表用。
409
+ // 原生 OIDC 要求精确匹配,必须显示完整地址(含协议与端口)。
410
+ react.createElement('div', { className: 'wua-row' },
411
+ react.createElement('label', { className: 'wua-label' }, '回调地址(填入 IdP 的 Redirect URI 允许列表,须精确匹配)'),
412
+ oidcRedirectUri
413
+ ? react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0, wordBreak: 'break-all' } },
414
+ react.createElement('code', null, oidcRedirectUri))
415
+ : react.createElement('p', { className: 'wua-warn' },
416
+ '无法确定回调地址:请先填写上方 Redirect Base(或在本插件的 remote-web-ui 设置里配置 publicBaseUrl)。'),
417
+ ),
404
418
  react.createElement('div', { className: 'wua-row' },
405
419
  react.createElement('label', { className: 'wua-label' },
406
420
  react.createElement('input', { type: 'checkbox', checked: oidcTrustBrowser, onChange: (e) => setOidcTrustBrowser(e.target.checked) }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {