dsh-webui-oauth 0.6.3 → 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,37 @@
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
+
5
36
  ## 0.6.3
6
37
 
7
38
  修复部分 IdP 下**绑定 OIDC 立即失败**的问题(点「绑定 OIDC 身份」后立刻收到
package/index.js CHANGED
@@ -2507,6 +2507,29 @@ export async function apply(ctx) {
2507
2507
  *
2508
2508
  * @returns {Promise<void>} 直接写响应,不返回内容
2509
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
+
2510
2533
  async function startOidcAuthorize(ctx2, req, res, purpose) {
2511
2534
  const creds = await readCredentials(ctx2)
2512
2535
  const o = await oidcOf(creds)
@@ -2688,20 +2711,20 @@ export async function apply(ctx) {
2688
2711
  if (!st) {
2689
2712
  const why = known ? 'state 已过期(TTL ' + Math.round(OIDC_STATE_TTL_MS / 60000) + ' 分钟)' : 'state 无效、已使用或不存在'
2690
2713
  await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: why })
2691
- sendJson(res, 200, { ok: false, error: 'oidc-invalid-state' }, clearStateCookie)
2714
+ redirectBackToApp(ctx, req, res, 'oidcerr=invalid-state', clearStateCookie)
2692
2715
  return
2693
2716
  }
2694
2717
  // 登录 CSRF 绑定:回调必须由发起授权的同一浏览器完成(携带同值 Cookie)。
2695
2718
  // 只校验 state 存在于服务端集合不足以阻止“攻击者取 state、受害者完成回调”。
2696
2719
  if (cookieOf(req, COOKIE_OIDC_STATE) !== state) {
2697
2720
  await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'state Cookie 不匹配(疑似登录 CSRF)' })
2698
- sendJson(res, 200, { ok: false, error: 'oidc-invalid-state' }, clearStateCookie)
2721
+ redirectBackToApp(ctx, req, res, 'oidcerr=invalid-state', clearStateCookie)
2699
2722
  return
2700
2723
  }
2701
2724
  if (!code) {
2702
2725
  const err = params.get('error') || 'missing-code'
2703
2726
  await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: '授权被拒或缺少 code: ' + sanitizeSub(err) })
2704
- sendJson(res, 200, { ok: false, error: 'oidc-authorize-failed', detail: err })
2727
+ redirectBackToApp(ctx, req, res, 'oidcerr=' + encodeURIComponent(err), clearStateCookie)
2705
2728
  return
2706
2729
  }
2707
2730
  // 换 token(authorization_code + PKCE verifier + client_secret)
@@ -2761,7 +2784,7 @@ export async function apply(ctx) {
2761
2784
  }
2762
2785
  if (!vres.ok) {
2763
2786
  await auditLog(ctx, 'oidc_login_failure', { ip: meta.ip, ua: meta.ua, detail: 'id_token 验证失败: ' + vres.error })
2764
- sendJson(res, 200, { ok: false, error: 'oidc-invalid-id-token', detail: vres.error })
2787
+ redirectBackToApp(ctx, req, res, 'oidcerr=' + encodeURIComponent(vres.error), clearStateCookie)
2765
2788
  return
2766
2789
  }
2767
2790
  const rawSub = vres.payload.sub
@@ -2774,7 +2797,8 @@ export async function apply(ctx) {
2774
2797
  // 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
2775
2798
  await writeCredentials(ctx, { ...creds, boundSub: rawSub })
2776
2799
  await auditLog(ctx, 'oidc_bind_success', { username: auditName, ip: meta.ip, ua: meta.ua })
2777
- sendJson(res, 200, { ok: true, bound: true, sub: auditName }, clearStateCookie)
2800
+ // 成功必须【把浏览器送回应用】,而不是停在 JSON 上(见 redirectBackToApp 说明)。
2801
+ redirectBackToApp(ctx, req, res, 'oidcbound=1', clearStateCookie)
2778
2802
  return
2779
2803
  }
2780
2804
 
@@ -2784,12 +2808,12 @@ export async function apply(ctx) {
2784
2808
  const bound = boundSubOf(creds)
2785
2809
  if (!bound) {
2786
2810
  await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: '尚未绑定 OIDC 身份,拒绝登录' })
2787
- sendJson(res, 200, { ok: false, error: 'oidc-not-bound' }, clearStateCookie)
2811
+ redirectBackToApp(ctx, req, res, 'oidcerr=not-bound', clearStateCookie)
2788
2812
  return
2789
2813
  }
2790
2814
  if (!safeTokenEquals(String(rawSub), bound)) {
2791
2815
  await auditLog(ctx, 'oidc_login_failure', { username: auditName, ip: meta.ip, ua: meta.ua, detail: 'sub 与已绑定身份不匹配,拒绝登录' })
2792
- sendJson(res, 200, { ok: false, error: 'oidc-sub-mismatch', detail: '该 IdP 身份未被绑定到本 WebUI' }, clearStateCookie)
2816
+ redirectBackToApp(ctx, req, res, 'oidcerr=sub-mismatch', clearStateCookie)
2793
2817
  return
2794
2818
  }
2795
2819
 
package/lib/client.js CHANGED
@@ -146,6 +146,34 @@ window.__ModuleLoader__.load({
146
146
  };
147
147
  }, []);
148
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
+
149
177
  // 与服务端 passwordStrength 一致的同步校验
150
178
  const SPECIAL_CHARS = /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?/~]/;
151
179
  function checkPassword(p) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {