dsh-webui-oauth 0.5.1 → 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,73 @@
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
+
35
+ ## 0.6.0
36
+
37
+ 新增 **OIDC 身份绑定**:配置 OIDC 不再等于谁都能登录,必须显式绑定一个 IdP 身份。
38
+
39
+ ### 新增:OIDC 绑定 / 解绑(访问控制)
40
+
41
+ - **绑定前,OIDC 登录一律拒绝**。此前只要配置了 OIDC,任何能通过该 IdP 验证的人都能进入
42
+ WebUI——IdP 侧的任何账号都成为本 WebUI 的入口。现在未绑定时 `/oidc/login` 直接返回
43
+ `oidc-not-bound`,登录页也不显示 SSO 按钮。
44
+ - **绑定**:本地账号登录后,在**设置 → 身份认证 → OIDC 身份绑定**点「绑定 OIDC 身份」,
45
+ 走一次 IdP 授权,回调里校验通过的 `sub` 会存入凭据文件(`boundSub`)。此后**只有该
46
+ 身份**能通过 SSO 登录。
47
+ - **解绑**:点「解除绑定」(需当前密码),清空 `boundSub`,SSO 登录立即恢复为「一律拒绝」。
48
+ - 绑定状态在设置页可见;服务端只回传**掩码后**的 sub(如 `su*****ce`),不把原始标识
49
+ 发给浏览器。
50
+ - 新增端点:`GET /dsh-webui-oauth/oidc/bind`(需已登录会话)、
51
+ `POST /dsh-webui-oauth/oidc/unbind`(需当前密码)。
52
+ - 审计新增 `oidc_bind_success` / `oidc_unbind_success` / `oidc_unbind_failure`。
53
+
54
+ ### 兼容性
55
+
56
+ - 旧凭据文件没有 `boundSub` 字段 → 视为**未绑定**,OIDC 登录被拒。这是安全方向:
57
+ 升级不会因为"多出"一个 OIDC 入口而放开访问。需要 OIDC 登录的部署升级后请重新绑定一次。
58
+ - 「禁用认证」会重置凭据文件,绑定关系一并清除;重新启用后需重新绑定。
59
+
60
+ ### 修复
61
+
62
+ - 登录页 `GET /login` 在拼接 SSO 按钮状态时引用了未定义变量(写作 `creds` 而非
63
+ `credsNow`),导致登录页直接 500、整个 WebUI 无法登录。已修复并补回归测试
64
+ (该分支只在浏览器 GET 时走到,纯 JSON 用例覆盖不到)。
65
+
66
+ ### 测试
67
+
68
+ - 新增 `test/oidc-bind.test.mjs`(25 项):未绑定拒登、绑定端点鉴权、sub 不匹配拒登
69
+ (不发会话)、匹配 sub 可登录、解绑需密码、解绑后拒登、旧凭据兼容、登录页渲染。
70
+ - 全量 314 项断言通过。
71
+
5
72
  ## 0.5.1
6
73
 
7
74
  修复 OIDC 密钥轮换后长时间无法登录的问题。**使用 OIDC/SSO 的部署建议升级。**
package/README.en.md CHANGED
@@ -101,7 +101,8 @@ Authentication is enforced in four layers, all implemented by **wrapping the web
101
101
  - `trustBrowserOrigin` defaults to `true` when omitted (legacy behavior); an explicit `false` makes both sites **ignore** the browser-influenced origin and use `publicBaseUrl`. With no `publicBaseUrl` either, the token redirect falls back to the request Host / loopback, and OIDC fails closed with an error.
102
102
  - The old `oidc.trustBrowserOrigin` is still accepted: when the settings section does not set the switch explicitly, it is used as the fallback, so existing deployments migrate smoothly.
103
103
  - **Host-rewriting reverse proxies**: when the proxy rewrites `Host` to a loopback address (`127.0.0.1:3080`, `::1`, `localhost`, `0.0.0.0`) and sends nothing else, the plugin **cannot** recover the browser-side address from the request, so it treats the address as proxy-rewritten and uses the configured `publicBaseUrl` directly (the legacy "declare the public address in settings.yaml" advice was ineffective in that shape, because `publicBaseUrl` was only trusted on an exact Host match). If the proxy can send the original host, `X-Forwarded-Host` (or RFC 7239 `Forwarded: host=`) is preferred over the request Host for building the redirect.
104
- - **Optional OIDC SSO (0.4.0)**: standards-based authorization-code + PKCE with a **confidential client (client_secret required)**, security-benchmarked against Logto. Once configured, the login page shows an "SSO single sign-on" button; after IdP authentication the plugin creates a local session keyed by the IdP `sub` (reusing the session TTL and persistence). The **redirect_uri base is set by `oidc.redirectBase`**; by default the browser is trusted (it trusts the front-end `location.origin` — under a host-rewriting reverse proxy the browser's public address is correct), ****constrained by the unifed switch above****; when disabled only the configured `redirectBase` is used; if neither is available it fails closed with an error. id_token signatures support **RSA (RS256/384/512, PS256/384/512), EC (ES256/384/512) and EdDSA (Ed25519)**, with iss/aud/exp/iat/nbf/nonce and JWKS(kid) validation (see "Signature algorithm support" below). Logout is the simple variant: it clears the local session only.
104
+ - **Optional OIDC SSO (0.4.0; identity binding required since 0.6.0)**: standards-based authorization-code + PKCE with a **confidential client (client_secret required)**, security-benchmarked against Logto. **Configuring OIDC does not mean anyone can log in**: you must first bind one IdP identity. Until then OIDC login is refused outright and the login page hides the SSO button (see the next bullet). Once bound, the login page shows an "SSO single sign-on" button; after IdP authentication the plugin creates a local session keyed by the IdP `sub` (reusing the session TTL and persistence).
105
+ - **OIDC identity binding / unbinding (0.6.0)**: binding = log in with the local account, then click "绑定 OIDC 身份" under **Settings → Authentication → OIDC identity binding** and complete one IdP authorization; the verified `sub` is stored in the credentials file (`boundSub`), and **only that identity** can then log in via SSO. Unbinding = click "解除绑定" (current password required); once cleared, SSO login immediately reverts to "refuse everything". The settings page only ever shows a masked sub (e.g. `su*****ce`). A credentials file without `boundSub` counts as unbound, so upgrading never opens access. The **redirect_uri base is set by `oidc.redirectBase`**; by default the browser is trusted (it trusts the front-end `location.origin` — under a host-rewriting reverse proxy the browser's public address is correct), ****constrained by the unifed switch above****; when disabled only the configured `redirectBase` is used; if neither is available it fails closed with an error. id_token signatures support **RSA (RS256/384/512, PS256/384/512), EC (ES256/384/512) and EdDSA (Ed25519)**, with iss/aud/exp/iat/nbf/nonce and JWKS(kid) validation (see "Signature algorithm support" below). Logout is the simple variant: it clears the local session only.
105
106
  - **Scheme-adaptive post-login redirect (0.3.3, fixes #6 / #7)**: after a successful login the plugin sends the browser to the core's token-bearing root URL. Its **authority now comes from the request Host** (no longer hardcoded to `127.0.0.1`) and its **scheme follows the protocol the request actually used**, resolved in this order: ① the operator's explicit `remote-web-ui.publicBaseUrl` (**trusted only when its host/port matches the incoming Host** — otherwise a LAN direct hit would be redirected to the public address and lose the freshly issued session cookie to a cross-origin hop); ② the standard proxies headers `X-Forwarded-Proto` (leftmost value) or RFC 7239 `Forwarded: proto=`; ③ a TLS-terminating socket (`socket.encrypted`); ④ fallback `http`. It deliberately does **not** guess "non-IP hostname ⇒ https", which would turn plain-http intranet access (`http://nas.local:3080`) into a dead https link. The login page adds a one-way client-side fallback: an https page receiving a **same-origin** `http://` redirect upgrades it to `https://` (never downgrades, never rewrites another origin).
106
107
 
107
108
  Sessions are **server-side and persisted to disk** (`sessions.jsonl`, survive a DSH restart, expire server-side), carried by an `HttpOnly; SameSite=Lax` cookie (`dsh_wua_session`) that JS cannot read; changing the password **revokes every other session**.
package/README.md CHANGED
@@ -118,7 +118,8 @@ DSH WebUI 身份认证插件(持久化插件)。在「设置 → 身份认
118
118
 
119
119
  **已知取舍**:本机浏览器直连 `http://127.0.0.1:3080` 也落进第 2 行,于是开关开着也会跳 `publicBaseUrl`。要同时支持本机直连,只能让反代下发 `X-Forwarded-Host`(走第 1 行)。
120
120
  - **登录后跳转的协议自适应(0.3.3,修 #6 / #7)**:插件登录成功后要把浏览器引导到核心的带 token 根地址,其 **authority 取自本次请求的 Host**(不再写死 `127.0.0.1`),**scheme 按请求实际协议解析**,优先级:① 操作者显式声明 `remote-web-ui.publicBaseUrl`(**仅当其 host/port 与本次请求 Host 一致时**采信——否则局域网直连会被重定向到公网地址、跨源丢掉刚下发的会话 Cookie);② 标准代理头 `X-Forwarded-Proto`(取最左值)或 RFC 7239 `Forwarded: proto=`;③ socket 自身是 TLS(插件直接终结 TLS);④ 兜底 `http`。**刻意不做「非 IP 域名即 https」的猜测**——那会把纯 http 的内网域名访问(`http://nas.local:3080`)打成 https 死链。前端登录页另有一层单向兜底:页面在 https 下收到**同源** `http://` 跳转时自动升级为 `https://`(反向不降级、异源不改写)。
121
- - **可选 OIDC 单点登录(0.4.0)**:标准 OIDC 授权码 + PKCE(机密客户端,须 `client_secret`),以 Logto 为安全基准。配置后登录页出现「SSO 单点登录」按钮,认证通过后以 IdP 的 `sub` 建立本地会话(沿用会话有效期与持久化)。**redirect_uri 的 base 由 `oidc.redirectBase` 决定;默认交由浏览器处理(采信前端 `location.origin`,反代重写 Host 时浏览器看到的公网地址即正确),受上面统一开关约束;关闭时仅用配置的 `redirectBase`;两者都不可用则 fail-closed 报错。** id_token 签名支持 **RSA(RS256/384/512、PS256/384/512)、EC(ES256/384/512)、EdDSA(Ed25519)**,配合 iss/aud/exp/iat/nbf/nonce 与 JWKS(kid) 校验(见下节「签名算法支持」)。登出为简单版:仅清本地会话。
121
+ - **可选 OIDC 单点登录(0.4.0;0.6.0 起需绑定身份)**:标准 OIDC 授权码 + PKCE(机密客户端,须 `client_secret`),以 Logto 为安全基准。**配置 OIDC ≠ 谁都能登录**:必须先绑定一个 IdP 身份,绑定前 OIDC 登录一律拒绝、登录页也不显示 SSO 按钮(见下条)。绑定后登录页出现「SSO 单点登录」按钮,认证通过后以 IdP 的 `sub` 建立本地会话(沿用会话有效期与持久化)。
122
+ - **OIDC 身份绑定 / 解绑(0.6.0)**:绑定 = 本地账号登录后在**设置 → 身份认证 → OIDC 身份绑定**点「绑定 OIDC 身份」,走一次 IdP 授权,校验通过的 `sub` 存入凭据文件(`boundSub`);**此后只有该身份能通过 SSO 登录**。解绑 = 点「解除绑定」(需当前密码),清空后 SSO 登录立即恢复为「一律拒绝」。设置页只显示掩码后的 sub(如 `su*****ce`)。旧凭据文件无 `boundSub` 字段时视为未绑定(升级不会放开访问)。**redirect_uri 的 base 由 `oidc.redirectBase` 决定;默认交由浏览器处理(采信前端 `location.origin`,反代重写 Host 时浏览器看到的公网地址即正确),受上面统一开关约束;关闭时仅用配置的 `redirectBase`;两者都不可用则 fail-closed 报错。** id_token 签名支持 **RSA(RS256/384/512、PS256/384/512)、EC(ES256/384/512)、EdDSA(Ed25519)**,配合 iss/aud/exp/iat/nbf/nonce 与 JWKS(kid) 校验(见下节「签名算法支持」)。登出为简单版:仅清本地会话。
122
123
 
123
124
  会话为**服务端会话,持久化到磁盘**(`sessions.jsonl`,重启 DSH 不掉线,到期自动失效),由 `HttpOnly; SameSite=Lax` Cookie(`dsh_wua_session`)携带,JS 无法读取;修改密码会**吊销所有其他会话**。
124
125
 
package/index.js CHANGED
@@ -387,6 +387,48 @@ async function writeCredentials(ctx, creds) {
387
387
  await ctx.fs.writeText(target, JSON.stringify(creds), undefined, undefined, { mode: 'danger-full-access' })
388
388
  }
389
389
 
390
+ // ---------------- OIDC 身份绑定(sub 白名单) ----------------
391
+ //
392
+ // 语义(按部署者确认的验收标准):
393
+ // - 绑定前:OIDC 登录【一律拒绝】。配置了 OIDC 只代表「允许发起授权」,
394
+ // 不代表谁都能登录——否则任何能通过该 IdP 的人都能进 WebUI。
395
+ // - 绑定:本地账号登录后,在设置页点「绑定」,走一次 OIDC 授权,把回调里
396
+ // 校验通过的 sub 存进凭据文件(boundSub)。此后只有这一个 sub 能登录。
397
+ // - 解绑:把 boundSub 清空,OIDC 登录立即恢复为「一律拒绝」。
398
+ //
399
+ // 存放位置:凭据文件 dsh-webui-auth.json 的 boundSub 字段。与 username/hash 同文件,
400
+ // 因为它们同属「谁可以进这个 WebUI」这一件事,且该目录已是 0600。
401
+ // 旧版凭据文件没有该字段 → 读出来是 undefined → 视为未绑定,行为即「拒绝 OIDC 登录」,
402
+ // 这对既有部署是安全的方向(不会因为升级而放开访问)。
403
+
404
+ /** 取已绑定的 sub;未绑定/非法时返回 null。 */
405
+ function boundSubOf(creds) {
406
+ if (!creds || typeof creds.boundSub !== 'string') return null
407
+ const s = creds.boundSub
408
+ return s.length > 0 ? s : null
409
+ }
410
+
411
+ /** 当前是否已绑定 OIDC 身份。 */
412
+ function isOidcBound(creds) {
413
+ return boundSubOf(creds) !== null
414
+ }
415
+
416
+ /**
417
+ * 给界面展示用的 sub 掩码。
418
+ *
419
+ * 为什么不直接用 sanitizeSub:那是「日志注入防护」——只把不安全字符换成下划线、
420
+ * 截断长度,内容本身照样完整可见。拿它回传前端等于把 sub 明文发给了浏览器。
421
+ * 界面只需要让操作者辨认「绑的是哪一个」,不需要拿到可用于构造 token 的原始标识,
422
+ * 因此这里保留首尾少量字符、中间打码。
423
+ *
424
+ * 极短的 sub(≤4)整串打码,避免"保留首尾"退化成几乎明文。
425
+ */
426
+ function maskSubForDisplay(sub) {
427
+ if (typeof sub !== 'string' || !sub) return null
428
+ if (sub.length <= 4) return '*'.repeat(sub.length)
429
+ return sub.slice(0, 2) + '*'.repeat(Math.max(4, sub.length - 4)) + sub.slice(-2)
430
+ }
431
+
390
432
  /**
391
433
  * 读取 OIDC 配置。优先独立文件 dsh-webui-oauth.json;不存在时回落到
392
434
  * dsh-webui-auth.json 的 oidc 段(旧版布局),实现无感升级。
@@ -1994,6 +2036,36 @@ export async function apply(ctx) {
1994
2036
  const r = await readOidcConfig(ctx, creds)
1995
2037
  return r.oidc
1996
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
+ }
1997
2069
  async function oidcEnabled(creds) {
1998
2070
  const o = await oidcOf(creds)
1999
2071
  return !!(o && o.enabled === true && typeof o.issuer === 'string' && typeof o.clientId === 'string' && typeof o.clientSecret === 'string')
@@ -2011,7 +2083,10 @@ export async function apply(ctx) {
2011
2083
  const oidcOn = await oidcEnabled(credsNow)
2012
2084
  const page = LOGIN_PAGE
2013
2085
  .replace('__MODE__', enabledFlag ? 'login' : 'setup')
2014
- .replace('__OIDC_ENABLED__', oidcOn ? 'true' : 'false')
2086
+ // SSO 按钮的显示条件 = 已配置 OIDC **且**已绑定身份。
2087
+ // 只判配置会让未绑定的部署显示一个点了必然被拒的按钮(oidc-not-bound),
2088
+ // 用户会以为坏了。绑定是访问前提,按钮就该跟着绑定状态走。
2089
+ .replace('__OIDC_ENABLED__', oidcOn && isOidcBound(credsNow) ? 'true' : 'false')
2015
2090
  .replace('__THEME_PREFERENCE__', themePreference(ctx))
2016
2091
  res.writeHead(200, {
2017
2092
  'content-type': 'text/html; charset=utf-8',
@@ -2179,7 +2254,26 @@ export async function apply(ctx) {
2179
2254
  return { trustBrowserOrigin: pol.trust, configuredOrigin: configuredOrigin(ctx) }
2180
2255
  } catch (e) { return null }
2181
2256
  })(),
2182
- oidc: oidcOn ? { enabled: true, issuer: oidcCfg.issuer, clientId: oidcCfg.clientId, scope: oidcCfg.scope, redirectBase: oidcCfg.redirectBase, trustBrowserOrigin: oidcCfg.trustBrowserOrigin !== false } : { enabled: false },
2257
+ // bound/boundSubHint 供设置页展示绑定状态。
2258
+ // 只回传 sub 的**脱敏短标识**:sub 是 IdP 侧的稳定标识符,全量回传给前端
2259
+ // 没有功能需要(绑定只需显示"已绑定到谁"),却会把它暴露在浏览器里。
2260
+ oidc: oidcOn
2261
+ ? {
2262
+ enabled: true,
2263
+ issuer: oidcCfg.issuer,
2264
+ clientId: oidcCfg.clientId,
2265
+ scope: oidcCfg.scope,
2266
+ redirectBase: oidcCfg.redirectBase,
2267
+ trustBrowserOrigin: oidcCfg.trustBrowserOrigin !== false,
2268
+ bound: isOidcBound(creds),
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,
2275
+ }
2276
+ : { enabled: false, bound: isOidcBound(creds), boundSubHint: isOidcBound(creds) ? maskSubForDisplay(boundSubOf(creds)) : null, redirectUri: null, callbackPath: OIDC_CALLBACK_PATH },
2183
2277
  })
2184
2278
  } catch (e) {
2185
2279
  sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
@@ -2285,11 +2379,27 @@ export async function apply(ctx) {
2285
2379
  sendJson(res, 200, { ok: false, error: 'oidc-invalid', reason: 'trustBrowserOrigin 须为 boolean' })
2286
2380
  return
2287
2381
  }
2288
- if (oidcIn.clientSecret !== undefined && (typeof oidcIn.clientSecret !== 'string' || !oidcIn.clientSecret.trim())) {
2289
- await auditLog(ctx, 'configure_failure', { username, ip: meta.ip, ua: meta.ua, detail: 'OIDC 配置:clientSecret 须为非空字符串(或留空保留旧值)' })
2290
- 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 须为字符串' })
2291
2390
  return
2292
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
+ }
2293
2403
  }
2294
2404
  // 合并 OIDC 配置:oidcIn 提供时整段替换(secret 留空则保留旧值);否则关闭。
2295
2405
  let oidcOut = null
@@ -2381,7 +2491,68 @@ export async function apply(ctx) {
2381
2491
 
2382
2492
  // ---------------- OIDC SSO 端点 ----------------
2383
2493
 
2384
- // 发起 OIDC 授权:GET /dsh-webui-oauth/oidc/login?base=<前端origin>
2494
+ /**
2495
+ * 发起一次 OIDC 授权往返,302 到 IdP。登录与绑定共用(唯一区别是 purpose)。
2496
+ *
2497
+ * purpose 会随 state 一起暂存,回调据此决定这是"登录"还是"绑定":
2498
+ * - 'login':校验 sub 必须等于已绑定值,否则拒绝;
2499
+ * - 'bind' :把校验通过的 sub 写入 boundSub。
2500
+ * 把用途放在服务端 state 表里而不是 query 参数:query 由浏览器可控,
2501
+ * 若能通过改 URL 把绑定流程变成登录流程,绑定就形同虚设。
2502
+ *
2503
+ * @returns {Promise<void>} 直接写响应,不返回内容
2504
+ */
2505
+ async function startOidcAuthorize(ctx2, req, res, purpose) {
2506
+ const creds = await readCredentials(ctx2)
2507
+ const o = await oidcOf(creds)
2508
+ if (!(await oidcEnabled(creds))) {
2509
+ sendJson(res, 200, { ok: false, error: 'oidc-not-configured' })
2510
+ return
2511
+ }
2512
+ // 决定回调 base:与登录后 token 跳转共用 remote-web-ui 段的统一开关。
2513
+ let frontendBase = null
2514
+ if (typeof req.url === 'string') {
2515
+ const m = /[?&]base=([^&]+)/.exec(req.url)
2516
+ if (m) { try { frontendBase = decodeURIComponent(m[1]) } catch (e) { frontendBase = null } }
2517
+ }
2518
+ const base = resolveOidcBase(o, frontendBase, trustedOriginPolicy(ctx2, o))
2519
+ if (!base) {
2520
+ sendJson(res, 200, { ok: false, error: 'oidc-base-unresolvable', hint: '请在设置中配置 redirectBase(反代场景),或让浏览器从登录页发起 SSO 登录' })
2521
+ return
2522
+ }
2523
+ const redirectUri = base.replace(/\/$/, '') + OIDC_CALLBACK_PATH
2524
+ // 拉取 Discovery(缓存);失败则引导检查 issuer 配置。
2525
+ const disc = await oidcDiscovery.get(o.issuer)
2526
+ if (!disc.ok || !disc.metadata) {
2527
+ const meta = requestMeta(req)
2528
+ await auditLog(ctx2, 'oidc_discovery_failure', { issuer: o.issuer, ip: meta.ip, ua: meta.ua, detail: disc.error })
2529
+ sendJson(res, 200, { ok: false, error: 'oidc-discovery-failed', detail: disc.error })
2530
+ return
2531
+ }
2532
+ // 生成 state / nonce / PKCE verifier,按 TTL 暂存(含容量上限,防灌爆)。
2533
+ const state = oidcRandom(24)
2534
+ const nonce = oidcRandom(24)
2535
+ const verifier = oidcRandom(32)
2536
+ oidcStateStore.put(state, { nonce, verifier, redirectUri, purpose })
2537
+ const authUrl = new URL(disc.metadata.authorization_endpoint)
2538
+ authUrl.searchParams.set('response_type', 'code')
2539
+ authUrl.searchParams.set('client_id', o.clientId)
2540
+ authUrl.searchParams.set('redirect_uri', redirectUri)
2541
+ authUrl.searchParams.set('scope', typeof o.scope === 'string' && o.scope.trim() ? o.scope.trim() : 'openid profile email')
2542
+ authUrl.searchParams.set('state', state)
2543
+ authUrl.searchParams.set('nonce', nonce)
2544
+ authUrl.searchParams.set('code_challenge', oidcChallenge(verifier))
2545
+ authUrl.searchParams.set('code_challenge_method', 'S256')
2546
+ authUrl.searchParams.set('prompt', 'select_account')
2547
+ // 登录 CSRF 绑定:把 state 同时写进一个 HttpOnly 短时效 Cookie。回调时两者必须
2548
+ // 同时匹配——否则攻击者可先在自己的浏览器发起授权拿到 state,再诱导受害者带着
2549
+ // 该 state 完成回调,把受害者的浏览器登录进攻击者的账号(会话固定/登录 CSRF)。
2550
+ res.setHeader('Set-Cookie', COOKIE_OIDC_STATE + '=' + state + '; HttpOnly; SameSite=Lax; Path=/; Max-Age=' + Math.floor(OIDC_STATE_TTL_MS / 1000))
2551
+ res.writeHead(302, { location: authUrl.href })
2552
+ res.end()
2553
+ }
2554
+
2555
+ // 发起 OIDC 登录授权:GET /dsh-webui-oauth/oidc/login?base=<前端origin>
2385
2556
  ctx.effect(() => ctx.webServer.register({
2386
2557
  kind: 'exact',
2387
2558
  path: '/dsh-webui-oauth/oidc/login',
@@ -2391,58 +2562,88 @@ export async function apply(ctx) {
2391
2562
  sendJson(res, 405, { error: '仅支持 GET' })
2392
2563
  return
2393
2564
  }
2394
- const creds = await readCredentials(ctx)
2395
- const o = await oidcOf(creds)
2396
- if (!(await oidcEnabled(creds))) {
2397
- sendJson(res, 200, { ok: false, error: 'oidc-not-configured' })
2565
+ // 未绑定任何 sub 时不允许发起登录:绑定是访问前提,先挡在最前面,
2566
+ // 免得用户走完整个 IdP 往返才被拒(体验差且浪费一次授权)。
2567
+ const creds0 = await readCredentials(ctx)
2568
+ if (!isOidcBound(creds0)) {
2569
+ sendJson(res, 200, { ok: false, error: 'oidc-not-bound', hint: '请先用本地账号登录,在设置页「身份认证」中绑定 OIDC 身份' })
2398
2570
  return
2399
2571
  }
2400
- // 决定回调 base:与登录后 token 跳转共用 remote-web-ui 段的统一开关。
2401
- let frontendBase = null
2402
- if (typeof req.url === 'string') {
2403
- const m = /[?&]base=([^&]+)/.exec(req.url)
2404
- if (m) { try { frontendBase = decodeURIComponent(m[1]) } catch (e) { frontendBase = null } }
2572
+ await startOidcAuthorize(ctx, req, res, 'login')
2573
+ } catch (e) {
2574
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
2575
+ }
2576
+ },
2577
+ }), 'dsh-webui-oauth: oidc login')
2578
+
2579
+ // 发起 OIDC 绑定授权:GET /dsh-webui-oauth/oidc/bind
2580
+ //
2581
+ // 必须先持有有效的本地会话(已登录)——绑定语义是"把我这个管理员账号
2582
+ // 与某个 IdP 身份关联起来",不能让未认证者触发。
2583
+ ctx.effect(() => ctx.webServer.register({
2584
+ kind: 'exact',
2585
+ path: '/dsh-webui-oauth/oidc/bind',
2586
+ handler: async (req, res) => {
2587
+ try {
2588
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
2589
+ sendJson(res, 405, { error: '仅支持 GET' })
2590
+ return
2405
2591
  }
2406
- const base = resolveOidcBase(o, frontendBase, trustedOriginPolicy(ctx, o))
2407
- if (!base) {
2408
- sendJson(res, 200, { ok: false, error: 'oidc-base-unresolvable', hint: '请在设置中配置 redirectBase(反代场景),或让浏览器从登录页发起 SSO 登录' })
2592
+ if (!checkRequest(req)) {
2593
+ sendJson(res, 401, { error: 'unauthorized' })
2409
2594
  return
2410
2595
  }
2411
- const redirectUri = base.replace(/\/$/, '') + OIDC_CALLBACK_PATH
2412
- // 拉取 Discovery(缓存);失败则引导检查 issuer 配置。
2413
- const disc = await oidcDiscovery.get(o.issuer)
2414
- if (!disc.ok || !disc.metadata) {
2415
- const meta = requestMeta(req)
2416
- await auditLog(ctx, 'oidc_discovery_failure', { issuer: o.issuer, ip: meta.ip, ua: meta.ua, detail: disc.error })
2417
- sendJson(res, 200, { ok: false, error: 'oidc-discovery-failed', detail: disc.error })
2596
+ await startOidcAuthorize(ctx, req, res, 'bind')
2597
+ } catch (e) {
2598
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
2599
+ }
2600
+ },
2601
+ }), 'dsh-webui-oauth: oidc bind')
2602
+
2603
+ // 解除 OIDC 绑定:POST /dsh-webui-oauth/oidc/unbind
2604
+ //
2605
+ // 清空 boundSub 后,OIDC 登录立即回到"一律拒绝"(见回调里的 bound 判空)。
2606
+ // 需要当前密码,避免会话被盗后攻击者悄悄解绑(解绑本身是安全降级操作,
2607
+ // 但仍属于"改认证配置",与 disable/configure 保持一致的验证强度)。
2608
+ ctx.effect(() => ctx.webServer.register({
2609
+ kind: 'exact',
2610
+ path: '/dsh-webui-oauth/oidc/unbind',
2611
+ handler: async (req, res) => {
2612
+ try {
2613
+ if (req.method !== 'POST') {
2614
+ sendJson(res, 405, { error: '仅支持 POST' })
2418
2615
  return
2419
2616
  }
2420
- // 生成 state / nonce / PKCE verifier,按 TTL 暂存(含容量上限,防灌爆)。
2421
- const state = oidcRandom(24)
2422
- const nonce = oidcRandom(24)
2423
- const verifier = oidcRandom(32)
2424
- oidcStateStore.put(state, { nonce, verifier, redirectUri })
2425
- const authUrl = new URL(disc.metadata.authorization_endpoint)
2426
- authUrl.searchParams.set('response_type', 'code')
2427
- authUrl.searchParams.set('client_id', o.clientId)
2428
- authUrl.searchParams.set('redirect_uri', redirectUri)
2429
- authUrl.searchParams.set('scope', typeof o.scope === 'string' && o.scope.trim() ? o.scope.trim() : 'openid profile email')
2430
- authUrl.searchParams.set('state', state)
2431
- authUrl.searchParams.set('nonce', nonce)
2432
- authUrl.searchParams.set('code_challenge', oidcChallenge(verifier))
2433
- authUrl.searchParams.set('code_challenge_method', 'S256')
2434
- authUrl.searchParams.set('prompt', 'select_account')
2435
- // 登录 CSRF 绑定:把 state 同时写进一个 HttpOnly 短时效 Cookie。回调时两者必须
2436
- // 同时匹配——否则攻击者可先在自己的浏览器发起授权拿到 state,再诱导受害者带着
2437
- // 该 state 完成回调,把受害者的浏览器登录进攻击者的账号(会话固定/登录 CSRF)。
2438
- res.setHeader('Set-Cookie', COOKIE_OIDC_STATE + '=' + state + '; HttpOnly; SameSite=Lax; Path=/; Max-Age=' + Math.floor(OIDC_STATE_TTL_MS / 1000))
2439
- res.writeHead(302, { location: authUrl.href })
2440
- res.end()
2617
+ if (!checkRequest(req)) {
2618
+ sendJson(res, 401, { error: 'unauthorized' })
2619
+ return
2620
+ }
2621
+ const body = await readJsonBody(req, res)
2622
+ if (body === null) return
2623
+ const creds = await readCredentials(ctx)
2624
+ const meta = requestMeta(req)
2625
+ const current = String(body.current || '')
2626
+ const curValid = current && creds && typeof creds.hash === 'string' && await verifyPassword(current, creds.hash)
2627
+ if (!curValid) {
2628
+ await auditLog(ctx, 'oidc_unbind_failure', { username: creds && creds.username ? creds.username : null, ip: meta.ip, ua: meta.ua, detail: '当前密码不正确' })
2629
+ sendJson(res, 200, { ok: false, error: 'current-invalid' })
2630
+ return
2631
+ }
2632
+ if (!isOidcBound(creds)) {
2633
+ sendJson(res, 200, { ok: true, bound: false, alreadyUnbound: true })
2634
+ return
2635
+ }
2636
+ // 只删 boundSub,其余字段原样保留。
2637
+ const next = { ...creds }
2638
+ delete next.boundSub
2639
+ await writeCredentials(ctx, next)
2640
+ await auditLog(ctx, 'oidc_unbind_success', { username: creds.username, ip: meta.ip, ua: meta.ua })
2641
+ sendJson(res, 200, { ok: true, bound: false })
2441
2642
  } catch (e) {
2442
2643
  sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
2443
2644
  }
2444
2645
  },
2445
- }), 'dsh-webui-oauth: oidc login')
2646
+ }), 'dsh-webui-oauth: oidc unbind')
2446
2647
 
2447
2648
  // OIDC 回调:GET /dsh-webui-oauth/oidc/callback?code&state
2448
2649
  ctx.effect(() => ctx.webServer.register({
@@ -2550,10 +2751,37 @@ export async function apply(ctx) {
2550
2751
  sendJson(res, 200, { ok: false, error: 'oidc-invalid-id-token', detail: vres.error })
2551
2752
  return
2552
2753
  }
2553
- // 建本地会话。身份用**原始 sub**(sanitize 是有损的:'a/b' 与 'a_b' 会塌成同一个
2554
- // 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
2555
2754
  const rawSub = vres.payload.sub
2556
2755
  const auditName = sanitizeSub(rawSub)
2756
+
2757
+ // 用途分支:绑定 vs 登录。purpose 来自服务端 state 表(见 startOidcAuthorize),
2758
+ // 浏览器无法通过改 URL 把绑定流程降级成登录流程。
2759
+ if (st.purpose === 'bind') {
2760
+ // 绑定:把本次校验通过的 sub 记进凭据文件。
2761
+ // 保留原有 username/hash/ttl,只加 boundSub —— 用 spread 以免漏字段。
2762
+ await writeCredentials(ctx, { ...creds, boundSub: rawSub })
2763
+ 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)
2765
+ return
2766
+ }
2767
+
2768
+ // 登录:sub 必须与已绑定值完全一致,否则拒绝。
2769
+ // 这一步是访问控制的核心——没有它,任何能通过该 IdP 的人都能进 WebUI。
2770
+ // 用恒定时间比较,避免通过响应时间侧信道逐字节猜出 boundSub。
2771
+ const bound = boundSubOf(creds)
2772
+ if (!bound) {
2773
+ 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)
2775
+ return
2776
+ }
2777
+ if (!safeTokenEquals(String(rawSub), bound)) {
2778
+ 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)
2780
+ return
2781
+ }
2782
+
2783
+ // 建本地会话。身份用**原始 sub**(sanitize 是有损的:'a/b' 与 'a_b' 会塌成同一个
2784
+ // 本地用户名,拿它当身份会张冠李戴);审计里才用 sanitizeSub 过滤后的短标识。
2557
2785
  const s = createSession(rawSub, ttlOf(creds))
2558
2786
  await auditLog(ctx, 'oidc_login_success', { username: auditName, ip: meta.ip, ua: meta.ua })
2559
2787
  // 授权往返结束:同时下发会话 Cookie 并清除一次性的 state 绑定 Cookie。
package/lib/client.js CHANGED
@@ -80,6 +80,15 @@ window.__ModuleLoader__.load({
80
80
  const [oidcScope, setOidcScope] = react.useState('openid profile email');
81
81
  const [oidcRedirectBase, setOidcRedirectBase] = react.useState('');
82
82
  const [oidcTrustBrowser, setOidcTrustBrowser] = react.useState(true);
83
+ // 绑定状态:bound 表示已绑定某个 IdP 身份;boundSubHint 是掩码后的展示值
84
+ // (服务端只回传掩码,前端拿不到 sub 明文,也不应该拿)。
85
+ const [oidcBound, setOidcBound] = react.useState(false);
86
+ const [oidcBoundHint, setOidcBoundHint] = react.useState('');
87
+ const [unbindPw, setUnbindPw] = react.useState('');
88
+ const [bindMsg, setBindMsg] = react.useState(null);
89
+ // 服务端算好的完整回调地址(含 scheme/host/port)。原生 OIDC 要求
90
+ // redirect_uri 与 IdP 登记值精确匹配,少一个字符都会被拒,所以要显示全。
91
+ const [oidcRedirectUri, setOidcRedirectUri] = react.useState('');
83
92
 
84
93
  react.useEffect(() => {
85
94
  let alive = true;
@@ -99,6 +108,9 @@ window.__ModuleLoader__.load({
99
108
  setOidcClientId((o && o.clientId) || '');
100
109
  if (o && o.scope) setOidcScope(o.scope);
101
110
  if (o && o.redirectBase) setOidcRedirectBase(o.redirectBase);
111
+ setOidcBound(!!(o && o.bound));
112
+ setOidcBoundHint((o && o.boundSubHint) || '');
113
+ setOidcRedirectUri((o && o.redirectUri) || '');
102
114
  }
103
115
  });
104
116
  load();
@@ -215,6 +227,45 @@ window.__ModuleLoader__.load({
215
227
  });
216
228
  };
217
229
 
230
+ // 绑定:先发起服务端授权往返,IdP 校验通过后由回调把 sub 落库。
231
+ // 这里不直接调 API 拿 sub——那样等于让浏览器自报身份,毫无安全意义。
232
+ const onBind = () => {
233
+ if (busy) return;
234
+ setBindMsg(null);
235
+ // base 用当前页面的 origin:反代部署下浏览器看到的才是对外地址。
236
+ const base = location.origin || '';
237
+ location.href = '/dsh-webui-oauth/oidc/bind' + (base ? '?base=' + encodeURIComponent(base) : '');
238
+ };
239
+
240
+ const onUnbind = () => {
241
+ if (busy) return;
242
+ if (!unbindPw) {
243
+ setBindMsg({ kind: 'err', text: '请输入当前密码以解除绑定' });
244
+ return;
245
+ }
246
+ setBusy(true);
247
+ setBindMsg(null);
248
+ api('/dsh-webui-oauth/oidc/unbind', { current: unbindPw }).then((r) => {
249
+ setBusy(false);
250
+ if (r && r.ok) {
251
+ setUnbindPw('');
252
+ setOidcBound(false);
253
+ setOidcBoundHint('');
254
+ setBindMsg({ kind: 'ok', text: '已解除绑定。此后 OIDC 登录将被拒绝,直到重新绑定。' });
255
+ api('/dsh-webui-oauth/status').then((s) => {
256
+ if (s && !s.error) setStatus(s);
257
+ }).catch(() => {});
258
+ } else if (r && r.error === 'current-invalid') {
259
+ setBindMsg({ kind: 'err', text: '当前密码不正确' });
260
+ } else {
261
+ setBindMsg({ kind: 'err', text: '解绑失败:' + ((r && r.error) || '未知错误') });
262
+ }
263
+ }).catch(() => {
264
+ setBusy(false);
265
+ setBindMsg({ kind: 'err', text: '解绑失败:无法连接认证服务' });
266
+ });
267
+ };
268
+
218
269
  const onLogout = () => {
219
270
  if (busy) return;
220
271
  setBusy(true);
@@ -354,12 +405,50 @@ window.__ModuleLoader__.load({
354
405
  react.createElement('label', { className: 'wua-label' }, 'Redirect Base(可选;反代重写 Host 时必填,如 https://dsh.example.com)'),
355
406
  react.createElement('input', { className: 'wua-input', type: 'text', value: oidcRedirectBase, spellCheck: false, onChange: (e) => setOidcRedirectBase(e.target.value) }),
356
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
+ ),
357
418
  react.createElement('div', { className: 'wua-row' },
358
419
  react.createElement('label', { className: 'wua-label' },
359
420
  react.createElement('input', { type: 'checkbox', checked: oidcTrustBrowser, onChange: (e) => setOidcTrustBrowser(e.target.checked) }),
360
421
  ' 信任浏览器 origin(默认开启;关闭时仅用上方 Redirect Base)'),
361
422
  ),
362
423
  ) : null,
424
+ // ---- OIDC 身份绑定 ----
425
+ // 绑定是访问控制前提:未绑定时任何能通过该 IdP 的人都会被拒,
426
+ // 登录页也不会显示 SSO 按钮。因此这块独立于"是否勾选启用",
427
+ // 只要 OIDC 已配置就展示当前绑定状态。
428
+ oidcEnabled ? react.createElement('div', { className: 'wua-section' },
429
+ react.createElement('label', { className: 'wua-label' }, 'OIDC 身份绑定'),
430
+ oidcBound
431
+ ? react.createElement('p', { className: 'wua-intro' },
432
+ '当前已绑定身份:', react.createElement('code', null, oidcBoundHint || '(已绑定)'),
433
+ '。只有该身份可通过 SSO 登录。')
434
+ : react.createElement('p', { className: 'wua-warn' },
435
+ '尚未绑定任何 OIDC 身份。此时 OIDC 登录一律被拒绝,登录页也不会显示 SSO 按钮。请先保存上方配置,再点击「绑定 OIDC 身份」并完成一次 IdP 登录。'),
436
+ oidcBound
437
+ ? react.createElement('div', { className: 'wua-row' },
438
+ react.createElement('label', { className: 'wua-label' }, '当前密码(解除绑定需验证)'),
439
+ react.createElement('input', { className: 'wua-input', type: 'password', value: unbindPw, autoComplete: 'current-password', onChange: (e) => setUnbindPw(e.target.value) }),
440
+ )
441
+ : null,
442
+ react.createElement('div', { className: 'wua-btnrow' },
443
+ oidcBound
444
+ ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onUnbind }, '解除绑定')
445
+ : react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onBind }, '绑定 OIDC 身份'),
446
+ oidcBound
447
+ ? react.createElement('button', { className: 'wua-btn ghost', disabled: busy || status === null, onClick: onBind }, '重新绑定其他身份')
448
+ : null,
449
+ ),
450
+ bindMsg ? react.createElement('p', { className: bindMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, bindMsg.text) : null,
451
+ ) : null,
363
452
  react.createElement('div', { className: 'wua-btnrow' },
364
453
  react.createElement('button', { className: 'wua-btn', disabled: busy || status === null, onClick: onSave },
365
454
  busy ? '保存中…' : '保存账号密码'),
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
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/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/displace-original.test.mjs"
8
8
  },
9
9
  "exports": {
10
10
  ".": "./index.js",