dsh-webui-oauth 0.8.1 → 0.8.2

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,33 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.8.2
6
+
7
+ 浏览器与会话标识改为 HTTP 实时;设备授权必须声明应用。
8
+
9
+ ### 浏览器会话:标识按 HTTP 请求实时更新
10
+
11
+ - `checkRequest` 每次请求都用当前请求的 UA / IP 重新解析系统、浏览器、设备形态,
12
+ 写入会话记录(UA/IP 变化立即落盘,否则每分钟一次);
13
+ - 「浏览器会话」列表不再展示登录时的快照,而是**最近一次请求**的真实标识;
14
+ - 设置页每 15 秒自动刷新会话列表(也可手动刷新)。
15
+
16
+ ### 设备会话:申请时声明应用 + HTTP 来源
17
+
18
+ - `POST /device/authorize` **必须**携带 `client_name`(可选 `client_description`),
19
+ 未声明返回 `400 invalid_request`;
20
+ - 批准页在批准前调用 `POST /device/inspect` 显示应用名称/说明并二次确认;
21
+ - 设备记录保存声明的应用名称、说明,以及每次 Bearer 请求的 IP、UA、系统、浏览器、指纹;
22
+ - 「设备会话」列表展示:应用名称、说明、系统 · 浏览器、指纹、最近使用、来源 IP;
23
+ - 设备 Bearer 请求同样实时刷新这些字段(UA/IP 变化立即落盘)。
24
+
25
+ ### 测试
26
+
27
+ - `device-flow` 新增 8 项:未声明应用被拒、批准页可查看声明、设备列表带
28
+ 应用说明 / 来源 IP / 系统字段 / 指纹;
29
+ - `lifecycle` 新增 2 项:同一会话换 UA 后列表实时更新为 Chrome 130 / Linux;
30
+ - 全量 24 个测试文件、687 项断言通过。
31
+
5
32
  ## 0.8.1
6
33
 
7
34
  设备批准登录回跳、浏览器会话列表、保存提示语义、DeepSeek 子窗口中转、绑定弹窗。
package/README.md CHANGED
@@ -423,6 +423,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
423
423
  给没有浏览器 Cookie 的客户端(dsh-Go 等 App、CLI)使用的第二套凭据。流程遵循 RFC 8628,**无回调**:
424
424
 
425
425
  1. `POST /dsh-webui-oauth/device/authorize`(公开)→ `device_code`、`user_code`、`verification_uri`;
426
+ **必须带 `client_name` 声明应用**(可选 `client_description`),否则返回 `invalid_request`;
426
427
  2. 用户在浏览器打开 `verification_uri`;未登录会自动跳转登录页(密码或 SSO),
427
428
  登录成功后回到批准页并保留 `user_code`,再批准(或拒绝);
428
429
  3. `POST /dsh-webui-oauth/device/token`(公开)轮询 → `access_token`(Bearer)。
@@ -430,9 +431,11 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
430
431
  设备会话与浏览器会话**完全独立**:
431
432
 
432
433
  - 单独存放于 `devices.jsonl`,只存 token 的 SHA-256;
434
+ - 记录**申请时声明的应用名称/说明**,以及每次 Bearer 请求的 IP、UA、系统、浏览器、指纹;
433
435
  - **没有过期时间**,只有显式吊销(`POST /dsh-webui-oauth/devices/revoke`)才失效;
434
436
  - 设置页「设备会话(App / CLI)」单独列表,可逐个吊销;
435
437
  - 浏览器会话也有独立列表(系统 / 浏览器 / 设备指纹 / 最近活跃),可单独吊销;
438
+ 标识按**每个 HTTP 请求**实时刷新,不是登录时的快照;
436
439
  - 设备 Bearer 直接通过 `checkRequest`,可访问 `/api`、`/plugins`、WebSocket 与静态资源;
437
440
  - 设备 Bearer **不能**做设备管理(列表 / 吊销 / 批准新设备只允许浏览器会话)。
438
441
 
package/index.js CHANGED
@@ -949,7 +949,15 @@ class PersistentDevices {
949
949
  try { ev = JSON.parse(line) } catch (e) { continue }
950
950
  if (!ev || typeof ev.op !== 'string') continue
951
951
  if (ev.op === 'add' && ev.dev && typeof ev.dev === 'object' && typeof ev.dev.id === 'string') {
952
- this.live.set(ev.dev.id, { ...ev.dev })
952
+ this.live.set(ev.dev.id, {
953
+ ...ev.dev,
954
+ description: typeof ev.dev.description === 'string' ? ev.dev.description : '',
955
+ lastIp: typeof ev.dev.lastIp === 'string' ? ev.dev.lastIp : null,
956
+ lastUa: typeof ev.dev.lastUa === 'string' ? ev.dev.lastUa : null,
957
+ os: typeof ev.dev.os === 'string' ? ev.dev.os : '',
958
+ browser: typeof ev.dev.browser === 'string' ? ev.dev.browser : '',
959
+ device: typeof ev.dev.device === 'string' ? ev.dev.device : '',
960
+ })
953
961
  } else if (ev.op === 'revoke' && typeof ev.id === 'string') {
954
962
  const d = this.live.get(ev.id)
955
963
  if (d) d.revokedAt = Number(ev.at) || Date.now()
@@ -982,17 +990,23 @@ class PersistentDevices {
982
990
  } catch (e) { /* 压缩失败不影响运行 */ }
983
991
  }
984
992
 
985
- add({ name, hash, clientId, scope }) {
993
+ add({ name, description, hash, clientId, scope }) {
986
994
  const id = randomBytes(8).toString('hex')
987
995
  const now = Date.now()
988
996
  const rec = {
989
997
  id,
990
998
  name: String(name || '设备').slice(0, 60),
999
+ description: String(description || '').slice(0, 160),
991
1000
  hash,
992
1001
  clientId: String(clientId || ''),
993
1002
  scope: String(scope || 'dsh'),
994
1003
  createdAt: now,
995
1004
  lastUsedAt: 0,
1005
+ lastIp: null,
1006
+ lastUa: null,
1007
+ os: '',
1008
+ browser: '',
1009
+ device: '',
996
1010
  }
997
1011
  this.live.set(id, rec)
998
1012
  this.append({ op: 'add', dev: rec })
@@ -1012,13 +1026,13 @@ class PersistentDevices {
1012
1026
  return null
1013
1027
  }
1014
1028
 
1015
- touch(dev) {
1029
+ touch(dev, force) {
1016
1030
  const now = Date.now()
1017
1031
  dev.lastUsedAt = now
1018
- // 每次请求都写盘会放大 IO:最多每分钟落一次 touch
1019
- if (now - (dev.persistedAt || 0) >= 60000) {
1032
+ // 每次请求都写盘会放大 IO:最多每分钟落一次;UA/IP 变化时立即落。
1033
+ if (force || now - (dev.persistedAt || 0) >= 60000) {
1020
1034
  dev.persistedAt = now
1021
- this.append({ op: 'touch', id: dev.id, at: now })
1035
+ this.append({ op: 'add', dev })
1022
1036
  }
1023
1037
  }
1024
1038
 
@@ -1037,10 +1051,17 @@ class PersistentDevices {
1037
1051
  out.push({
1038
1052
  id: dev.id,
1039
1053
  name: dev.name,
1054
+ description: dev.description || '',
1040
1055
  clientId: dev.clientId,
1041
1056
  scope: dev.scope,
1042
1057
  createdAt: dev.createdAt,
1043
1058
  lastUsedAt: dev.lastUsedAt,
1059
+ lastIp: dev.lastIp || null,
1060
+ lastUa: dev.lastUa || null,
1061
+ os: dev.os || '',
1062
+ browser: dev.browser || '',
1063
+ device: dev.device || '',
1064
+ fingerprint: sessionFingerprint({ ua: dev.lastUa, ip: dev.lastIp }),
1044
1065
  })
1045
1066
  }
1046
1067
  out.sort((a, b) => b.createdAt - a.createdAt)
@@ -1200,6 +1221,24 @@ function deviceApprovalPage(userCode) {
1200
1221
  ok.disabled = true; no.disabled = true;
1201
1222
  m.textContent = '处理中…';
1202
1223
  try {
1224
+ // 批准前先看应用声明:让用户知道自己在批准哪个应用。
1225
+ if (decision === 'approve') {
1226
+ try {
1227
+ var infoRes = await fetch('/dsh-webui-oauth/device/inspect', {
1228
+ method: 'POST', headers: { 'content-type': 'application/json' },
1229
+ body: JSON.stringify({ user_code: document.getElementById('c').value })
1230
+ });
1231
+ if (infoRes.status === 401) { goLogin(); return; }
1232
+ var info = await infoRes.json();
1233
+ if (info && info.ok) {
1234
+ var label = info.app + (info.description ? '(' + info.description + ')' : '');
1235
+ if (!window.confirm('应用「' + label + '」请求访问此 DSH,是否批准?')) {
1236
+ m.textContent = '已取消';
1237
+ return;
1238
+ }
1239
+ }
1240
+ } catch (e) { /* inspect 失败不阻断批准 */ }
1241
+ }
1203
1242
  var res = await fetch('/dsh-webui-oauth/device/approve', {
1204
1243
  method: 'POST', headers: { 'content-type': 'application/json' },
1205
1244
  body: JSON.stringify({ user_code: document.getElementById('c').value, action: decision })
@@ -1512,6 +1551,7 @@ const SELF_GATED_EXACT_PATHS = new Set([
1512
1551
  '/dsh-webui-oauth/sessions/revoke-one',
1513
1552
  '/dsh-webui-oauth/sessions',
1514
1553
  '/dsh-webui-oauth/device/approve',
1554
+ '/dsh-webui-oauth/device/inspect',
1515
1555
  '/dsh-webui-oauth/device/core-session',
1516
1556
  '/dsh-webui-oauth/devices',
1517
1557
  '/dsh-webui-oauth/devices/revoke',
@@ -3138,7 +3178,16 @@ export async function apply(ctx) {
3138
3178
  if (bearer) {
3139
3179
  const dev = devices.findByToken(bearer)
3140
3180
  if (!dev) return false
3141
- devices.touch(dev)
3181
+ // 设备会话同样以当前 HTTP 请求实时刷新来源信息(IP / UA / 系统 / 浏览器)。
3182
+ const meta = requestMeta(req)
3183
+ const parsed = describeUserAgent(meta.ua)
3184
+ const changed = dev.lastIp !== meta.ip || dev.lastUa !== meta.ua
3185
+ dev.lastIp = meta.ip
3186
+ dev.lastUa = meta.ua
3187
+ dev.os = parsed.os
3188
+ dev.browser = parsed.browser
3189
+ dev.device = parsed.device
3190
+ devices.touch(dev, changed)
3142
3191
  return true
3143
3192
  }
3144
3193
  const token = cookieOf(req, COOKIE_NAME)
@@ -3149,10 +3198,20 @@ export async function apply(ctx) {
3149
3198
  sessions.delete(token)
3150
3199
  return false
3151
3200
  }
3201
+ // 浏览器标识以**当前 HTTP 请求**为准,实时刷新(UA / IP / 系统 / 浏览器 / 设备)。
3202
+ // 落盘做节流:UA/IP 变化立即写,否则每分钟一次,避免每个静态资源请求都写盘。
3152
3203
  const now = Date.now()
3153
- // 最近活跃时间:每分钟最多落盘一次,供会话列表展示。
3154
- if (now - (s.lastSeenAt || 0) > 60000) {
3155
- s.lastSeenAt = now
3204
+ const meta = requestMeta(req)
3205
+ const parsed = describeUserAgent(meta.ua)
3206
+ const changed = s.ip !== meta.ip || s.ua !== meta.ua
3207
+ s.lastSeenAt = now
3208
+ s.ip = meta.ip
3209
+ s.ua = meta.ua
3210
+ s.os = parsed.os
3211
+ s.browserName = parsed.browser
3212
+ s.device = parsed.device
3213
+ if (changed || now - (s.persistedAt || 0) > 60000) {
3214
+ s.persistedAt = now
3156
3215
  sessions.append({ op: 'add', token, sess: s })
3157
3216
  }
3158
3217
  if (s.browser === true) s.expiresAt = now + SESSION_TTL_MS
@@ -3785,15 +3844,25 @@ export async function apply(ctx) {
3785
3844
  sendJson(res, 429, { error: 'temporarily_unavailable' })
3786
3845
  return
3787
3846
  }
3847
+ // 设备授权必须声明"是什么应用":名称进入待批准记录与设备列表。
3848
+ const name = String(body.client_name || body.app_name || '').trim().slice(0, 60)
3849
+ if (!name) {
3850
+ sendJson(res, 400, {
3851
+ error: 'invalid_request',
3852
+ error_description: 'client_name 必填:设备授权需要在申请时声明应用名称',
3853
+ })
3854
+ return
3855
+ }
3856
+ const description = String(body.client_description || body.app_description || '').trim().slice(0, 160)
3788
3857
  const deviceCode = randomBytes(32).toString('hex')
3789
3858
  const userCode = makeUserCode()
3790
3859
  const scope = String(body.scope || 'dsh').slice(0, 80)
3791
- const name = String(body.client_name || body.name || '').trim().slice(0, 60) || 'App'
3792
3860
  pendingDevices.set(deviceCode, {
3793
3861
  userCode,
3794
3862
  clientId,
3795
3863
  scope,
3796
3864
  name,
3865
+ description,
3797
3866
  expiresAt: now + DEVICE_CODE_TTL_MS,
3798
3867
  interval: DEVICE_INTERVAL_S,
3799
3868
  lastPollAt: 0,
@@ -3863,7 +3932,13 @@ export async function apply(ctx) {
3863
3932
  // 单次发放:同一个 device_code 只能换一次 token。
3864
3933
  const token = randomBytes(32).toString('hex')
3865
3934
  const hash = createHash('sha256').update(token).digest('hex')
3866
- const rec = devices.add({ name: pending.name, hash, clientId: pending.clientId, scope: pending.scope })
3935
+ const rec = devices.add({
3936
+ name: pending.name,
3937
+ description: pending.description,
3938
+ hash,
3939
+ clientId: pending.clientId,
3940
+ scope: pending.scope,
3941
+ })
3867
3942
  pending.issued = true
3868
3943
  pendingDevices.delete(code)
3869
3944
  const meta = requestMeta(req)
@@ -3957,6 +4032,48 @@ export async function apply(ctx) {
3957
4032
  },
3958
4033
  }), 'dsh-webui-oauth: device approve')
3959
4034
 
4035
+ // 批准前查看待授权应用的声明(仅浏览器会话)
4036
+ ctx.effect(() => ctx.webServer.register({
4037
+ kind: 'exact',
4038
+ path: '/dsh-webui-oauth/device/inspect',
4039
+ handler: async (req, res) => {
4040
+ try {
4041
+ if (req.method !== 'POST') {
4042
+ sendJson(res, 405, { error: '仅支持 POST' })
4043
+ return
4044
+ }
4045
+ if (enabledFlag && !sessionOf(req)) {
4046
+ sendJson(res, 401, { error: 'unauthorized' })
4047
+ return
4048
+ }
4049
+ const body = await readJsonBody(req, res)
4050
+ if (body === null) return
4051
+ const wanted = normalizeUserCode(body.user_code)
4052
+ let entry = null
4053
+ for (const pending of pendingDevices.values()) {
4054
+ if (pending.userCode.replace('-', '') === wanted) { entry = pending; break }
4055
+ }
4056
+ if (!entry) {
4057
+ sendJson(res, 200, { ok: false, error: 'invalid-code' })
4058
+ return
4059
+ }
4060
+ if (entry.expiresAt <= Date.now()) {
4061
+ sendJson(res, 200, { ok: false, error: 'expired' })
4062
+ return
4063
+ }
4064
+ sendJson(res, 200, {
4065
+ ok: true,
4066
+ app: entry.name,
4067
+ description: entry.description || '',
4068
+ client_id: entry.clientId,
4069
+ scope: entry.scope,
4070
+ })
4071
+ } catch (e) {
4072
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
4073
+ }
4074
+ },
4075
+ }), 'dsh-webui-oauth: device inspect')
4076
+
3960
4077
  // 设备会话列表 / 吊销(浏览器会话)
3961
4078
  ctx.effect(() => ctx.webServer.register({
3962
4079
  kind: 'exact',
package/lib/client.js CHANGED
@@ -388,6 +388,20 @@ window.__ModuleLoader__.load({
388
388
  // 读取 OIDC 回调带回来的回执。绑定/登录结束后浏览器会被 302 回应用,
389
389
  // 结果通过 query 传递(oidcbound=1 / oidcerr=...):因为是整页跳转,
390
390
  // 组件重新挂载时才知道刚才发生了什么。
391
+ // 会话列表实时刷新:UA/IP 由服务端按每个 HTTP 请求更新,这里定期拉取。
392
+ react.useEffect(() => {
393
+ if (typeof setInterval !== 'function') return;
394
+ const timer = setInterval(() => {
395
+ api('/dsh-webui-oauth/sessions').then((r) => {
396
+ if (r && r.ok && Array.isArray(r.sessions)) setSessionList(r.sessions);
397
+ }).catch(() => {});
398
+ api('/dsh-webui-oauth/devices').then((r) => {
399
+ if (r && r.ok && Array.isArray(r.devices)) setDeviceList(r.devices);
400
+ }).catch(() => {});
401
+ }, 15000);
402
+ return () => { if (typeof clearInterval === 'function') clearInterval(timer); };
403
+ }, []);
404
+
391
405
  // 复验弹窗通过 postMessage 通知;同时轮询 status 兜底(弹窗被拦截/关不掉)。
392
406
  react.useEffect(() => {
393
407
  if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
@@ -1036,8 +1050,13 @@ window.__ModuleLoader__.load({
1036
1050
  style: { alignItems: 'center' },
1037
1051
  },
1038
1052
  react.createElement('span', { className: 'wua-audit-event' }, d.name || '设备'),
1039
- react.createElement('span', { className: 'wua-audit-time' }, '创建 ' + fmtTime(d.createdAt)),
1040
- d.lastUsedAt ? react.createElement('span', { className: 'wua-audit-time' }, '最近 ' + fmtTime(d.lastUsedAt)) : null,
1053
+ d.description ? react.createElement('span', { className: 'wua-audit-user' }, d.description) : null,
1054
+ react.createElement('span', { className: 'wua-audit-user' },
1055
+ [d.os, d.browser].filter(Boolean).join(' · ') || '尚未连接'),
1056
+ react.createElement('span', { className: 'wua-audit-time' }, '指纹 ' + (d.fingerprint || '—')),
1057
+ react.createElement('span', { className: 'wua-audit-time' },
1058
+ d.lastUsedAt ? '最近 ' + fmtTime(d.lastUsedAt) : '从未使用'),
1059
+ d.lastIp ? react.createElement('span', { className: 'wua-audit-ip' }, d.lastIp) : null,
1041
1060
  react.createElement('span', { style: { flex: 1 } }),
1042
1061
  react.createElement('button', {
1043
1062
  className: 'wua-btn ghost',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {