dsh-webui-oauth 0.7.9 → 0.8.0

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,51 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.8.0
6
+
7
+ 新增 **OAuth 2.1 设备授权(RFC 8628,无回调)**:App / CLI 可以获得与浏览器完全独立的设备会话,
8
+ 只有主动吊销才失效。
9
+
10
+ ### 新增:设备授权流程
11
+
12
+ - `POST /dsh-webui-oauth/device/authorize`(公开)→ `device_code`、`user_code`、
13
+ `verification_uri`、`verification_uri_complete`、`expires_in`、`interval`;
14
+ - `GET /dsh-webui-oauth/device`(公开)→ 批准页;输入代码后批准/拒绝;
15
+ - `POST /dsh-webui-oauth/device/approve`(**仅浏览器会话**)→ 批准或拒绝;
16
+ - `POST /dsh-webui-oauth/device/token`(公开)→ 轮询换 `access_token`;
17
+ 支持 `authorization_pending` / `slow_down` / `access_denied` / `expired_token` / `invalid_grant`。
18
+
19
+ ### 新增:独立设备会话
20
+
21
+ - 设备 token 存放在 `devices.jsonl`,**只存 SHA-256**,比对用 `timingSafeEqual`;
22
+ - 无过期时间,只有 `POST /dsh-webui-oauth/devices/revoke` 主动吊销才失效;
23
+ - `checkRequest` 接受 `Authorization: Bearer`,覆盖 `/api`、`/plugins`、
24
+ WebSocket upgrade、静态资源与 fallback;
25
+ - 设置页新增「设备会话(App / CLI)」卡片:名称、创建/最近使用时间、逐个吊销、刷新;
26
+ - 设备 Bearer **不能**做设备管理(列表 / 吊销 / 批准新设备只允许浏览器会话),
27
+ 避免一个被窃取的设备 token 反向吊销其它设备或自我扩权。
28
+
29
+ ### 新增:核心 launch Cookie 桥
30
+
31
+ - `POST /dsh-webui-oauth/device/core-session`(仅设备 Bearer):
32
+ 服务端内部用 `connection.authenticatedUrl()` + `Host: <authority>` 调本机,
33
+ 截获 `dsh-auth-*` Cookie,返回 `{cookie_name, cookie_value, authority}`;
34
+ - **不把 launch token 交给客户端**(它等价于该进程 owner 权限);
35
+ - 响应 `no-store`。
36
+
37
+ ### 安全边界
38
+
39
+ - 设备授权是公开端点:待批准队列上限 1000、设备码 10 分钟过期、轮询最小间隔 5 秒;
40
+ - 设备 token = 该 dsh 的完整权限;建议只保存在可信设备,丢失后立即吊销;
41
+ - 设备管理只允许浏览器会话(Cookie),Bearer 会被 401。
42
+
43
+ ### 测试
44
+
45
+ - 新增 `test/device-flow.test.mjs`(27 项):authorize → pending → 批准 → token →
46
+ Bearer 访问 → 设备列表 → 核心 Cookie 桥 → 主动吊销 → 401;磁盘只存哈希、
47
+ device_code 不可重放、设备 Bearer 不能做设备管理;
48
+ - 全量 24 个测试文件、656 项断言通过。
49
+
5
50
  ## 0.7.9
6
51
 
7
52
  「概览」卡片新增**「吊销所有浏览器会话」**按钮。
package/README.en.md CHANGED
@@ -418,6 +418,31 @@ credentials for no reason. The login path is the opposite and must send a new se
418
418
  Reproduce with `test/oidc-redirect-matrix.test.mjs` (dependency-free unit regression,
419
419
  wired into `npm test`).
420
420
 
421
+ ## Device authorization (App / CLI, OAuth 2.1 device flow)
422
+
423
+ A second credential type for clients without browser cookies (dsh-Go, CLIs), following RFC 8628
424
+ with **no callback**:
425
+
426
+ 1. `POST /dsh-webui-oauth/device/authorize` (public) → `device_code`, `user_code`, `verification_uri`;
427
+ 2. the user opens `verification_uri` in a browser, signs in and approves (or denies) the code;
428
+ 3. `POST /dsh-webui-oauth/device/token` (public) polls → `access_token` (Bearer).
429
+
430
+ Device sessions are **completely independent** of browser sessions:
431
+
432
+ - stored in `devices.jsonl` as SHA-256 hashes only;
433
+ - **no expiry** — only an explicit revoke (`POST /dsh-webui-oauth/devices/revoke`) invalidates them;
434
+ - listed separately on the settings page, revocable one by one;
435
+ - the Bearer goes through `checkRequest`, so it covers `/api`, `/plugins`, WebSockets and static assets;
436
+ - a device Bearer **cannot** manage devices (list / revoke / approve are browser-session only).
437
+
438
+ Core launch cookie bridge: `POST /dsh-webui-oauth/device/core-session` (device Bearer only) calls
439
+ `connection.authenticatedUrl()` internally and returns `{cookie_name, cookie_value, authority}`;
440
+ the launch token is **never** handed to the client.
441
+
442
+ Security: a device token equals full control of this dsh. Keep it on trusted devices and revoke it
443
+ immediately if lost. Public endpoints are bounded: 1000 pending codes, 10-minute expiry, 5-second poll
444
+ interval (`slow_down`).
445
+
421
446
  ## Known limits
422
447
 
423
448
  - **DeepSeek official account login (0.2.1+, remote callback relay)**: `/oauth/callback` is **not**
package/README.md CHANGED
@@ -418,6 +418,31 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
418
418
 
419
419
  复现方式见 `test/oidc-redirect-matrix.test.mjs`(无需外部依赖的单元回归,已接入 `npm test`)。
420
420
 
421
+ ## 设备授权(App / CLI,OAuth 2.1 device flow)
422
+
423
+ 给没有浏览器 Cookie 的客户端(dsh-Go 等 App、CLI)使用的第二套凭据。流程遵循 RFC 8628,**无回调**:
424
+
425
+ 1. `POST /dsh-webui-oauth/device/authorize`(公开)→ `device_code`、`user_code`、`verification_uri`;
426
+ 2. 用户在浏览器打开 `verification_uri`,登录后输入 `user_code` 批准(或拒绝);
427
+ 3. `POST /dsh-webui-oauth/device/token`(公开)轮询 → `access_token`(Bearer)。
428
+
429
+ 设备会话与浏览器会话**完全独立**:
430
+
431
+ - 单独存放于 `devices.jsonl`,只存 token 的 SHA-256;
432
+ - **没有过期时间**,只有显式吊销(`POST /dsh-webui-oauth/devices/revoke`)才失效;
433
+ - 设置页「设备会话(App / CLI)」单独列表,可逐个吊销;
434
+ - 设备 Bearer 直接通过 `checkRequest`,可访问 `/api`、`/plugins`、WebSocket 与静态资源;
435
+ - 设备 Bearer **不能**做设备管理(列表 / 吊销 / 批准新设备只允许浏览器会话)。
436
+
437
+ 核心 launch Cookie 桥:
438
+
439
+ `POST /dsh-webui-oauth/device/core-session`(仅设备 Bearer)在服务端内部用
440
+ `connection.authenticatedUrl()` 换取 `dsh-auth-*` Cookie,返回
441
+ `{cookie_name, cookie_value, authority}`;**不会把 launch token 交给客户端**。
442
+
443
+ 安全边界:设备 token 等价于该 dsh 的完整权限。只保存在可信设备上;丢失后立即在设置页吊销。
444
+ 公开端点有限流:待批准队列上限 1000、过期 10 分钟、轮询间隔 5 秒(`slow_down`)。
445
+
421
446
  ## 已知边界
422
447
 
423
448
  - **DeepSeek 官方账号登录(0.2.1+,远程回调中转)**:`/oauth/callback` **不是公开路径**,仍受会话闸门保护。上游 `@deepseek-ai/dsh-deepseek-account-platform` 的 `loginOrigin()` 只接受 `http://localhost:<port>`、`http://127.0.0.1:<port>`、`http://[::1]:<port>`(显式非零端口),不支持反代域名。插件的处理方式:
package/index.js CHANGED
@@ -153,6 +153,7 @@
153
153
  */
154
154
 
155
155
  import { randomBytes, scrypt as scryptCb, timingSafeEqual, createHmac, createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
156
+ import { request as httpRequest } from "node:http";
156
157
  import { readFileSync, writeFileSync, appendFileSync, accessSync, mkdirSync, unlinkSync, constants as fsConstants } from "node:fs";
157
158
  import { fileURLToPath } from "node:url";
158
159
  import { resolve as resolvePath } from "node:path";
@@ -372,6 +373,11 @@ function sessionsPath() {
372
373
  return DATA_DIR + '/sessions.jsonl'
373
374
  }
374
375
 
376
+ /** 设备会话(App / CLI)持久化文件:只存 token 的 SHA-256,永不存明文。 */
377
+ function devicesPath() {
378
+ return DATA_DIR + '/devices.jsonl'
379
+ }
380
+
375
381
  /** H5: HMAC 密钥文件,用于审计 IP 的假名化。 */
376
382
  function hmacKeyPath() {
377
383
  return DATA_DIR + '/audit-hmac-key'
@@ -741,6 +747,14 @@ function cookieOf(req, name) {
741
747
  return null
742
748
  }
743
749
 
750
+ /** 从 Authorization: Bearer 取设备令牌;格式不合法时返回 null。 */
751
+ function bearerOf(req) {
752
+ const raw = req && req.headers ? req.headers.authorization : null
753
+ if (!raw) return null
754
+ const m = /^Bearer\s+([A-Za-z0-9._~+\/=-]+)$/i.exec(String(raw).trim())
755
+ return m ? m[1] : null
756
+ }
757
+
744
758
  /**
745
759
  * 恒定时间令牌比较:先对两侧做 HMAC-SHA256 归一化(消除长度侧信道),
746
760
  * 再 timingSafeEqual。用于 setup token 校验(128-bit 随机值,网络时序本难利用,
@@ -853,6 +867,221 @@ class PersistentSessions {
853
867
  }
854
868
  }
855
869
 
870
+ /**
871
+ * 设备会话存储(App / CLI 的 Bearer 凭据)。
872
+ *
873
+ * 与浏览器会话**完全独立**:
874
+ * - 不绑 Cookie、不随浏览器退出/会话 TTL 失效;
875
+ * - 没有过期时间,只有显式吊销(revoke)才失效;
876
+ * - 只存 token 的 SHA-256,比对用 timingSafeEqual;
877
+ * - 单独一份 devices.jsonl,设置页单独一个列表。
878
+ */
879
+ class PersistentDevices {
880
+ constructor() {
881
+ this.live = new Map()
882
+ this.ok = true
883
+ }
884
+
885
+ load() {
886
+ let lines = []
887
+ try {
888
+ lines = readFileSync(devicesPath(), 'utf8').split('\n').filter((l) => l.trim())
889
+ } catch (e) { return }
890
+ for (const line of lines) {
891
+ let ev = null
892
+ try { ev = JSON.parse(line) } catch (e) { continue }
893
+ if (!ev || typeof ev.op !== 'string') continue
894
+ if (ev.op === 'add' && ev.dev && typeof ev.dev === 'object' && typeof ev.dev.id === 'string') {
895
+ this.live.set(ev.dev.id, { ...ev.dev })
896
+ } else if (ev.op === 'revoke' && typeof ev.id === 'string') {
897
+ const d = this.live.get(ev.id)
898
+ if (d) d.revokedAt = Number(ev.at) || Date.now()
899
+ } else if (ev.op === 'touch' && typeof ev.id === 'string') {
900
+ const d = this.live.get(ev.id)
901
+ if (d) d.lastUsedAt = Number(ev.at) || 0
902
+ }
903
+ }
904
+ }
905
+
906
+ append(ev) {
907
+ try {
908
+ ensureDataDir()
909
+ appendFileSync(devicesPath(), JSON.stringify(ev) + '\n', { encoding: 'utf8', mode: 0o600 })
910
+ this.ok = true
911
+ } catch (e) {
912
+ this.ok = false
913
+ }
914
+ }
915
+
916
+ compact() {
917
+ try {
918
+ ensureDataDir()
919
+ const out = []
920
+ for (const dev of this.live.values()) out.push({ op: 'add', dev })
921
+ for (const dev of this.live.values()) {
922
+ if (dev.revokedAt) out.push({ op: 'revoke', id: dev.id, at: dev.revokedAt })
923
+ }
924
+ writeFileSync(devicesPath(), out.map((e) => JSON.stringify(e)).join('\n') + (out.length ? '\n' : ''), { encoding: 'utf8', mode: 0o600 })
925
+ } catch (e) { /* 压缩失败不影响运行 */ }
926
+ }
927
+
928
+ add({ name, hash, clientId, scope }) {
929
+ const id = randomBytes(8).toString('hex')
930
+ const now = Date.now()
931
+ const rec = {
932
+ id,
933
+ name: String(name || '设备').slice(0, 60),
934
+ hash,
935
+ clientId: String(clientId || ''),
936
+ scope: String(scope || 'dsh'),
937
+ createdAt: now,
938
+ lastUsedAt: 0,
939
+ }
940
+ this.live.set(id, rec)
941
+ this.append({ op: 'add', dev: rec })
942
+ return rec
943
+ }
944
+
945
+ /** 恒定时间查 token:遍历所有未吊销设备,比较 SHA-256。 */
946
+ findByToken(token) {
947
+ if (typeof token !== 'string' || token.length === 0) return null
948
+ const hb = Buffer.from(createHash('sha256').update(token).digest('hex'), 'hex')
949
+ for (const dev of this.live.values()) {
950
+ if (dev.revokedAt) continue
951
+ let db
952
+ try { db = Buffer.from(String(dev.hash || ''), 'hex') } catch (e) { continue }
953
+ if (db.length === hb.length && timingSafeEqual(db, hb)) return dev
954
+ }
955
+ return null
956
+ }
957
+
958
+ touch(dev) {
959
+ const now = Date.now()
960
+ dev.lastUsedAt = now
961
+ // 每次请求都写盘会放大 IO:最多每分钟落一次 touch
962
+ if (now - (dev.persistedAt || 0) >= 60000) {
963
+ dev.persistedAt = now
964
+ this.append({ op: 'touch', id: dev.id, at: now })
965
+ }
966
+ }
967
+
968
+ revoke(id) {
969
+ const dev = this.live.get(id)
970
+ if (!dev || dev.revokedAt) return false
971
+ dev.revokedAt = Date.now()
972
+ this.append({ op: 'revoke', id, at: dev.revokedAt })
973
+ return true
974
+ }
975
+
976
+ list() {
977
+ const out = []
978
+ for (const dev of this.live.values()) {
979
+ if (dev.revokedAt) continue
980
+ out.push({
981
+ id: dev.id,
982
+ name: dev.name,
983
+ clientId: dev.clientId,
984
+ scope: dev.scope,
985
+ createdAt: dev.createdAt,
986
+ lastUsedAt: dev.lastUsedAt,
987
+ })
988
+ }
989
+ out.sort((a, b) => b.createdAt - a.createdAt)
990
+ return out
991
+ }
992
+
993
+ count() {
994
+ let n = 0
995
+ for (const dev of this.live.values()) if (!dev.revokedAt) n += 1
996
+ return n
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * 设备授权批准页(RFC 8628 的 verification_uri)。
1002
+ * 公开 GET;真正的批准 POST 需要浏览器会话(自管端点里 checkRequest)。
1003
+ * 只回填 A-Z0-9- 的 user_code,避免把 query 原样注入 HTML。
1004
+ */
1005
+ function deviceApprovalPage(userCode) {
1006
+ const code = String(userCode || '').toUpperCase().replace(/[^A-Z0-9-]/g, '').slice(0, 9)
1007
+ return `<!DOCTYPE html>
1008
+ <html lang="zh-CN">
1009
+ <head>
1010
+ <meta charset="utf-8">
1011
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1012
+ <meta name="color-scheme" content="light dark">
1013
+ <title>设备授权 · DSH</title>
1014
+ <style>
1015
+ :root { color-scheme: light dark; }
1016
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
1017
+ background: #f6f7f9; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; color: #15171b; }
1018
+ @media (prefers-color-scheme: dark) { body { background: #141416; color: #f2f3f5; } }
1019
+ .card { width: 380px; max-width: calc(100vw - 40px); padding: 26px 24px; box-sizing: border-box;
1020
+ background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px;
1021
+ box-shadow: 0 18px 50px rgba(0,0,0,.10); }
1022
+ @media (prefers-color-scheme: dark) { .card { background: #1e1e21; border-color: rgba(255,255,255,.10); box-shadow: 0 18px 50px rgba(0,0,0,.5); } }
1023
+ h1 { margin: 0 0 6px; font-size: 18px; }
1024
+ p.sub { margin: 0 0 18px; font-size: 13px; line-height: 1.65; opacity: .68; }
1025
+ label { display: block; font-size: 12px; opacity: .65; margin-bottom: 6px; }
1026
+ input { box-sizing: border-box; width: 100%; height: 42px; padding: 0 12px; font-size: 17px; letter-spacing: .18em;
1027
+ text-align: center; text-transform: uppercase; border-radius: 10px; border: 1px solid rgba(0,0,0,.18);
1028
+ background: transparent; color: inherit; outline: none; }
1029
+ input:focus { border-color: #4a7cf7; box-shadow: 0 0 0 3px rgba(74,124,247,.18); }
1030
+ .row { display: flex; gap: 10px; margin-top: 16px; }
1031
+ button { flex: 1; height: 40px; font-size: 13px; font-weight: 600; border-radius: 10px; cursor: pointer;
1032
+ border: 1px solid rgba(0,0,0,.15); background: transparent; color: inherit; }
1033
+ button.primary { background: #4a7cf7; border-color: #4a7cf7; color: #fff; }
1034
+ button:disabled { opacity: .5; cursor: default; }
1035
+ #m { margin: 14px 0 0; font-size: 13px; line-height: 1.6; min-height: 20px; }
1036
+ a { color: #4a7cf7; }
1037
+ </style>
1038
+ </head>
1039
+ <body>
1040
+ <div class="card">
1041
+ <h1>设备授权</h1>
1042
+ <p class="sub">输入应用显示的代码。批准后,该设备会获得一个与浏览器独立的会话,只有主动吊销才会失效。</p>
1043
+ <label for="c">设备代码</label>
1044
+ <input id="c" value="${code}" autocomplete="off" spellcheck="false" placeholder="XXXX-XXXX">
1045
+ <div class="row">
1046
+ <button id="no">拒绝</button>
1047
+ <button id="ok" class="primary">批准</button>
1048
+ </div>
1049
+ <p id="m"></p>
1050
+ </div>
1051
+ <script>
1052
+ (function () {
1053
+ var m = document.getElementById('m');
1054
+ var ok = document.getElementById('ok');
1055
+ var no = document.getElementById('no');
1056
+ async function act(decision) {
1057
+ ok.disabled = true; no.disabled = true;
1058
+ m.textContent = '处理中…';
1059
+ try {
1060
+ var res = await fetch('/dsh-webui-oauth/device/approve', {
1061
+ method: 'POST', headers: { 'content-type': 'application/json' },
1062
+ body: JSON.stringify({ user_code: document.getElementById('c').value, action: decision })
1063
+ });
1064
+ if (res.status === 401) {
1065
+ m.innerHTML = '请先登录后再批准。<a href="/dsh-webui-oauth/login">前往登录</a>';
1066
+ return;
1067
+ }
1068
+ var j = await res.json();
1069
+ if (j && j.ok) m.textContent = decision === 'approve' ? '已批准,请返回应用完成登录。' : '已拒绝该设备。';
1070
+ else m.textContent = (j && (j.reason || j.error)) || '操作失败';
1071
+ } catch (e) {
1072
+ m.textContent = '无法连接认证服务';
1073
+ } finally {
1074
+ ok.disabled = false; no.disabled = false;
1075
+ }
1076
+ }
1077
+ ok.addEventListener('click', function () { act('approve'); });
1078
+ no.addEventListener('click', function () { act('deny'); });
1079
+ })();
1080
+ </script>
1081
+ </body>
1082
+ </html>`
1083
+ }
1084
+
856
1085
  // ---------------- 登录页(与上游一致,追加 setup-token 输入框) ----------------
857
1086
 
858
1087
  const LOGIN_PAGE = `<!DOCTYPE html>
@@ -1107,6 +1336,9 @@ const PUBLIC_EXACT_PATHS = new Set([
1107
1336
  '/dsh-webui-oauth/setup', // 首次初始化(由 setup token 把关)
1108
1337
  '/dsh-webui-oauth/oidc/login', // SSO 发起(未绑定时内部拒绝)
1109
1338
  '/dsh-webui-oauth/oidc/callback',// IdP 回跳(state + PKCE + 验签把关)
1339
+ '/dsh-webui-oauth/device', // 设备授权批准页(GET;批准 POST 走自管端点)
1340
+ '/dsh-webui-oauth/device/authorize', // RFC 8628 设备授权请求
1341
+ '/dsh-webui-oauth/device/token', // RFC 8628 轮询换 token
1110
1342
  ])
1111
1343
 
1112
1344
  // 本插件自己注册的端点:内部**逐个**自带 checkRequest(公开的那几个按设计放行)。
@@ -1130,6 +1362,10 @@ const SELF_GATED_EXACT_PATHS = new Set([
1130
1362
  '/dsh-webui-oauth/oauth-relay',
1131
1363
  '/dsh-webui-oauth/remote-trust',
1132
1364
  '/dsh-webui-oauth/sessions/revoke',
1365
+ '/dsh-webui-oauth/device/approve',
1366
+ '/dsh-webui-oauth/device/core-session',
1367
+ '/dsh-webui-oauth/devices',
1368
+ '/dsh-webui-oauth/devices/revoke',
1133
1369
  ])
1134
1370
 
1135
1371
  /**
@@ -2661,6 +2897,27 @@ export async function apply(ctx) {
2661
2897
  sessions.load()
2662
2898
  sessions.compact()
2663
2899
 
2900
+ // 设备会话(App / CLI):与浏览器会话独立,只有显式吊销才失效。
2901
+ const devices = new PersistentDevices()
2902
+ devices.load()
2903
+ devices.compact()
2904
+
2905
+ // RFC 8628 待批准的设备码(内存态,10 分钟过期)。
2906
+ const pendingDevices = new Map()
2907
+ const DEVICE_CLIENT_ID = 'dsh-app'
2908
+ const DEVICE_CODE_TTL_MS = 10 * 60 * 1000
2909
+ const DEVICE_INTERVAL_S = 5
2910
+ const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ23456789'
2911
+ function makeUserCode() {
2912
+ const bytes = randomBytes(8)
2913
+ let out = ''
2914
+ for (let i = 0; i < 8; i += 1) out += USER_CODE_ALPHABET[bytes[i] % USER_CODE_ALPHABET.length]
2915
+ return out.slice(0, 4) + '-' + out.slice(4)
2916
+ }
2917
+ function normalizeUserCode(raw) {
2918
+ return String(raw || '').toUpperCase().replace(/[^A-Z0-9]/g, '')
2919
+ }
2920
+
2664
2921
  // H4: 按 IP 限流 { ip -> [timestamps] }
2665
2922
  const failuresByIp = new Map()
2666
2923
  const RATE_WINDOW_MS = 60_000
@@ -2692,6 +2949,16 @@ export async function apply(ctx) {
2692
2949
 
2693
2950
  function checkRequest(req) {
2694
2951
  if (!enabledFlag) return true
2952
+ // 设备会话(App / CLI):Bearer token,与浏览器 Cookie 完全独立。
2953
+ // 显式携带了 Bearer 却无效时直接拒绝,不回落到 Cookie——避免"令牌坏了
2954
+ // 却因为浏览器恰好带着 Cookie 而静默放行"。
2955
+ const bearer = bearerOf(req)
2956
+ if (bearer) {
2957
+ const dev = devices.findByToken(bearer)
2958
+ if (!dev) return false
2959
+ devices.touch(dev)
2960
+ return true
2961
+ }
2695
2962
  const token = cookieOf(req, COOKIE_NAME)
2696
2963
  if (!token) return false
2697
2964
  const s = sessions.get(token)
@@ -3177,6 +3444,352 @@ export async function apply(ctx) {
3177
3444
  },
3178
3445
  }), 'dsh-webui-oauth: revoke sessions')
3179
3446
 
3447
+ // ---------------- 设备授权(RFC 8628 device_code) ----------------
3448
+ //
3449
+ // 面向 App / CLI:无回调、无浏览器 Cookie。流程:
3450
+ // POST /device/authorize → device_code + user_code + verification_uri
3451
+ // 用户在浏览器打开 verification_uri 并批准(需浏览器会话)
3452
+ // POST /device/token → 轮询换 Bearer access_token
3453
+ //
3454
+ // 换到的 token 存在 devices.jsonl(只存 SHA-256),与浏览器会话完全独立,
3455
+ // 没有过期时间,只有显式吊销(/devices/revoke)才失效。
3456
+
3457
+ ctx.effect(() => ctx.webServer.register({
3458
+ kind: 'exact',
3459
+ path: '/dsh-webui-oauth/device/authorize',
3460
+ handler: async (req, res) => {
3461
+ try {
3462
+ if (req.method !== 'POST') {
3463
+ sendJson(res, 405, { error: '仅支持 POST' })
3464
+ return
3465
+ }
3466
+ const body = await readJsonBody(req, res)
3467
+ if (body === null) return
3468
+ const clientId = String(body.client_id || '')
3469
+ if (clientId !== DEVICE_CLIENT_ID) {
3470
+ sendJson(res, 400, { error: 'invalid_client' })
3471
+ return
3472
+ }
3473
+ // 防灌爆:先清过期,再限制待批准队列长度(公开端点,必须设上限)。
3474
+ const now = Date.now()
3475
+ for (const [k, p] of pendingDevices) if (p.expiresAt <= now) pendingDevices.delete(k)
3476
+ if (pendingDevices.size >= 1000) {
3477
+ sendJson(res, 429, { error: 'temporarily_unavailable' })
3478
+ return
3479
+ }
3480
+ const deviceCode = randomBytes(32).toString('hex')
3481
+ const userCode = makeUserCode()
3482
+ const scope = String(body.scope || 'dsh').slice(0, 80)
3483
+ const name = String(body.client_name || body.name || '').trim().slice(0, 60) || 'App'
3484
+ pendingDevices.set(deviceCode, {
3485
+ userCode,
3486
+ clientId,
3487
+ scope,
3488
+ name,
3489
+ expiresAt: now + DEVICE_CODE_TTL_MS,
3490
+ interval: DEVICE_INTERVAL_S,
3491
+ lastPollAt: 0,
3492
+ approvedBy: null,
3493
+ denied: false,
3494
+ issued: false,
3495
+ })
3496
+ const origin = requestOriginOf(req) || ('http://127.0.0.1:' + (ctx.webServer.port || 3080))
3497
+ const verify = origin + '/dsh-webui-oauth/device'
3498
+ sendJson(res, 200, {
3499
+ device_code: deviceCode,
3500
+ user_code: userCode,
3501
+ verification_uri: verify,
3502
+ verification_uri_complete: verify + '?user_code=' + encodeURIComponent(userCode),
3503
+ expires_in: Math.floor(DEVICE_CODE_TTL_MS / 1000),
3504
+ interval: DEVICE_INTERVAL_S,
3505
+ })
3506
+ } catch (e) {
3507
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3508
+ }
3509
+ },
3510
+ }), 'dsh-webui-oauth: device authorize')
3511
+
3512
+ ctx.effect(() => ctx.webServer.register({
3513
+ kind: 'exact',
3514
+ path: '/dsh-webui-oauth/device/token',
3515
+ handler: async (req, res) => {
3516
+ try {
3517
+ if (req.method !== 'POST') {
3518
+ sendJson(res, 405, { error: '仅支持 POST' })
3519
+ return
3520
+ }
3521
+ const body = await readJsonBody(req, res)
3522
+ if (body === null) return
3523
+ const grant = String(body.grant_type || '')
3524
+ if (grant !== 'urn:ietf:params:oauth:grant-type:device_code') {
3525
+ sendJson(res, 400, { error: 'unsupported_grant_type' })
3526
+ return
3527
+ }
3528
+ const code = String(body.device_code || '')
3529
+ const pending = pendingDevices.get(code)
3530
+ if (!pending) {
3531
+ sendJson(res, 400, { error: 'invalid_grant' })
3532
+ return
3533
+ }
3534
+ if (pending.expiresAt <= Date.now()) {
3535
+ pendingDevices.delete(code)
3536
+ sendJson(res, 400, { error: 'expired_token' })
3537
+ return
3538
+ }
3539
+ if (pending.denied) {
3540
+ pendingDevices.delete(code)
3541
+ sendJson(res, 400, { error: 'access_denied' })
3542
+ return
3543
+ }
3544
+ if (!pending.approvedBy) {
3545
+ const now = Date.now()
3546
+ if (pending.lastPollAt && now - pending.lastPollAt < pending.interval * 1000) {
3547
+ pending.lastPollAt = now
3548
+ sendJson(res, 400, { error: 'slow_down' })
3549
+ return
3550
+ }
3551
+ pending.lastPollAt = now
3552
+ sendJson(res, 400, { error: 'authorization_pending' })
3553
+ return
3554
+ }
3555
+ // 单次发放:同一个 device_code 只能换一次 token。
3556
+ const token = randomBytes(32).toString('hex')
3557
+ const hash = createHash('sha256').update(token).digest('hex')
3558
+ const rec = devices.add({ name: pending.name, hash, clientId: pending.clientId, scope: pending.scope })
3559
+ pending.issued = true
3560
+ pendingDevices.delete(code)
3561
+ const meta = requestMeta(req)
3562
+ await auditLog(ctx, 'device_token_issued', { username: pending.approvedBy, ip: meta.ip, ua: meta.ua, detail: rec.name })
3563
+ sendJson(res, 200, {
3564
+ access_token: token,
3565
+ token_type: 'Bearer',
3566
+ scope: pending.scope,
3567
+ device_id: rec.id,
3568
+ })
3569
+ } catch (e) {
3570
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3571
+ }
3572
+ },
3573
+ }), 'dsh-webui-oauth: device token')
3574
+
3575
+ // 批准页(公开 GET;POST 批准需要浏览器会话)
3576
+ ctx.effect(() => ctx.webServer.register({
3577
+ kind: 'exact',
3578
+ path: '/dsh-webui-oauth/device',
3579
+ handler: async (req, res) => {
3580
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
3581
+ sendJson(res, 405, { error: '仅支持 GET' })
3582
+ return
3583
+ }
3584
+ let code = ''
3585
+ try {
3586
+ code = new URL(req.url || '/', 'http://x').searchParams.get('user_code') || ''
3587
+ } catch (e) { code = '' }
3588
+ res.writeHead(200, {
3589
+ 'content-type': 'text/html; charset=utf-8',
3590
+ 'cache-control': 'no-store',
3591
+ 'x-content-type-options': 'nosniff',
3592
+ 'x-frame-options': 'DENY',
3593
+ })
3594
+ res.end(deviceApprovalPage(code))
3595
+ },
3596
+ }), 'dsh-webui-oauth: device page')
3597
+
3598
+ ctx.effect(() => ctx.webServer.register({
3599
+ kind: 'exact',
3600
+ path: '/dsh-webui-oauth/device/approve',
3601
+ handler: async (req, res) => {
3602
+ try {
3603
+ if (req.method !== 'POST') {
3604
+ sendJson(res, 405, { error: '仅支持 POST' })
3605
+ return
3606
+ }
3607
+ // 只允许浏览器会话批准:设备 Bearer 不能给自己或其它设备发放新设备。
3608
+ if (enabledFlag && !sessionOf(req)) {
3609
+ sendJson(res, 401, { error: 'unauthorized' })
3610
+ return
3611
+ }
3612
+ const body = await readJsonBody(req, res)
3613
+ if (body === null) return
3614
+ const wanted = normalizeUserCode(body.user_code)
3615
+ let entry = null
3616
+ let code = null
3617
+ for (const [dc, pending] of pendingDevices) {
3618
+ if (pending.userCode.replace('-', '') === wanted) {
3619
+ entry = pending
3620
+ code = dc
3621
+ break
3622
+ }
3623
+ }
3624
+ if (!entry) {
3625
+ sendJson(res, 200, { ok: false, error: 'invalid-code', reason: '设备代码不存在或已过期' })
3626
+ return
3627
+ }
3628
+ if (entry.expiresAt <= Date.now()) {
3629
+ pendingDevices.delete(code)
3630
+ sendJson(res, 200, { ok: false, error: 'expired', reason: '设备代码已过期,请在应用中重新发起' })
3631
+ return
3632
+ }
3633
+ if (body.action === 'deny') {
3634
+ entry.denied = true
3635
+ const meta = requestMeta(req)
3636
+ await auditLog(ctx, 'device_denied', { username: null, ip: meta.ip, ua: meta.ua, detail: entry.name })
3637
+ sendJson(res, 200, { ok: true, denied: true })
3638
+ return
3639
+ }
3640
+ const sess = sessionOf(req)
3641
+ entry.approvedBy = sess && sess.username ? sess.username : 'operator'
3642
+ if (typeof body.name === 'string' && body.name.trim()) entry.name = body.name.trim().slice(0, 60)
3643
+ const meta = requestMeta(req)
3644
+ await auditLog(ctx, 'device_approved', { username: entry.approvedBy, ip: meta.ip, ua: meta.ua, detail: entry.name })
3645
+ sendJson(res, 200, { ok: true })
3646
+ } catch (e) {
3647
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3648
+ }
3649
+ },
3650
+ }), 'dsh-webui-oauth: device approve')
3651
+
3652
+ // 设备会话列表 / 吊销(浏览器会话)
3653
+ ctx.effect(() => ctx.webServer.register({
3654
+ kind: 'exact',
3655
+ path: '/dsh-webui-oauth/devices',
3656
+ handler: async (req, res) => {
3657
+ if (req.method !== 'GET') {
3658
+ sendJson(res, 405, { error: '仅支持 GET' })
3659
+ return
3660
+ }
3661
+ // 设备管理只开放给浏览器会话:设备 Bearer 不得列出/吊销其它设备。
3662
+ if (enabledFlag && !sessionOf(req)) {
3663
+ sendJson(res, 401, { error: 'unauthorized' })
3664
+ return
3665
+ }
3666
+ sendJson(res, 200, { ok: true, devices: devices.list() })
3667
+ },
3668
+ }), 'dsh-webui-oauth: devices')
3669
+
3670
+ ctx.effect(() => ctx.webServer.register({
3671
+ kind: 'exact',
3672
+ path: '/dsh-webui-oauth/devices/revoke',
3673
+ handler: async (req, res) => {
3674
+ try {
3675
+ if (req.method !== 'POST') {
3676
+ sendJson(res, 405, { error: '仅支持 POST' })
3677
+ return
3678
+ }
3679
+ if (enabledFlag && !sessionOf(req)) {
3680
+ sendJson(res, 401, { error: 'unauthorized' })
3681
+ return
3682
+ }
3683
+ const body = await readJsonBody(req, res)
3684
+ if (body === null) return
3685
+ const id = String(body.id || '')
3686
+ if (!devices.revoke(id)) {
3687
+ sendJson(res, 200, { ok: false, error: 'not-found' })
3688
+ return
3689
+ }
3690
+ const meta = requestMeta(req)
3691
+ await auditLog(ctx, 'device_revoke', { username: null, ip: meta.ip, ua: meta.ua, detail: id })
3692
+ sendJson(res, 200, { ok: true })
3693
+ } catch (e) {
3694
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3695
+ }
3696
+ },
3697
+ }), 'dsh-webui-oauth: device revoke')
3698
+
3699
+ // 给 App 换核心 launch Cookie:设备 Bearer 专用,不把 launch token 交给客户端。
3700
+ ctx.effect(() => ctx.webServer.register({
3701
+ kind: 'exact',
3702
+ path: '/dsh-webui-oauth/device/core-session',
3703
+ handler: async (req, res) => {
3704
+ try {
3705
+ if (req.method !== 'POST') {
3706
+ sendJson(res, 405, { error: '仅支持 POST' })
3707
+ return
3708
+ }
3709
+ const bearer = bearerOf(req)
3710
+ if (!bearer || !devices.findByToken(bearer)) {
3711
+ sendJson(res, 401, { error: 'device-token-required' })
3712
+ return
3713
+ }
3714
+ const conn = ctx.get('connection')
3715
+ if (!conn || typeof conn.authenticatedUrl !== 'function') {
3716
+ sendJson(res, 501, { error: 'connection-unavailable' })
3717
+ return
3718
+ }
3719
+ const origin = requestOriginOf(req)
3720
+ if (!origin) {
3721
+ sendJson(res, 400, { error: 'origin-unavailable' })
3722
+ return
3723
+ }
3724
+ let authority = ''
3725
+ try { authority = new URL(origin).host } catch (e) {
3726
+ sendJson(res, 400, { error: 'origin-invalid' })
3727
+ return
3728
+ }
3729
+ let launch = ''
3730
+ try { launch = String(conn.authenticatedUrl(origin) || '') } catch (e) { launch = '' }
3731
+ if (!launch) {
3732
+ sendJson(res, 500, { error: 'launch-url-unavailable' })
3733
+ return
3734
+ }
3735
+ let launchPath = '/'
3736
+ try {
3737
+ const u = new URL(launch)
3738
+ launchPath = u.pathname + u.search
3739
+ } catch (e) {
3740
+ sendJson(res, 500, { error: 'launch-url-invalid' })
3741
+ return
3742
+ }
3743
+ const port = ctx.webServer.port || 3080
3744
+ const setCookie = await new Promise((resolve) => {
3745
+ const inner = httpRequest({
3746
+ host: '127.0.0.1',
3747
+ port,
3748
+ path: launchPath,
3749
+ method: 'GET',
3750
+ headers: {
3751
+ host: authority,
3752
+ authorization: 'Bearer ' + bearer,
3753
+ 'user-agent': 'dsh-webui-oauth/device-bridge',
3754
+ },
3755
+ }, (r2) => {
3756
+ const sc = r2.headers['set-cookie']
3757
+ r2.resume()
3758
+ resolve(sc || null)
3759
+ })
3760
+ inner.on('error', () => resolve(null))
3761
+ inner.setTimeout(5000, () => { inner.destroy(); resolve(null) })
3762
+ inner.end()
3763
+ })
3764
+ const list = Array.isArray(setCookie) ? setCookie : (setCookie ? [setCookie] : [])
3765
+ let picked = null
3766
+ for (const one of list) {
3767
+ const pair = String(one).split(';')[0]
3768
+ const eq = pair.indexOf('=')
3769
+ if (eq <= 0) continue
3770
+ const cookieName = pair.slice(0, eq).trim()
3771
+ const cookieValue = pair.slice(eq + 1)
3772
+ if (cookieName.startsWith('dsh-auth-')) { picked = { cookieName, cookieValue }; break }
3773
+ if (!picked) picked = { cookieName, cookieValue }
3774
+ }
3775
+ if (!picked) {
3776
+ sendJson(res, 502, { error: 'core-session-unavailable' })
3777
+ return
3778
+ }
3779
+ const meta = requestMeta(req)
3780
+ await auditLog(ctx, 'device_core_session', { username: null, ip: meta.ip, ua: meta.ua, detail: picked.cookieName })
3781
+ sendJson(res, 200, {
3782
+ ok: true,
3783
+ cookie_name: picked.cookieName,
3784
+ cookie_value: picked.cookieValue,
3785
+ authority,
3786
+ })
3787
+ } catch (e) {
3788
+ sendJson(res, 500, { error: e && e.message ? e.message : String(e) })
3789
+ }
3790
+ },
3791
+ }), 'dsh-webui-oauth: device core session')
3792
+
3180
3793
  ctx.effect(() => ctx.webServer.register({
3181
3794
  kind: 'exact',
3182
3795
  path: '/dsh-webui-oauth/status',
@@ -3203,6 +3816,8 @@ export async function apply(ctx) {
3203
3816
  remoteTrust: remoteTrustFlag,
3204
3817
  // 当前会话是否在 2 分钟内通过过 SSO 复验(保存/禁用/解绑可直接进行)。
3205
3818
  reauthActive: sessionReauthOk(req),
3819
+ // 设备会话(App / CLI):与浏览器会话独立的列表,仅主动吊销失效。
3820
+ deviceSessions: devices.count(),
3206
3821
  // 核心兜底席位形态(handler | resource | absent | unknown):核心升级后
3207
3822
  // 如果这里变成 unknown,文档请求会 404 —— 排查时先看这个字段。
3208
3823
  fallback: fallbackShape(ctx.webServer),
package/lib/client.js CHANGED
@@ -288,6 +288,10 @@ window.__ModuleLoader__.load({
288
288
  // 远程访问信任开关。
289
289
  const [remoteTrustBusy, setRemoteTrustBusy] = react.useState(false);
290
290
  const [remoteTrustMsg, setRemoteTrustMsg] = react.useState(null);
291
+ // 设备会话(App / CLI):独立列表,只有主动吊销才失效。
292
+ const [deviceList, setDeviceList] = react.useState(null);
293
+ const [deviceBusy, setDeviceBusy] = react.useState(false);
294
+ const [deviceMsg, setDeviceMsg] = react.useState(null);
291
295
  // 服务端算好的完整回调地址(含 scheme/host/port)。原生 OIDC 要求
292
296
  // redirect_uri 与 IdP 登记值精确匹配,少一个字符都会被拒,所以要显示全。
293
297
  const [oidcRedirectUri, setOidcRedirectUri] = react.useState('');
@@ -330,6 +334,10 @@ window.__ModuleLoader__.load({
330
334
  if (!alive) return;
331
335
  if (r && r.ok && Array.isArray(r.entries)) setAudit(r.entries);
332
336
  }).catch(() => { /* 审计不可用时静默 */ });
337
+ api('/dsh-webui-oauth/devices').then((r) => {
338
+ if (!alive) return;
339
+ if (r && r.ok && Array.isArray(r.devices)) setDeviceList(r.devices);
340
+ }).catch(() => { /* 设备列表不可用时静默 */ });
333
341
  return () => {
334
342
  alive = false;
335
343
  };
@@ -675,6 +683,30 @@ window.__ModuleLoader__.load({
675
683
  return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
676
684
  }
677
685
 
686
+ const onRevokeDevice = (id) => {
687
+ if (deviceBusy) return;
688
+ setDeviceBusy(true);
689
+ setDeviceMsg(null);
690
+ api('/dsh-webui-oauth/devices/revoke', { id }).then((r) => {
691
+ setDeviceBusy(false);
692
+ if (r && r.ok) {
693
+ setDeviceList((list) => (list || []).filter((d) => d.id !== id));
694
+ setDeviceMsg({ kind: 'ok', text: '已吊销该设备会话,对应 token 立即失效。' });
695
+ } else {
696
+ setDeviceMsg({ kind: 'err', text: (r && (r.reason || r.error)) || '吊销失败' });
697
+ }
698
+ }).catch(() => {
699
+ setDeviceBusy(false);
700
+ setDeviceMsg({ kind: 'err', text: '无法连接认证服务' });
701
+ });
702
+ };
703
+
704
+ const refreshDevices = () => {
705
+ api('/dsh-webui-oauth/devices').then((r) => {
706
+ if (r && r.ok && Array.isArray(r.devices)) setDeviceList(r.devices);
707
+ }).catch(() => {});
708
+ };
709
+
678
710
  const onToggleRemoteTrust = () => {
679
711
  if (remoteTrustBusy) return;
680
712
  const next = !remoteTrust;
@@ -861,6 +893,36 @@ window.__ModuleLoader__.load({
861
893
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
862
894
  '在「设置 → 账号」点击 DeepSeek 登录后,浏览器会跳到 127.0.0.1 的回调地址(远程机器上通常打不开)。官方「等待登录」窗口里会直接出现一个粘贴框:把地址栏里的完整 URL 粘进去即可。'),
863
895
  ]),
896
+ card('设备会话(App / CLI)', [
897
+ deviceList === null ? null : badge(String(deviceList.length) + ' 个', deviceList.length ? 'ok' : 'muted'),
898
+ ], [
899
+ react.createElement('p', { className: 'wua-intro' },
900
+ '设备通过 OAuth 2.1 设备授权(无回调)登录。它们与浏览器会话完全独立,不会过期,只有主动吊销才会失效。'),
901
+ deviceList === null
902
+ ? react.createElement('p', { className: 'wua-intro' }, '正在读取…')
903
+ : (deviceList.length === 0
904
+ ? react.createElement('p', { className: 'wua-intro' }, '暂无设备会话')
905
+ : react.createElement('div', { className: 'wua-audit' },
906
+ deviceList.map((d) => react.createElement('div', {
907
+ key: d.id,
908
+ className: 'wua-audit-row',
909
+ style: { alignItems: 'center' },
910
+ },
911
+ react.createElement('span', { className: 'wua-audit-event' }, d.name || '设备'),
912
+ react.createElement('span', { className: 'wua-audit-time' }, '创建 ' + fmtTime(d.createdAt)),
913
+ d.lastUsedAt ? react.createElement('span', { className: 'wua-audit-time' }, '最近 ' + fmtTime(d.lastUsedAt)) : null,
914
+ react.createElement('span', { style: { flex: 1 } }),
915
+ react.createElement('button', {
916
+ className: 'wua-btn ghost',
917
+ disabled: deviceBusy,
918
+ onClick: () => onRevokeDevice(d.id),
919
+ }, '吊销'),
920
+ )))),
921
+ react.createElement('div', { className: 'wua-actions' },
922
+ react.createElement('button', { className: 'wua-btn ghost', onClick: refreshDevices }, '刷新列表'),
923
+ ),
924
+ deviceMsg ? react.createElement('p', { className: deviceMsg.kind === 'ok' ? 'wua-msg' : 'wua-err' }, deviceMsg.text) : null,
925
+ ]),
864
926
  card('最近登录记录', [], [
865
927
  react.createElement('p', { className: 'wua-hint', style: { borderTop: 'none', paddingTop: 0 } },
866
928
  '完整审计日志可用 node index.js audit 查看。'),
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.7.9",
3
+ "version": "0.8.0",
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/client-prefill.test.mjs && node test/displace-original.test.mjs && node test/oidc-redirect-matrix.test.mjs && node test/gate-default-deny.test.mjs && node test/fallback-shape.test.mjs && node test/desktop-marker.test.mjs && node test/audit-null.test.mjs && node test/remote-trust.test.mjs && node test/oauth-relay.test.mjs && node test/lifecycle.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 && node test/oidc-redirect-matrix.test.mjs && node test/gate-default-deny.test.mjs && node test/fallback-shape.test.mjs && node test/desktop-marker.test.mjs && node test/audit-null.test.mjs && node test/remote-trust.test.mjs && node test/oauth-relay.test.mjs && node test/lifecycle.test.mjs && node test/device-flow.test.mjs"
8
8
  },
9
9
  "exports": {
10
10
  ".": "./index.js",