dsh-webui-oauth 0.6.1 → 0.6.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/CHANGELOG.md CHANGED
@@ -2,6 +2,71 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.6.3
6
+
7
+ 修复部分 IdP 下**绑定 OIDC 立即失败**的问题(点「绑定 OIDC 身份」后立刻收到
8
+ `oidc-authorize-failed / invalid_request`)。
9
+
10
+ ### 修复:授权请求携带了 IdP 不支持的 prompt 参数
11
+
12
+ - **现象**:点绑定后浏览器被 IdP 打回,回调形如
13
+ `.../oidc/callback?error=invalid_request&error_description=unsupported+prompt+value+requested`,
14
+ 界面显示 `{"ok":false,"error":"oidc-authorize-failed","detail":"invalid_request"}`。
15
+ - **原因**:插件在授权请求里**硬编码**了 `prompt=select_account`。该参数是 OIDC 的
16
+ 可选扩展,不同 IdP 支持度差异极大;不支持的 IdP 会直接拒绝整个授权请求,
17
+ 于是授权根本发不出去。
18
+ - **修复**:默认**不发送** `prompt`。需要"强制选择账号"行为的部署可在设置页
19
+ **Prompt(可选)** 里显式填写(如 `select_account`),留空即不携带该参数。
20
+
21
+ ### 建议
22
+
23
+ - 若你此前被此问题挡住,升级后**重新点一次绑定**即可,无需改动其它配置。
24
+ - 多数 IdP 的登录页本来就会让用户选择账号,因此该参数通常不必填。
25
+
26
+ ### 测试
27
+
28
+ - `test/oidc-integration.test.mjs` 增加两条断言:默认不携带 `prompt`、
29
+ 显式配置时如实携带。已确认能抓出修复前的行为(回退硬编码时 2 项 FAIL)。
30
+ - 另在隔离实例上用**会拒绝 prompt 的 IdP**(复刻同类行为)做了端到端对照:
31
+ 带 `prompt` 复现出完全相同的错误;去掉后绑定成功、随后 SSO 登录成功。
32
+ - 全量 337 项断言通过。
33
+
34
+ ## 0.6.2
35
+
36
+ 修复设置页在**切走再切回**时不回显已有配置的问题。
37
+
38
+ ### 修复:切页返回后配置项全空,须手动刷新才显示
39
+
40
+ - **现象**:在 WebUI 里切到别的页面、再切回设置 → 身份认证,用户名、OIDC 入口、
41
+ APP ID 等输入框全部为空;手动刷新整页才恢复正常。
42
+ - **原因**:回填逻辑被一个**模块级**变量 `loadedOnce` 包着("只回填一次")。
43
+ 模块变量只在整页刷新时重置;而 SPA 内切换页面时组件会重新挂载(局部 state 归零)
44
+ 却不会复位该变量,于是整块回填被跳过。
45
+ - **修复**:改为组件内的 `useRef` 记录"本次挂载是否已回填"。组件卸载即失效,
46
+ 切回来会重新回填;同一次挂载内它仍能阻止轮询把用户正在编辑的内容冲掉。
47
+
48
+ ### 回显规则(明确化)
49
+
50
+ 除**密钥**外一律预填:
51
+
52
+ | 字段 | 是否回显 |
53
+ | --- | --- |
54
+ | 用户名 | ✅ |
55
+ | OIDC 入口(issuer) | ✅ |
56
+ | OIDC APP ID(clientId) | ✅ |
57
+ | Scope | ✅ |
58
+ | Redirect Base | ✅ |
59
+ | 完整回调地址 | ✅(0.6.1 起) |
60
+ | **Client Secret** | ❌ **永不回显**(服务端不返回;输入框留空 = 保持不变) |
61
+
62
+ ### 测试
63
+
64
+ - 新增 `test/client-prefill.test.mjs`(11 项):用一个还原了关键 React 语义
65
+ (尤其是 `useRef` 在同一挂载期内返回同一对象、卸载后换新对象)的替身直接驱动
66
+ 真实组件源码,覆盖首次进入、切走切回、连续三次切换、clientSecret 永不回显。
67
+ - 已确认这些用例能抓出修复前的行为(回退旧写法时 4 项 FAIL)。
68
+ - 全量 335 项断言通过。
69
+
5
70
  ## 0.6.1
6
71
 
7
72
  修复 OIDC 配置保存被误拒、以及设置页看不到回调地址的问题。
package/index.js CHANGED
@@ -2267,6 +2267,8 @@ export async function apply(ctx) {
2267
2267
  trustBrowserOrigin: oidcCfg.trustBrowserOrigin !== false,
2268
2268
  bound: isOidcBound(creds),
2269
2269
  boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null,
2270
+ // prompt 是可选参数,回传当前值供设置页回显(缺省为空 = 不发送)。
2271
+ prompt: typeof oidcCfg.prompt === 'string' ? oidcCfg.prompt : '',
2270
2272
  // 提供给 IdP 登记用的确切回调地址。原生 OIDC 要求 redirect_uri 精确匹配,
2271
2273
  // 部署者必须把它抄进 IdP 的允许列表,因此这里直接算好给他。
2272
2274
  // 不能只给路径:IdP 侧需要完整的 scheme + host + port。
@@ -2415,6 +2417,9 @@ export async function apply(ctx) {
2415
2417
  scope: typeof oidcIn.scope === 'string' ? oidcIn.scope : undefined,
2416
2418
  redirectBase: typeof oidcIn.redirectBase === 'string' && oidcIn.redirectBase.trim() ? oidcIn.redirectBase.trim() : undefined,
2417
2419
  trustBrowserOrigin: oidcIn.trustBrowserOrigin !== undefined ? oidcIn.trustBrowserOrigin : true,
2420
+ // prompt 默认不发(留空即 undefined)。仅在部署者显式填写时才带给 IdP,
2421
+ // 因为不少 IdP 会因不认识的 prompt 值直接拒绝授权。
2422
+ prompt: typeof oidcIn.prompt === 'string' && oidcIn.prompt.trim() ? oidcIn.prompt.trim() : undefined,
2418
2423
  }
2419
2424
  } else if (oidcIn && oidcIn.enabled === false) {
2420
2425
  oidcOut = { enabled: false }
@@ -2543,7 +2548,15 @@ export async function apply(ctx) {
2543
2548
  authUrl.searchParams.set('nonce', nonce)
2544
2549
  authUrl.searchParams.set('code_challenge', oidcChallenge(verifier))
2545
2550
  authUrl.searchParams.set('code_challenge_method', 'S256')
2546
- authUrl.searchParams.set('prompt', 'select_account')
2551
+ // prompt 是 OIDC 的【可选】参数,且不同 IdP 支持度差异极大:
2552
+ // 部分 IdP(如某些企业 SSO)对不认识的 prompt 值直接回
2553
+ // error=invalid_request&error_description=unsupported prompt value requested,
2554
+ // 导致授权根本发不出去——表现就是"点绑定立刻失败"。
2555
+ // 因此这里【默认不发送】:多数 IdP 的登录页本来就会让用户选账号,
2556
+ // 显式 select_account 只对"已登录且想强制切换账号"的场景有意义。
2557
+ // 需要该行为的部署可通过 oidc.prompt 显式声明(见 README)。
2558
+ const promptValue = typeof o.prompt === 'string' ? o.prompt.trim() : ''
2559
+ if (promptValue) authUrl.searchParams.set('prompt', promptValue)
2547
2560
  // 登录 CSRF 绑定:把 state 同时写进一个 HttpOnly 短时效 Cookie。回调时两者必须
2548
2561
  // 同时匹配——否则攻击者可先在自己的浏览器发起授权拿到 state,再诱导受害者带着
2549
2562
  // 该 state 完成回调,把受害者的浏览器登录进攻击者的账号(会话固定/登录 CSRF)。
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('');
@@ -80,6 +91,9 @@ window.__ModuleLoader__.load({
80
91
  const [oidcScope, setOidcScope] = react.useState('openid profile email');
81
92
  const [oidcRedirectBase, setOidcRedirectBase] = react.useState('');
82
93
  const [oidcTrustBrowser, setOidcTrustBrowser] = react.useState(true);
94
+ // prompt(可选)。默认留空 = 不发送该参数——不少 IdP 会对不认识的
95
+ // prompt 值直接回 unsupported prompt value requested,导致授权发不出去。
96
+ const [oidcPrompt, setOidcPrompt] = react.useState('');
83
97
  // 绑定状态:bound 表示已绑定某个 IdP 身份;boundSubHint 是掩码后的展示值
84
98
  // (服务端只回传掩码,前端拿不到 sub 明文,也不应该拿)。
85
99
  const [oidcBound, setOidcBound] = react.useState(false);
@@ -89,6 +103,10 @@ window.__ModuleLoader__.load({
89
103
  // 服务端算好的完整回调地址(含 scheme/host/port)。原生 OIDC 要求
90
104
  // redirect_uri 与 IdP 登记值精确匹配,少一个字符都会被拒,所以要显示全。
91
105
  const [oidcRedirectUri, setOidcRedirectUri] = react.useState('');
106
+ // 本次挂载是否已灌过初始值。放在组件内(而非模块级):
107
+ // 组件卸载即失效,切页回来会重新回填;同一次挂载内它仍然阻止
108
+ // 轮询把用户正在编辑的内容冲掉。
109
+ const prefilled = react.useRef(false);
92
110
 
93
111
  react.useEffect(() => {
94
112
  let alive = true;
@@ -97,17 +115,22 @@ window.__ModuleLoader__.load({
97
115
  if (!s || s.error) return;
98
116
  setStatus(s);
99
117
  setTtl(String(typeof s.ttl === 'number' ? s.ttl : 12));
100
- if (!loadedOnce) {
101
- loadedOnce = true;
118
+ if (!prefilled.current) {
119
+ prefilled.current = true;
120
+ // 回显规则:除了密钥,其余全部预填。
121
+ // - 用户名、OIDC 入口(issuer)、APP ID(clientId)、Scope、
122
+ // Redirect Base —— 都是非敏感且用户需要看到当前值的。
123
+ // - clientSecret 刻意【不】回显:服务端根本不回传它
124
+ // (见 status 实现),输入框留空即代表"保持不变"。
102
125
  setUsername(s.username || '');
103
126
  const o = (s && s.oidc) || null;
104
127
  setOidcEnabled(!!(o && o.enabled));
105
128
  setOidcIssuer((o && o.issuer) || '');
106
129
  setOidcTrustBrowser(!o || o.trustBrowserOrigin !== false);
107
- // clientSecret 不回传(敏感),clientId/scope/redirectBase 尽量回填
108
130
  setOidcClientId((o && o.clientId) || '');
109
131
  if (o && o.scope) setOidcScope(o.scope);
110
132
  if (o && o.redirectBase) setOidcRedirectBase(o.redirectBase);
133
+ if (o && typeof o.prompt === 'string') setOidcPrompt(o.prompt);
111
134
  setOidcBound(!!(o && o.bound));
112
135
  setOidcBoundHint((o && o.boundSubHint) || '');
113
136
  setOidcRedirectUri((o && o.redirectUri) || '');
@@ -167,6 +190,8 @@ window.__ModuleLoader__.load({
167
190
  scope: oidcScope.trim(),
168
191
  redirectBase: oidcRedirectBase.trim() || null,
169
192
  trustBrowserOrigin: oidcTrustBrowser,
193
+ // 留空即不发送 prompt(服务端会存成 undefined)
194
+ prompt: oidcPrompt.trim(),
170
195
  };
171
196
  api('/dsh-webui-oauth/configure', { username: name, password, current, ttl: Number(ttl), oidc: oidcPayload }).then((r) => {
172
197
  setBusy(false);
@@ -420,6 +445,12 @@ window.__ModuleLoader__.load({
420
445
  react.createElement('input', { type: 'checkbox', checked: oidcTrustBrowser, onChange: (e) => setOidcTrustBrowser(e.target.checked) }),
421
446
  ' 信任浏览器 origin(默认开启;关闭时仅用上方 Redirect Base)'),
422
447
  ),
448
+ react.createElement('div', { className: 'wua-row' },
449
+ react.createElement('label', { className: 'wua-label' }, 'Prompt(可选,建议留空)'),
450
+ react.createElement('input', { className: 'wua-input', type: 'text', value: oidcPrompt, spellCheck: false, placeholder: '留空则不发送该参数', onChange: (e) => setOidcPrompt(e.target.value) }),
451
+ react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
452
+ '多数 IdP 无需该参数。部分 IdP 会对不认识的值直接拒绝授权(提示 unsupported prompt value requested),此时请保持留空。'),
453
+ ),
423
454
  ) : null,
424
455
  // ---- OIDC 身份绑定 ----
425
456
  // 绑定是访问控制前提:未绑定时任何能通过该 IdP 的人都会被拒,
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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",