dsh-webui-oauth 0.6.2 → 0.6.4

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,66 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.6.4
6
+
7
+ 修复 OIDC 回调结束后浏览器**停在 JSON 页面上**、需要手动回退的问题。
8
+
9
+ ### 修复:绑定/登录结束后自动回到应用并给出提示
10
+
11
+ - **现象**:点「绑定 OIDC 身份」完成 IdP 往返后,浏览器停在一段 JSON 上
12
+ (如 `{"ok":true,"bound":true,"sub":"..."}`),必须手动按后退键回到设置页;
13
+ 失败时同样如此(例如 `{"ok":false,"error":"oidc-authorize-failed",...}`)。
14
+ - **原因**:回调端点直接 `sendJson` 渲染结果。但 OIDC 回调是**浏览器主导的顶层导航**
15
+ (用户点按钮 → 302 到 IdP → 302 回回调),渲染 JSON 等于把用户丢在一个死页面上,
16
+ 而且看不出操作到底成没成功。
17
+ - **修复**:成功与失败都 **302 回应用**,并带一个回执参数由前端提示:
18
+ - 成功:`?oidcbound=1` → 设置页提示「OIDC 身份绑定成功」;
19
+ - 失败:`?oidcerr=<原因>` → 按原因给出可读提示(授权失效 / 未绑定 / 身份不匹配)。
20
+ 回跳复用 `postLoginRedirect()`,顺带完成核心的 launch-token → cookie 交换,
21
+ 否则回到首页仍会被核心 401 拦住。前端读取回执后会用 `history.replaceState`
22
+ 清掉 query,避免刷新时重复提示。
23
+
24
+ ### 安全
25
+
26
+ - 回执里**不含** sub 等敏感值(只有 `oidcbound=1` / `oidcerr=原因码`),
27
+ 避免把身份标识写进浏览器历史与访问日志。
28
+
29
+ ### 测试
30
+
31
+ - `test/oidc-bind.test.mjs` 新增「绑定成功后必须回到应用」用例,并把失败路径断言
32
+ 改为校验 302 + 回执;`test/oidc-integration.test.mjs` 的状态校验用例同步更新。
33
+ - 已确认能抓出修复前的行为(回退成 sendJson 时 2 项 FAIL)。
34
+ - 全量 342 项断言通过。
35
+
36
+ ## 0.6.3
37
+
38
+ 修复部分 IdP 下**绑定 OIDC 立即失败**的问题(点「绑定 OIDC 身份」后立刻收到
39
+ `oidc-authorize-failed / invalid_request`)。
40
+
41
+ ### 修复:授权请求携带了 IdP 不支持的 prompt 参数
42
+
43
+ - **现象**:点绑定后浏览器被 IdP 打回,回调形如
44
+ `.../oidc/callback?error=invalid_request&error_description=unsupported+prompt+value+requested`,
45
+ 界面显示 `{"ok":false,"error":"oidc-authorize-failed","detail":"invalid_request"}`。
46
+ - **原因**:插件在授权请求里**硬编码**了 `prompt=select_account`。该参数是 OIDC 的
47
+ 可选扩展,不同 IdP 支持度差异极大;不支持的 IdP 会直接拒绝整个授权请求,
48
+ 于是授权根本发不出去。
49
+ - **修复**:默认**不发送** `prompt`。需要"强制选择账号"行为的部署可在设置页
50
+ **Prompt(可选)** 里显式填写(如 `select_account`),留空即不携带该参数。
51
+
52
+ ### 建议
53
+
54
+ - 若你此前被此问题挡住,升级后**重新点一次绑定**即可,无需改动其它配置。
55
+ - 多数 IdP 的登录页本来就会让用户选择账号,因此该参数通常不必填。
56
+
57
+ ### 测试
58
+
59
+ - `test/oidc-integration.test.mjs` 增加两条断言:默认不携带 `prompt`、
60
+ 显式配置时如实携带。已确认能抓出修复前的行为(回退硬编码时 2 项 FAIL)。
61
+ - 另在隔离实例上用**会拒绝 prompt 的 IdP**(复刻同类行为)做了端到端对照:
62
+ 带 `prompt` 复现出完全相同的错误;去掉后绑定成功、随后 SSO 登录成功。
63
+ - 全量 337 项断言通过。
64
+
5
65
  ## 0.6.2
6
66
 
7
67
  修复设置页在**切走再切回**时不回显已有配置的问题。
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 }
@@ -2502,6 +2507,29 @@ export async function apply(ctx) {
2502
2507
  *
2503
2508
  * @returns {Promise<void>} 直接写响应,不返回内容
2504
2509
  */
2510
+ /**
2511
+ * 把浏览器送回应用,并带上一个回执码。
2512
+ *
2513
+ * 为什么需要它:OIDC 回调是**浏览器主导的顶层导航**——用户点「绑定」/「SSO 登录」,
2514
+ * 浏览器 302 到 IdP,IdP 再 302 回本回调。如果这里渲染 JSON,用户就停在一个
2515
+ * 死页面上,只能手动按后退键,而且不知道操作到底成没成功。
2516
+ * 因此成功与失败都应 302 回应用,用 query 参数带上结果,由前端提示。
2517
+ *
2518
+ * 与登录路径一致地复用 postLoginRedirect():既回到应用根,又能顺带完成
2519
+ * 核心的 launch-token → cookie 交换(否则回到首页仍会被核心 401 拦住)。
2520
+ *
2521
+ * @param res 响应对象
2522
+ * @param code 回执码,前端据此展示提示(如 oidcbound=1 / oidcerr=...)
2523
+ * @param extraHeaders 额外的响应头(如清除一次性 state Cookie)
2524
+ */
2525
+ function redirectBackToApp(ctx2, req, res, code, extraHeaders) {
2526
+ let target = '/'
2527
+ try { target = postLoginRedirect(ctx2, req) || '/' } catch (e) { target = '/' }
2528
+ const sep = target.includes('?') ? '&' : '?'
2529
+ res.writeHead(302, { location: target + sep + code, ...(extraHeaders || {}) })
2530
+ res.end()
2531
+ }
2532
+
2505
2533
  async function startOidcAuthorize(ctx2, req, res, purpose) {
2506
2534
  const creds = await readCredentials(ctx2)
2507
2535
  const o = await oidcOf(creds)
@@ -2543,7 +2571,15 @@ export async function apply(ctx) {
2543
2571
  authUrl.searchParams.set('nonce', nonce)
2544
2572
  authUrl.searchParams.set('code_challenge', oidcChallenge(verifier))
2545
2573
  authUrl.searchParams.set('code_challenge_method', 'S256')
2546
- authUrl.searchParams.set('prompt', 'select_account')
2574
+ // prompt 是 OIDC 的【可选】参数,且不同 IdP 支持度差异极大:
2575
+ // 部分 IdP(如某些企业 SSO)对不认识的 prompt 值直接回
2576
+ // error=invalid_request&error_description=unsupported prompt value requested,
2577
+ // 导致授权根本发不出去——表现就是"点绑定立刻失败"。
2578
+ // 因此这里【默认不发送】:多数 IdP 的登录页本来就会让用户选账号,
2579
+ // 显式 select_account 只对"已登录且想强制切换账号"的场景有意义。
2580
+ // 需要该行为的部署可通过 oidc.prompt 显式声明(见 README)。
2581
+ const promptValue = typeof o.prompt === 'string' ? o.prompt.trim() : ''
2582
+ if (promptValue) authUrl.searchParams.set('prompt', promptValue)
2547
2583
  // 登录 CSRF 绑定:把 state 同时写进一个 HttpOnly 短时效 Cookie。回调时两者必须
2548
2584
  // 同时匹配——否则攻击者可先在自己的浏览器发起授权拿到 state,再诱导受害者带着
2549
2585
  // 该 state 完成回调,把受害者的浏览器登录进攻击者的账号(会话固定/登录 CSRF)。
@@ -2675,20 +2711,20 @@ export async function apply(ctx) {
2675
2711
  if (!st) {
2676
2712
  const why = known ? 'state 已过期(TTL ' + Math.round(OIDC_STATE_TTL_MS / 60000) + ' 分钟)' : 'state 无效、已使用或不存在'
2677
2713
  await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: why })
2678
- sendJson(res, 200, { ok: false, error: 'oidc-invalid-state' }, clearStateCookie)
2714
+ redirectBackToApp(ctx, req, res, 'oidcerr=invalid-state', clearStateCookie)
2679
2715
  return
2680
2716
  }
2681
2717
  // 登录 CSRF 绑定:回调必须由发起授权的同一浏览器完成(携带同值 Cookie)。
2682
2718
  // 只校验 state 存在于服务端集合不足以阻止“攻击者取 state、受害者完成回调”。
2683
2719
  if (cookieOf(req, COOKIE_OIDC_STATE) !== state) {
2684
2720
  await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'state Cookie 不匹配(疑似登录 CSRF)' })
2685
- sendJson(res, 200, { ok: false, error: 'oidc-invalid-state' }, clearStateCookie)
2721
+ redirectBackToApp(ctx, req, res, 'oidcerr=invalid-state', clearStateCookie)
2686
2722
  return
2687
2723
  }
2688
2724
  if (!code) {
2689
2725
  const err = params.get('error') || 'missing-code'
2690
2726
  await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: '授权被拒或缺少 code: ' + sanitizeSub(err) })
2691
- sendJson(res, 200, { ok: false, error: 'oidc-authorize-failed', detail: err })
2727
+ redirectBackToApp(ctx, req, res, 'oidcerr=' + encodeURIComponent(err), clearStateCookie)
2692
2728
  return
2693
2729
  }
2694
2730
  // 换 token(authorization_code + PKCE verifier + client_secret)
@@ -2748,7 +2784,7 @@ export async function apply(ctx) {
2748
2784
  }
2749
2785
  if (!vres.ok) {
2750
2786
  await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'id_token 验证失败: ' + vres.error })
2751
- sendJson(res, 200, { ok: false, error: 'oidc-invalid-id-token', detail: vres.error })
2787
+ redirectBackToApp(ctx, req, res, 'oidcerr=' + encodeURIComponent(vres.error), clearStateCookie)
2752
2788
  return
2753
2789
  }
2754
2790
  const rawSub = vres.payload.sub
@@ -2761,7 +2797,8 @@ export async function apply(ctx) {
2761
2797
  // 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
2762
2798
  await writeCredentials(ctx, { ...creds, boundSub: rawSub })
2763
2799
  await auditLog(ctx, 'oidc_bind_success', { username: auditName, ip: meta.ip, ua: meta.ua })
2764
- sendJson(res, 200, { ok: true, bound: true, sub: auditName }, clearStateCookie)
2800
+ // 成功必须【把浏览器送回应用】,而不是停在 JSON 上(见 redirectBackToApp 说明)。
2801
+ redirectBackToApp(ctx, req, res, 'oidcbound=1', clearStateCookie)
2765
2802
  return
2766
2803
  }
2767
2804
 
@@ -2771,12 +2808,12 @@ export async function apply(ctx) {
2771
2808
  const bound = boundSubOf(creds)
2772
2809
  if (!bound) {
2773
2810
  await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '尚未绑定 OIDC 身份,拒绝登录' })
2774
- sendJson(res, 200, { ok: false, error: 'oidc-not-bound' }, clearStateCookie)
2811
+ redirectBackToApp(ctx, req, res, 'oidcerr=not-bound', clearStateCookie)
2775
2812
  return
2776
2813
  }
2777
2814
  if (!safeTokenEquals(String(rawSub), bound)) {
2778
2815
  await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: 'sub 与已绑定身份不匹配,拒绝登录' })
2779
- sendJson(res, 200, { ok: false, error: 'oidc-sub-mismatch', detail: '该 IdP 身份未被绑定到本 WebUI' }, clearStateCookie)
2816
+ redirectBackToApp(ctx, req, res, 'oidcerr=sub-mismatch', clearStateCookie)
2780
2817
  return
2781
2818
  }
2782
2819
 
package/lib/client.js CHANGED
@@ -91,6 +91,9 @@ window.__ModuleLoader__.load({
91
91
  const [oidcScope, setOidcScope] = react.useState('openid profile email');
92
92
  const [oidcRedirectBase, setOidcRedirectBase] = react.useState('');
93
93
  const [oidcTrustBrowser, setOidcTrustBrowser] = react.useState(true);
94
+ // prompt(可选)。默认留空 = 不发送该参数——不少 IdP 会对不认识的
95
+ // prompt 值直接回 unsupported prompt value requested,导致授权发不出去。
96
+ const [oidcPrompt, setOidcPrompt] = react.useState('');
94
97
  // 绑定状态:bound 表示已绑定某个 IdP 身份;boundSubHint 是掩码后的展示值
95
98
  // (服务端只回传掩码,前端拿不到 sub 明文,也不应该拿)。
96
99
  const [oidcBound, setOidcBound] = react.useState(false);
@@ -127,6 +130,7 @@ window.__ModuleLoader__.load({
127
130
  setOidcClientId((o && o.clientId) || '');
128
131
  if (o && o.scope) setOidcScope(o.scope);
129
132
  if (o && o.redirectBase) setOidcRedirectBase(o.redirectBase);
133
+ if (o && typeof o.prompt === 'string') setOidcPrompt(o.prompt);
130
134
  setOidcBound(!!(o && o.bound));
131
135
  setOidcBoundHint((o && o.boundSubHint) || '');
132
136
  setOidcRedirectUri((o && o.redirectUri) || '');
@@ -142,6 +146,34 @@ window.__ModuleLoader__.load({
142
146
  };
143
147
  }, []);
144
148
 
149
+ // 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
150
+ // 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
151
+ // 组件重新挂载时才知道刚才发生了什么。
152
+ react.useEffect(() => {
153
+ let qs = ''
154
+ try { qs = String((location && location.search) || '') } catch (e) { qs = '' }
155
+ if (!qs) return
156
+ const p = new URLSearchParams(qs)
157
+ const bound = p.get('oidcbound')
158
+ const err = p.get('oidcerr')
159
+ if (bound === null && err === null) return
160
+ if (bound !== null) {
161
+ setBindMsg({ kind: 'ok', text: 'OIDC 身份绑定成功。此后只有该身份可通过 SSO 登录。' })
162
+ } else {
163
+ const LABELS = {
164
+ 'invalid-state': '授权会话已失效或被中断,请重新发起绑定',
165
+ 'not-bound': '尚未绑定任何 OIDC 身份,SSO 登录已被拒绝',
166
+ 'sub-mismatch': '该 IdP 身份未被绑定到本 WebUI,登录已被拒绝',
167
+ }
168
+ setBindMsg({ kind: 'err', text: 'OIDC 操作失败:' + (LABELS[err] || err) })
169
+ }
170
+ // 清掉 query:否则用户刷新页面会重复看到这条提示。
171
+ try {
172
+ const clean = location.pathname + location.hash
173
+ history.replaceState(null, '', clean)
174
+ } catch (e) { /* 无 history 时忽略 */ }
175
+ }, []);
176
+
145
177
  // 与服务端 passwordStrength 一致的同步校验
146
178
  const SPECIAL_CHARS = /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?/~]/;
147
179
  function checkPassword(p) {
@@ -186,6 +218,8 @@ window.__ModuleLoader__.load({
186
218
  scope: oidcScope.trim(),
187
219
  redirectBase: oidcRedirectBase.trim() || null,
188
220
  trustBrowserOrigin: oidcTrustBrowser,
221
+ // 留空即不发送 prompt(服务端会存成 undefined)
222
+ prompt: oidcPrompt.trim(),
189
223
  };
190
224
  api('/dsh-webui-oauth/configure', { username: name, password, current, ttl: Number(ttl), oidc: oidcPayload }).then((r) => {
191
225
  setBusy(false);
@@ -439,6 +473,12 @@ window.__ModuleLoader__.load({
439
473
  react.createElement('input', { type: 'checkbox', checked: oidcTrustBrowser, onChange: (e) => setOidcTrustBrowser(e.target.checked) }),
440
474
  ' 信任浏览器 origin(默认开启;关闭时仅用上方 Redirect Base)'),
441
475
  ),
476
+ react.createElement('div', { className: 'wua-row' },
477
+ react.createElement('label', { className: 'wua-label' }, 'Prompt(可选,建议留空)'),
478
+ react.createElement('input', { className: 'wua-input', type: 'text', value: oidcPrompt, spellCheck: false, placeholder: '留空则不发送该参数', onChange: (e) => setOidcPrompt(e.target.value) }),
479
+ react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
480
+ '多数 IdP 无需该参数。部分 IdP 会对不认识的值直接拒绝授权(提示 unsupported prompt value requested),此时请保持留空。'),
481
+ ),
442
482
  ) : null,
443
483
  // ---- OIDC 身份绑定 ----
444
484
  // 绑定是访问控制前提:未绑定时任何能通过该 IdP 的人都会被拒,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {