fix(uds-auth): keep sessions until manual sign-out; refresh expired UAC tokens by QR (0.3.20)
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run

Users were asked to sign in again every ~30 minutes, even while active.

Session lifetime
- Browser: UDS_SESSION kept its login-time Max-Age and was never re-issued,
  so the cookie vanished 30 min after login. Re-issue it on use (throttled
  by slidingInterval).
- Desktop: remote.mux WS traffic never passed the HTTP auth middleware, so
  the idle TTL never slid. assertPrincipalLive now reports activity and
  slides the session and the account's bridges (throttled).
- New setting sessionIdleTimeoutHours (Settings page + config). Default 0 =
  no automatic sign-out; only a manual sign-out ends a session, so
  conversations and night-time cron are never cut off. 0.5-168 enables an
  idle timeout with the 7-day cap. The TTL is read live.
- Stores support long TTLs: the absolute cap follows the TTL, older 7-day
  sessions are lifted on activity, and expiry timers re-arm instead of
  overflowing setTimeout (~24.8 days).

UAC token check
- Every 2 hours (and 1 min after start) each QR session's token is checked
  against icenterapi. A rejected token is only flagged, never a sign-out.
  Network errors, 5xx and bad JSON keep the previous verdict (the old 6h
  check revoked all of an account's sessions on any failure, including
  outages).
- GET /uds-auth/session-status (non-sliding) returns alive + tokenValid.
  The client probes it every minute and on focus: a gone session signs the
  UI out with a notice; a rejected token opens a refresh QR once.
- Scanning in refresh mode writes the new token into every live session of
  the account and the skill credential cache in place: no new session, no
  reload. Other accounts are rejected (token_refresh_emp_mismatch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-13 19:59:48 +08:00
parent dfdf298184
commit 0e6de055a9
20 changed files with 904 additions and 119 deletions

View file

@ -40,13 +40,16 @@ outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn
trustedHosts: '' # empty = loopback Host only; proxies must set allowlist
retainSkillCredentialsOnLogout: true
skillCredentialTtlSeconds: 604800
sessionIdleTimeoutHours: 0 # 0 = never auto sign out; 0.5–168h idle timeout; editable in Settings
```
Durable state defaults to `~/.uds-auth/` (roles / owners / credentials / bridge / sessions / `config.runtime.json`; override with `UDS_AUTH_DATA_DIR`). Security toggles take effect on the next request after Settings save. Empty outbound allowlist denies all.
## Sessions
- **Lifetime**: 30-minute idle timeout that slides on activity, 7-day absolute cap. The Desktop bridge token slides with its session.
- **Lifetime**: by default (`sessionIdleTimeoutHours: 0`) sessions never sign out on their own — only a manual sign-out ends them, so running conversations and night-time scheduled tasks are never cut off. Setting 0.5–168 hours in Settings enables an idle timeout that slides on activity (HTTP requests and Desktop WS calls) with a 7-day absolute cap. The browser cookie and the Desktop bridge token slide with the session.
- **UAC token check**: every 2 hours (and 1 minute after start) each QR session's UAC token is re-checked against the icenterapi department profile. A rejected token (401/403, business error code, employee not visible/mismatched) never signs out: the client shows a QR prompt, and the scan writes the new token into every live session of that account in place — no new session, no reload. Network errors, 5xx and malformed responses keep the previous verdict.
- **Client probe**: `GET /uds-auth/session-status` (never slides the TTL) runs every minute and on window focus. It returns `alive` and `tokenValid`; a gone session signs the UI out, a rejected token opens the refresh QR once.
- **Survives restarts**: the default in-memory store is persisted to `~/.uds-auth/sessions.json` and restored on Host start (emergency admins have no SSO re-login, so they depend on this).
- Only the SHA-256 hash of each session token is stored; user data (may include the UAC token) is AES-256-GCM encrypted with the plugin key `.uds-auth-secret-key`.
- Expired, undecryptable or tampered rows are dropped on load; disabled-account and emergency-password rotation checks still run on every request.

View file

@ -43,6 +43,7 @@ outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn
trustedHosts: '' # 精确 Host 白名单;空=仅 loopback Host
retainSkillCredentialsOnLogout: true
skillCredentialTtlSeconds: 604800
sessionIdleTimeoutHours: 0 # 0 = 不自动退出;0.5–168 = 闲置超时(小时);设置页可改
```
状态文件默认在 `~/.uds-auth/`(roles / owners / credentials / bridge / sessions / `config.runtime.json`)。可用环境变量 `UDS_AUTH_DATA_DIR` 覆盖。从安装目录升级时,若新数据目录为空且安装根仍有旧 `roles.json` 等,启动会一次性迁移(幂等、不覆盖已有目标文件)。公网或反代必须配置 `trustedHosts`。默认目录为**用户级**共享路径,不是按 DSH profile 隔离;单实例部署须知悉该边界。
@ -51,7 +52,9 @@ skillCredentialTtlSeconds: 604800
## 会话
- **有效期**:30 分钟无操作过期,有操作自动续期;从登录起最长 7 天。Desktop 的 bridge 令牌随会话一起续期。
- **有效期**:默认(`sessionIdleTimeoutHours: 0`)不自动退出,只有手动退出才会结束会话,进行中的对话和夜间定时任务不会被打断。设置页填 0.5–168 小时可开启闲置超时:有操作(HTTP 请求及 Desktop 的 WS 调用)自动续期,最长 7 天。浏览器 cookie 和 Desktop 的 bridge 令牌随会话一起续期。
- **UAC token 检查**:每 2 小时(启动 1 分钟后先查一次)用各扫码会话的 UAC token 调 icenterapi 查部门信息。token 被拒(401/403、业务错误码、查不到/工号不符)**不会退出**:客户端弹出扫码提示,扫码后新 token 原地写入该账号所有在线会话,不新建会话、不刷新页面。网络错误、5xx、返回格式异常保持上次结论不变。
- **客户端探测**:每分钟及窗口重新获得焦点时调用 `GET /uds-auth/session-status`(不会续期),返回 `alive` 和 `tokenValid`:会话已不存在时界面自动退出;token 被拒时弹出一次刷新二维码。
- **重启不掉线**:默认内存会话会写入 `~/.uds-auth/sessions.json`,Host / Harness 重启后自动恢复(应急账号没有 SSO 自动重登,尤其依赖这一点)。
- 文件只保存会话令牌的 SHA-256 哈希,不保存令牌本身;用户信息(可能含 UAC token)用插件密钥 `.uds-auth-secret-key` 做 AES-256-GCM 加密。
- 加载时丢弃已过期、解密失败或被篡改的记录;停用账号、应急口令轮换等检查仍在每次请求时执行。

View file

@ -12,6 +12,9 @@ allowOpenRegistration: true
allowRawAgentToken: false
retainSkillCredentialsOnLogout: true
skillCredentialTtlSeconds: 604800
# Login session idle timeout in hours: 0 = never sign out automatically (manual sign-out only);
# 0.5–168 = sign out after that long idle (slides while in use, 7-day absolute cap)
sessionIdleTimeoutHours: 0
outboundAllowedHosts: icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn
# Optional exact Host allowlist for /uds-auth writes; empty = Origin must match Host only
trustedHosts: ''

View file

@ -119,7 +119,7 @@ def load_uds_skill_auth():
- 全局共享 token / 把 token 注入 `shellEnv`
- SKILL 教打印环境变量中的密钥
- 无白名单的开放代理
- 用 UI `sessionStore` 的 30min TTL 冒充 skill 长凭证
- 用 UI `sessionStore` 的闲置 TTL(`sessionIdleTimeoutHours`)冒充 skill 长凭证
- 业务 skill 硬编码插件源码路径(应用 `uds-skill-auth` skill)
## 验收

View file

@ -37,6 +37,12 @@ window.__ModuleLoader__.load({
"ui.deployConfig": "部署配置",
"ui.userSearchUrl": "用户搜索 URL(token 校验)",
"ui.workspaceRoot": "工作区根目录(空=$DSH_HOME/user-workspaces)",
"ui.sessionIdleTimeoutHours": "会话闲置超时(小时,0 = 不自动退出)",
"ui.tokenExpiredTitle": "UAC 登录已过期",
"ui.tokenExpiredHint": "请扫码刷新,不影响当前会话和定时任务",
"ui.tokenRefreshed": "UAC 登录已刷新",
"ui.sessionExpiredSignedOut": "登录已过期,已自动退出",
"ui.sessionIdleTimeoutHint": "默认 0:只有手动退出才会退出,定时任务和进行中的会话不受影响。大于 0 时,闲置超过该时长自动退出(范围 0.5–168)。UAC token 过期不会退出,只会提示扫码刷新。",
"ui.allowRawAgentToken": "允许 Skill 获取原始 UDS token",
"ui.allowRawAgentTokenHint": "开启后,具备当前任务 credentials scope 的本机 Skill 可获取工号和 token。只在业务确实需要原始 token 时开启。",
"ui.saveConfig": "保存配置",
@ -195,6 +201,12 @@ window.__ModuleLoader__.load({
"ui.deployConfig": "Deploy config",
"ui.userSearchUrl": "User search URL (token verify)",
"ui.workspaceRoot": "Workspace root (empty=$DSH_HOME/user-workspaces)",
"ui.sessionIdleTimeoutHours": "Session idle timeout (hours, 0 = never sign out)",
"ui.tokenExpiredTitle": "UAC sign-in expired",
"ui.tokenExpiredHint": "Scan to refresh it — your sessions and scheduled tasks keep running",
"ui.tokenRefreshed": "UAC sign-in refreshed",
"ui.sessionExpiredSignedOut": "Session expired — you have been signed out",
"ui.sessionIdleTimeoutHint": "Default 0: only a manual sign-out ends the session; scheduled tasks and running conversations are unaffected. Above 0, sessions idle that long sign out (range 0.5–168). An expired UAC token never signs out — it only prompts a QR refresh.",
"ui.allowRawAgentToken": "Allow Skills to read the raw UDS token",
"ui.allowRawAgentTokenHint": "Enables local Skills with the current task credentials scope to read the employee number and token. Turn this on only when a workflow needs the raw token.",
"ui.saveConfig": "Save config",
@ -468,6 +480,9 @@ window.__ModuleLoader__.load({
'.uds-auth-avatar{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;flex:none;font-size:10px;line-height:1;color:var(--dsw-alias-label-secondary,#646a73);background:transparent;border:none}',
'.uds-auth-badge-unauth .uds-auth-avatar{background:var(--dsw-alias-bg-module-platform,rgba(242,243,245,1));color:var(--dsw-alias-label-tertiary,#8f959e)}',
'.uds-auth-badge-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.uds-auth-badge.has-token-warning::after{content:"";flex:none;width:6px;height:6px;margin-left:6px;border-radius:50%;background:var(--dsw-alias-state-warning-primary,#e37318)}',
'.uds-auth-token-warn{margin:8px 0 4px;padding:8px 10px;border-radius:6px;background:rgba(227,115,24,.08);font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary,#646a73)}',
'.uds-auth-token-warn strong{display:block;color:var(--dsw-alias-state-warning-primary,#e37318);font-weight:500}',
'.uds-auth-role{font-size:10px;padding:1px 6px;border-radius:10px;font-weight:600;flex-shrink:0}',
'.uds-auth-role-super_admin{background:rgba(213,73,65,.12);color:var(--dsw-alias-state-error-primary,#d54941)}',
'.uds-auth-role-admin{background:rgba(217,119,6,.12);color:var(--dsw-alias-state-warn-primary,#d97706)}',
@ -973,6 +988,53 @@ window.__ModuleLoader__.load({
return document.documentElement.getAttribute('data-uds-logged-in') === '1'
}
/** Server error codes / messages meaning the caller's local session is gone. */
const SESSION_GONE_CODES = new Set(['session_revoked', 'login_required_session', 'not_logged_in'])
const SESSION_GONE_MESSAGES = new Set(['会话已失效,请重新登录', 'Session expired; please sign in again'])
function looksLikeSessionGone(error) {
if (!error || typeof error !== 'object') return false
const code = error.details?.udsError || error.data?.error || error.error
return SESSION_GONE_CODES.has(String(code || ''))
|| SESSION_GONE_MESSAGES.has(String(error.message || '').trim())
}
let _sessionCheckInFlight = null
/**
* Ask the server whether our session is still alive. /uds-auth/session-status never
* slides the idle TTL, so the idle watcher cannot keep an unattended tab signed in.
* Only a definite alive=false signs out — network/server trouble never does.
*/
function checkSessionAlive() {
if (!isLoggedInInUi()) return Promise.resolve(true)
if (_sessionCheckInFlight) return _sessionCheckInFlight
_sessionCheckInFlight = (async () => {
try {
const res = await fetch('/uds-auth/session-status', {
credentials: 'include',
cache: 'no-store',
headers: bridgeAuthHeaders(),
})
if (!res.ok) return true
const data = await res.json().catch(() => null)
if (data && data.alive === false && isLoggedInInUi()) {
window.dispatchEvent(new Event('uds-auth-expired'))
return false
}
// UAC token rejected → prompt a QR refresh; the session itself stays.
if (data && data.alive && typeof data.tokenValid === 'boolean') {
window.dispatchEvent(new Event(data.tokenValid ? 'uds-auth-token-ok' : 'uds-auth-token-expired'))
}
return true
} catch {
return true
} finally {
_sessionCheckInFlight = null
}
})()
return _sessionCheckInFlight
}
function clearSessionIfAnonymous(sessions) {
// Until /api/me confirms login, treat as anonymous (AuthBadge refreshes me on mount).
if (isLoggedInInUi()) return
@ -1114,6 +1176,7 @@ function reloadAfterLogin() {
: { ...baseHeaders, ...optHeaders }
const res = await fetch(url, { credentials: 'include', ...options, headers })
const data = await res.json().catch(() => ({}))
if (res.status === 401) void checkSessionAlive()
if (!res.ok) {
const err = new Error(data.message || data.error || res.statusText || t('err.request_failed'))
err.status = res.status
@ -1316,6 +1379,7 @@ function reloadAfterLogin() {
loginSystemCode: '',
originSystemCode: '',
workspaceRoot: '',
sessionIdleTimeoutHours: 0,
allowRawAgentToken: false,
})
const [fallbackEnabled, setFallbackEnabled] = useState(false)
@ -1439,6 +1503,20 @@ function reloadAfterLogin() {
field('loginSystemCode', 'loginSystemCode'),
field('originSystemCode', 'originSystemCode'),
field('workspaceRoot', t('ui.workspaceRoot')),
h('div', { className: 'uds-auth-settings-field' },
h('label', { htmlFor: 'uds-auth-sessionIdleTimeoutHours' }, t('ui.sessionIdleTimeoutHours')),
h('input', {
id: 'uds-auth-sessionIdleTimeoutHours',
type: 'number',
min: 0,
max: 168,
step: 0.5,
value: form.sessionIdleTimeoutHours ?? '',
disabled: !canSettings || busy,
onChange: (e) => setForm((prev) => ({ ...prev, sessionIdleTimeoutHours: e.target.value })),
}),
),
h('p', { className: 'uds-auth-settings-intro' }, t('ui.sessionIdleTimeoutHint')),
h('label', { className: 'uds-auth-settings-toggle' },
h('input', {
type: 'checkbox',
@ -1542,6 +1620,12 @@ function reloadAfterLogin() {
const [open, setOpen] = useState(false)
const [anchor, setAnchor] = useState(null)
const [user, setUser] = useState(null)
const [sessionExpired, setSessionExpired] = useState(false)
const [tokenExpired, setTokenExpired] = useState(false)
const tokenExpiredRef = useRef(false)
const [tokenNotice, setTokenNotice] = useState('')
/** QR in refresh mode: a signed-in user re-scans to renew the UAC token in place. */
const refreshModeRef = useRef(false)
const [loading, setLoading] = useState(false)
@ -1682,8 +1766,9 @@ function reloadAfterLogin() {
setQrStatus(t('ui.qrGenerating'))
setQrImg('')
// Me check in background — never block QR paint on /api/me.
const refreshing = refreshModeRef.current
void refreshUser().then((loggedIn) => {
if (!loggedIn) return
if (!loggedIn || refreshing) return
stopQr()
setQrStatus('')
setQrImg('')
@ -1692,7 +1777,7 @@ function reloadAfterLogin() {
try {
const started = await fetchJson('/uds-auth/qr-start')
// Already signed in while QR was starting — drop the code.
if (document.documentElement.getAttribute('data-uds-logged-in') === '1') {
if (!refreshing && document.documentElement.getAttribute('data-uds-logged-in') === '1') {
stopQr()
setQrStatus('')
setQrImg('')
@ -1720,7 +1805,7 @@ function reloadAfterLogin() {
const result = await fetchJson('/uds-auth/qr-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-UDS-QR-Binding': browserBinding },
body: JSON.stringify({ qrCodeKey, qrCodeValue }),
body: JSON.stringify(refreshing ? { qrCodeKey, qrCodeValue, refresh: true } : { qrCodeKey, qrCodeValue }),
})
if (qrRef.current.key !== qrCodeKey) return
const codeCode = result.code?.code || ''
@ -1730,6 +1815,14 @@ function reloadAfterLogin() {
setQrExpired(false)
setQrFailed(false)
const auth = result.auth
if (auth?.success && auth.refreshed) {
// Token renewed in place — no new session, no reload.
tokenExpiredRef.current = false
setTokenExpired(false)
setTokenNotice(t('ui.tokenRefreshed'))
setTimeout(() => { setOpen(false); setTokenNotice('') }, 1500)
return
}
if (!auth?.success || !auth?.bridge?.token) {
setQrStatus(t('ui.loginFailed'))
return
@ -1813,6 +1906,76 @@ function reloadAfterLogin() {
}
}, [localKey, refreshUser])
/** Drop local auth state. `expired`: server already dropped the session — skip /api/logout. */
const signOut = useCallback(async ({ expired = false } = {}) => {
if (!expired) {
try { await fetchJson('/uds-auth/api/logout', { method: 'POST' }) } catch { /* ignore */ }
}
clearAuthCookies()
clearBridge()
try { window.__udsAuthWsAuthedOnce = false } catch { /* ignore */ }
try { window.__udsAuthResetEmp = null } catch { /* ignore */ }
setUser(null)
tokenExpiredRef.current = false
setTokenExpired(false)
setSessionExpired(expired)
// Expired: open the panel once so the reason and a fresh QR are right there.
setOpen(expired)
document.documentElement.setAttribute('data-uds-logged-in', '0')
document.documentElement.setAttribute('data-uds-can-settings', '0')
document.documentElement.setAttribute('data-uds-can-create-ws', '0')
clearSessionIfAnonymous(window.__udsAuthSessions)
window.dispatchEvent(new Event('uds-auth-changed'))
try { softReconnectAuth({ force: true }) } catch { /* ignore */ }
}, [])
useEffect(() => {
const onExpired = () => { if (isLoggedInInUi()) void signOut({ expired: true }) }
window.addEventListener('uds-auth-expired', onExpired)
return () => window.removeEventListener('uds-auth-expired', onExpired)
}, [signOut])
useEffect(() => {
if (user) setSessionExpired(false)
}, [user])
// UAC token expired: open the panel once with a refresh QR. The session is untouched.
useEffect(() => {
const onTokenExpired = () => {
if (tokenExpiredRef.current || !isLoggedInInUi()) return
tokenExpiredRef.current = true
setTokenExpired(true)
setOpen(true)
}
const onTokenOk = () => {
tokenExpiredRef.current = false
setTokenExpired(false)
}
window.addEventListener('uds-auth-token-expired', onTokenExpired)
window.addEventListener('uds-auth-token-ok', onTokenOk)
return () => {
window.removeEventListener('uds-auth-token-expired', onTokenExpired)
window.removeEventListener('uds-auth-token-ok', onTokenOk)
}
}, [])
// Idle watcher: notice server-side idle expiry within a minute (and right away when
// the window comes back), instead of surfacing "please sign in again" on every call.
useEffect(() => {
if (!user) return undefined
const check = () => { void checkSessionAlive() }
const onVisible = () => { if (document.visibilityState === 'visible') check() }
const timer = setInterval(check, 60_000)
check()
window.addEventListener('focus', check)
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(timer)
window.removeEventListener('focus', check)
document.removeEventListener('visibilitychange', onVisible)
}
}, [user])
useEffect(() => {
// Restore session after login reload / refresh — cookie alone does not set the badge.
// (Previously me ran only inside startQr, so a click on「未登录」was required.)
@ -1842,10 +2005,12 @@ function reloadAfterLogin() {
return () => { cancelled = true }
}, [open, user])
refreshModeRef.current = !!(user && tokenExpired)
useEffect(() => {
if (open && !user && loginMode === 'qr') startQr()
if (!open || loginMode !== 'qr') stopQr()
}, [open, user, loginMode, startQr, stopQr])
const wantQr = open && loginMode === 'qr' && (!user || tokenExpired)
if (wantQr) startQr()
else stopQr()
}, [open, user, tokenExpired, loginMode, startQr, stopQr])
useLayoutEffect(() => {
if (!open) { setAnchor(null); return undefined }
@ -1882,6 +2047,49 @@ function reloadAfterLogin() {
: (qrStatus || t('ui.qrGenerateFailed'))
const qrOverlayHint = t('ui.qrExpiredHint')
const renderQr = () => (
h('div', { className: 'uds-auth-qr' },
(qrImg || qrOverlay) && h('div', {
className: 'uds-auth-qr-frame' + (qrOverlay ? ' is-expired' : ''),
},
qrImg ? h('img', { src: qrImg, alt: 'QR' }) : h('div', {
style: {
width: '100%',
height: '100%',
background: 'var(--dsw-alias-bg-module-platform, #f4f5f7)',
},
}),
qrOverlay && h('div', { className: 'uds-auth-qr-overlay' },
h('div', { className: 'uds-auth-qr-overlay-title' }, qrOverlayTitle),
h('div', { className: 'uds-auth-qr-overlay-hint' }, qrOverlayHint),
h('button', {
type: 'button',
className: 'uds-auth-qr-refresh',
'aria-label': t('ui.refreshQr'),
title: t('ui.refreshQr'),
onClick: startQr,
},
h('svg', {
width: 22,
height: 22,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2.4,
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': 'true',
},
h('path', { d: 'M21 12a9 9 0 1 1-2.6-6.2' }),
h('polyline', { points: '21 3 21 9 15 9' }),
),
),
),
),
!qrOverlay && h('div', { className: 'uds-auth-qr-status' }, qrStatus || t('ui.loading')),
)
)
return h('div', {
ref: rootRef,
className: 'uds-auth-host' + (wide ? '' : ' is-rail'),
@ -1901,49 +2109,11 @@ function reloadAfterLogin() {
? (loginMode === 'qr'
? h(React.Fragment, null,
h('div', { className: 'uds-auth-info' },
h('div', { className: 'uds-auth-info-name' }, qrOverlay ? qrOverlayTitle : t('ui.notLoggedIn')),
h('div', { className: 'uds-auth-info-name' },
qrOverlay ? qrOverlayTitle : (sessionExpired ? t('ui.sessionExpiredSignedOut') : t('ui.notLoggedIn'))),
h('div', { className: 'uds-auth-info-detail' }, qrOverlay ? qrOverlayHint : t('ui.pleaseScan')),
),
h('div', { className: 'uds-auth-qr' },
(qrImg || qrOverlay) && h('div', {
className: 'uds-auth-qr-frame' + (qrOverlay ? ' is-expired' : ''),
},
qrImg ? h('img', { src: qrImg, alt: 'QR' }) : h('div', {
style: {
width: '100%',
height: '100%',
background: 'var(--dsw-alias-bg-module-platform, #f4f5f7)',
},
}),
qrOverlay && h('div', { className: 'uds-auth-qr-overlay' },
h('div', { className: 'uds-auth-qr-overlay-title' }, qrOverlayTitle),
h('div', { className: 'uds-auth-qr-overlay-hint' }, qrOverlayHint),
h('button', {
type: 'button',
className: 'uds-auth-qr-refresh',
'aria-label': t('ui.refreshQr'),
title: t('ui.refreshQr'),
onClick: startQr,
},
h('svg', {
width: 22,
height: 22,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2.4,
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': 'true',
},
h('path', { d: 'M21 12a9 9 0 1 1-2.6-6.2' }),
h('polyline', { points: '21 3 21 9 15 9' }),
),
),
),
),
!qrOverlay && h('div', { className: 'uds-auth-qr-status' }, qrStatus || t('ui.loading')),
),
renderQr(),
// U01: emergency password always discoverable — disabled state is shown, not hidden.
// Local sealed-box unlock stays hidden unless Host actually has a box configured.
h('button', {
@ -2039,32 +2209,26 @@ function reloadAfterLogin() {
h('div', { className: 'uds-auth-info-detail' }, h('span', { className: 'uds-auth-info-detail-label' }, t('ui.role')), label || user.role || '-'),
user.department && h('div', { className: 'uds-auth-info-detail' }, h('span', { className: 'uds-auth-info-detail-label' }, t('ui.department')), user.department),
),
tokenNotice && h('div', { className: 'uds-auth-qr-status', role: 'status' }, tokenNotice),
tokenExpired && h(React.Fragment, null,
h('div', { className: 'uds-auth-token-warn', role: 'status' },
h('strong', null, t('ui.tokenExpiredTitle')),
t('ui.tokenExpiredHint'),
),
renderQr(),
),
h('button', {
type: 'button', className: 'uds-auth-btn uds-auth-btn-danger',
onClick: async () => {
try { await fetchJson('/uds-auth/api/logout', { method: 'POST' }) } catch { /* ignore */ }
clearAuthCookies()
clearBridge()
try { window.__udsAuthWsAuthedOnce = false } catch { /* ignore */ }
try { window.__udsAuthResetEmp = null } catch { /* ignore */ }
setUser(null)
setOpen(false)
document.documentElement.setAttribute('data-uds-logged-in', '0')
document.documentElement.setAttribute('data-uds-can-settings', '0')
document.documentElement.setAttribute('data-uds-can-create-ws', '0')
clearSessionIfAnonymous(window.__udsAuthSessions)
window.dispatchEvent(new Event('uds-auth-changed'))
try { softReconnectAuth({ force: true }) } catch { /* ignore */ }
},
onClick: () => signOut(),
}, t('ui.logout')),
),
),
h('button', {
type: 'button',
className: 'uds-auth-badge' + (user ? '' : ' uds-auth-badge-unauth'),
className: 'uds-auth-badge' + (user ? '' : ' uds-auth-badge-unauth') + (user && tokenExpired ? ' has-token-warning' : ''),
'aria-expanded': open,
'aria-label': displayName || 'UAC',
title: displayName || 'UAC',
title: user && tokenExpired ? t('ui.tokenExpiredTitle') : (displayName || 'UAC'),
onClick: () => setOpen((v) => !v),
},
h('span', { className: 'uds-auth-badge-label' }, displayName),
@ -2197,7 +2361,16 @@ function reloadAfterLogin() {
rpc.__udsAuthListGate = true
const origCall = rpc.call.bind(rpc)
rpc.call = async function udsAuthRpcCall(channel, endpoint, payload, signal) {
const result = await origCall(channel, endpoint, payload, signal)
let result
try {
result = await origCall(channel, endpoint, payload, signal)
} catch (err) {
if (looksLikeSessionGone(err)) void checkSessionAlive()
throw err
}
if (result && result.ok === false && looksLikeSessionGone(result.error)) {
void checkSessionAlive()
}
if (!isLoggedInInUi()) {
if (channel === '/api') {
if (endpoint === 'session/list') return { ok: true, value: { items: [] } }

View file

@ -23,6 +23,7 @@ const DEFAULT_CONFIG = {
originSystemCode: '',
retainSkillCredentialsOnLogout: true,
skillCredentialTtlSeconds: 7 * 24 * 60 * 60,
sessionIdleTimeoutHours: 0,
outboundAllowedHosts: 'icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn',
initialAdminEmpNo: '',
allowOpenRegistration: true,
@ -44,7 +45,8 @@ const INTERNAL = {
storeType: 'memory',
redisUrl: 'redis://localhost:6379',
cookieName: 'UDS_SESSION',
cookieMaxAge: 1800000,
// Default only; the live idle TTL comes from sessionIdleTimeoutHours (see lib/index.js).
cookieMaxAge: 24 * 60 * 60 * 1000,
},
}

View file

@ -642,6 +642,7 @@ export function installDshAcl(ctx, {
getSessionStore,
ensureUserWorkspace,
getWorkspaceRegistry,
onPrincipalActive,
}) {
const disposers = []
const resolveRolesStore = () => {
@ -747,6 +748,11 @@ export function installDshAcl(ctx, {
throwForbidden('fallback_rotated')
}
}
// Desktop works over the remote.mux WS, which never passes the HTTP auth middleware;
// report activity so the idle TTL slides instead of expiring mid-use.
if (sessionRow && typeof onPrincipalActive === 'function') {
try { onPrincipalActive(sessionRow.sessionId, emp) } catch { /* ignore */ }
}
}
// Stamp owner on session create

View file

@ -30,6 +30,12 @@ export const MESSAGES = {
'ui.deployConfig': '部署配置',
'ui.userSearchUrl': '用户搜索 URL(token 校验)',
'ui.workspaceRoot': '工作区根目录(空=$DSH_HOME/user-workspaces)',
'ui.sessionIdleTimeoutHours': '会话闲置超时(小时,0 = 不自动退出)',
'ui.tokenExpiredTitle': 'UAC 登录已过期',
'ui.tokenExpiredHint': '请扫码刷新,不影响当前会话和定时任务',
'ui.tokenRefreshed': 'UAC 登录已刷新',
'ui.sessionExpiredSignedOut': '登录已过期,已自动退出',
'ui.sessionIdleTimeoutHint': '默认 0:只有手动退出才会退出,定时任务和进行中的会话不受影响。大于 0 时,闲置超过该时长自动退出(范围 0.5–168)。UAC token 过期不会退出,只会提示扫码刷新。',
'ui.allowRawAgentToken': '允许 Skill 获取原始 UDS token',
'ui.allowRawAgentTokenHint': '开启后,具备当前任务 credentials scope 的本机 Skill 可获取工号和 token。只在业务确实需要原始 token 时开启。',
'ui.saveConfig': '保存配置',
@ -166,6 +172,8 @@ export const MESSAGES = {
'err.invalid_user_search_url': 'userSearchUrl 必须是 https URL',
'err.invalid_workspace_root': 'workspaceRoot 必须是绝对路径',
'err.invalid_ttl': 'TTL 超出允许范围',
'err.invalid_session_idle_timeout': '会话闲置超时需为 0(不自动退出)或 0.5–168 小时',
'err.token_refresh_emp_mismatch': '请用当前登录账号 {empNo} 扫码刷新(本次扫码账号:{scanned})',
'err.invalid_outbound_host': 'outbound 白名单 host 格式无效',
'err.bootstrap_required': '系统尚未初始化管理员(当前扫码工号 {empNo})。请设置环境变量 UDS_AUTH_INITIAL_ADMIN={empNo} 后重启 Harness;或先用「应急账号登录」进入,在设置中添加用户',
'err.registration_closed': '未开放自动注册,请联系管理员',
@ -228,6 +236,12 @@ export const MESSAGES = {
'ui.deployConfig': 'Deploy config',
'ui.userSearchUrl': 'User search URL (token verify)',
'ui.workspaceRoot': 'Workspace root (empty=$DSH_HOME/user-workspaces)',
'ui.sessionIdleTimeoutHours': 'Session idle timeout (hours, 0 = never sign out)',
'ui.tokenExpiredTitle': 'UAC sign-in expired',
'ui.tokenExpiredHint': 'Scan to refresh it — your sessions and scheduled tasks keep running',
'ui.tokenRefreshed': 'UAC sign-in refreshed',
'ui.sessionExpiredSignedOut': 'Session expired — you have been signed out',
'ui.sessionIdleTimeoutHint': 'Default 0: only a manual sign-out ends the session; scheduled tasks and running conversations are unaffected. Above 0, sessions idle that long sign out (range 0.5–168). An expired UAC token never signs out — it only prompts a QR refresh.',
'ui.allowRawAgentToken': 'Allow Skills to read the raw UDS token',
'ui.allowRawAgentTokenHint': 'Enables local Skills with the current task credentials scope to read the employee number and token. Turn this on only when a workflow needs the raw token.',
'ui.saveConfig': 'Save config',
@ -359,6 +373,8 @@ export const MESSAGES = {
'err.invalid_user_search_url': 'userSearchUrl must be an https URL',
'err.invalid_workspace_root': 'workspaceRoot must be an absolute path',
'err.invalid_ttl': 'TTL out of allowed range',
'err.invalid_session_idle_timeout': 'Session idle timeout must be 0 (never sign out) or 0.5–168 hours',
'err.token_refresh_emp_mismatch': 'Scan with the signed-in account {empNo} to refresh (scanned: {scanned})',
'err.invalid_outbound_host': 'Invalid outbound host entry',
'err.bootstrap_required': 'Admin not initialized (scanned employee no. {empNo}). Set UDS_AUTH_INITIAL_ADMIN={empNo} and restart Harness, or sign in with the emergency account and add users in Settings',
'err.registration_closed': 'Open registration is disabled; contact an admin',

View file

@ -101,6 +101,8 @@ const CONFIG_DEFAULTS = {
retainSkillCredentialsOnLogout: true,
/** skill 凭证 TTL(秒),默认 7 天 */
skillCredentialTtlSeconds: 7 * 24 * 60 * 60,
/** 登录会话闲置超时(小时);0 = 不自动退出(默认,只能手动退出)。>0 时活跃自动续期、最长 7 天 */
sessionIdleTimeoutHours: 0,
/** outbound 白名单 host(逗号分隔或数组) */
outboundAllowedHosts: 'icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn',
/** SEC-05: raw /agent-credentials token response off by default */
@ -122,12 +124,28 @@ const INTERNAL = {
session: {
storeType: 'memory', // 单实例足够;多实例需共享 store,不可静默切 memory
redisUrl: 'redis://localhost:6379',
cookieMaxAge: 1800000, // 30min idle TTL
/** Idle TTL (ms), read live from sessionIdleTimeoutHours so settings apply without restart. */
get cookieMaxAge() { return sessionIdleTimeoutMs(_currentConfig?.sessionIdleTimeoutHours) },
slidingExpiration: true,
slidingInterval: 300000,
},
}
const SESSION_IDLE_MIN_HOURS = 0.5
const SESSION_IDLE_MAX_HOURS = 7 * 24
/** sessionIdleTimeoutHours = 0: sessions never expire on their own (10 years, still sliding). */
const SESSION_NO_EXPIRY_MS = 10 * 365 * 24 * 60 * 60 * 1000
function isValidSessionIdleHours(n) {
return Number.isFinite(n) && (n === 0 || (n >= SESSION_IDLE_MIN_HOURS && n <= SESSION_IDLE_MAX_HOURS))
}
function sessionIdleTimeoutMs(hours) {
const n = hours === '' || hours == null ? NaN : Number(hours)
const h = isValidSessionIdleHours(n) ? n : CONFIG_DEFAULTS.sessionIdleTimeoutHours
return h === 0 ? SESSION_NO_EXPIRY_MS : Math.round(h * 60 * 60 * 1000)
}
// 懒加载 schemastery Config schema(只在需要注册 DSH 设置面板时用)
// 用 createRequire 因为 schemastery 由 DSH host 运行时提供,不在我们的 package 里
let _ConfigSchema = null
@ -144,6 +162,7 @@ function loadConfigSchemaAndSettings(ctx) {
workspaceRoot: z.string().default(CONFIG_DEFAULTS.workspaceRoot),
retainSkillCredentialsOnLogout: z.boolean().default(CONFIG_DEFAULTS.retainSkillCredentialsOnLogout),
skillCredentialTtlSeconds: z.number().default(CONFIG_DEFAULTS.skillCredentialTtlSeconds),
sessionIdleTimeoutHours: z.number().default(CONFIG_DEFAULTS.sessionIdleTimeoutHours),
outboundAllowedHosts: z.string().default(CONFIG_DEFAULTS.outboundAllowedHosts),
initialAdminEmpNo: z.string().default(CONFIG_DEFAULTS.initialAdminEmpNo),
allowOpenRegistration: z.boolean().default(CONFIG_DEFAULTS.allowOpenRegistration),
@ -197,6 +216,8 @@ let _agentAuthHandlers = null
let _taskCapabilities = null
/** @type {SessionBridgeStore | null} */
let _sessionBridge = null
/** @type {import('./uds/session-revalidation.js').UdsTokenMonitor | null} */
let _tokenMonitor = null
/** SEC-20: false until init completes; routes return 503 when unready. */
let _pluginReady = false
let _pluginReadyError = null
@ -222,6 +243,7 @@ function mintBridge(empNo, kind = 'fallback', ssoToken, bearer) {
kind,
ssoToken,
token: bearer || undefined,
ttlMs: INTERNAL.session.cookieMaxAge,
})
return {
empNo: minted.empNo,
@ -235,6 +257,24 @@ function mintBridge(empNo, kind = 'fallback', ssoToken, bearer) {
}
}
/** sessionId → last slide time; throttles WS-driven idle-TTL sliding. */
const _lastSlideAt = new Map()
/** Slide session + Desktop bridge idle TTL on WS/RPC activity (no HTTP request involved). */
function slideActiveSession(sessionId, empNo) {
const now = Date.now()
if (now - (_lastSlideAt.get(sessionId) || 0) < INTERNAL.session.slidingInterval) return
_lastSlideAt.set(sessionId, now)
if (_lastSlideAt.size > 5000) {
for (const [sid, at] of _lastSlideAt) {
if (now - at >= INTERNAL.session.slidingInterval) _lastSlideAt.delete(sid)
}
}
const ttl = Math.floor(INTERNAL.session.cookieMaxAge / 1000)
Promise.resolve(_sessionStore?.touch(sessionId, ttl)).catch(() => {})
try { _sessionBridge?.touchEmpNo(empNo, INTERNAL.session.cookieMaxAge) } catch { /* ignore */ }
}
/** Create local session + bridge sharing the same bearer. */
async function mintLoginSession(empNo, userContext, kind = 'uds', ssoToken) {
const live = () => !_rolesStore?.isDisabled(empNo)
@ -366,6 +406,8 @@ async function handleQRProxy(req, res) {
try {
let parsedBody = {}
try { parsedBody = JSON.parse(body) } catch { parsedBody = {} }
// Token refresh for a signed-in user: kept local, never forwarded upstream.
const refreshToken = parsedBody.refresh === true
// A04: peek only — do not consume until upstream confirms login.
const peeked = peekQrChallenge(parsedBody.qrCodeKey, parsedBody.qrCodeValue, req.headers['x-uds-qr-binding'])
if (!peeked.ok) {
@ -455,6 +497,18 @@ async function handleQRProxy(req, res) {
return sendErr(res, req, status, code, { host: hostOfUrl(_currentConfig.userSearchUrl), ...vars })
}
const id = profile.empNo || credentials.empNo
if (refreshToken) {
// Refresh in place: no new session, no reload — conversations and cron continue.
const caller = await peekCallerSession(req)
if (!caller) return sendErr(res, req, 401, 'not_logged_in')
if (String(caller.empNo) !== String(id)) {
return sendErr(res, req, 409, 'token_refresh_emp_mismatch', { empNo: caller.empNo, scanned: id })
}
const refreshed = await refreshSessionToken(id, credentials.token, profile)
_logger?.info?.('[uds-auth] UAC token refreshed for %s (%d session(s))', id, refreshed)
return sendJSON(res, 200, { code: { code: '0000' }, bo: { code: '0000' },
auth: { success: true, refreshed: true, empNo: id } })
}
const role = await _authMiddleware.resolveRoleOnLogin(id)
const now = new Date().toISOString()
const userContext = { empNo: id, userId: id, username: profile.username || id,
@ -549,6 +603,58 @@ async function handleQRCode(req, res) {
}
}
/** The caller's live local session, without sliding its TTL. */
async function peekCallerSession(req) {
try {
const extracted = await extractSessionBearerAsync(req, {
sessionBridge: _sessionBridge,
isLiveBearer: async (b) => !!(await _sessionStore?.getByBearer(b)),
})
if (!extracted?.bearer) return null
const session = await _sessionStore.getByBearer(extracted.bearer)
if (!session) return null
if (extracted.bridgeEmpNo && String(extracted.bridgeEmpNo) !== String(session.empNo)) return null
if (_rolesStore?.isDisabled?.(session.empNo)) return null
return session
} catch {
return null
}
}
/**
* Write a freshly verified UAC token into every live QR session of the account.
* Sessions, bridges and running conversations stay as they are.
*/
async function refreshSessionToken(empNo, token, profile) {
const ids = (await _sessionStore.listUdsSessionsForValidation())
.filter((s) => String(s.empNo) === String(empNo))
.map((s) => s.sessionId)
const now = new Date().toISOString()
const patch = {
token,
tokenRefreshedAt: now,
...(profile?.department ? { department: profile.department } : {}),
...(profile?.organization ? { organization: profile.organization } : {}),
}
for (const id of ids) await _sessionStore.updateUserData(id, patch)
try { _skillCredentials?.set(empNo, token) } catch { /* cache is best-effort */ }
_tokenMonitor?.markRefreshed(ids)
return ids.length
}
/** { alive, empNo, tokenValid } for the caller's session without touching it (see route comment). */
async function handleSessionStatus(req, res) {
const session = await peekCallerSession(req)
res.setHeader('Cache-Control', 'no-store')
return sendJSON(res, 200, {
ok: true,
alive: !!session,
empNo: session?.empNo || null,
// false → UAC token rejected; client prompts a QR scan that refreshes it in place.
tokenValid: session ? !_tokenMonitor?.isTokenRejected(session.sessionId) : null,
})
}
// 客户端 config.get —— RPC 在 web 端以 HTTP 方式请求 /uds-auth/config.get
async function handleConfigGet(req, res) {
const c = _currentConfig || {}
@ -567,6 +673,7 @@ async function handleConfigGet(req, res) {
workspaceRoot: c.workspaceRoot,
retainSkillCredentialsOnLogout: c.retainSkillCredentialsOnLogout !== false,
skillCredentialTtlSeconds: c.skillCredentialTtlSeconds,
sessionIdleTimeoutHours: c.sessionIdleTimeoutHours ?? CONFIG_DEFAULTS.sessionIdleTimeoutHours,
outboundAllowedHosts: c.outboundAllowedHosts,
allowRawAgentToken: c.allowRawAgentToken === true,
},
@ -1038,6 +1145,7 @@ function validateConfigPatch(partial) {
'workspaceRoot',
'retainSkillCredentialsOnLogout',
'skillCredentialTtlSeconds',
'sessionIdleTimeoutHours',
'outboundAllowedHosts',
'initialAdminEmpNo',
'allowOpenRegistration',
@ -1067,6 +1175,13 @@ function validateConfigPatch(partial) {
throw err('invalid_ttl')
}
}
if (partial.sessionIdleTimeoutHours !== undefined) {
const raw = partial.sessionIdleTimeoutHours
const n = raw === '' || raw == null ? NaN : Number(raw)
if (!isValidSessionIdleHours(n)) {
throw err('invalid_session_idle_timeout')
}
}
if (partial.outboundAllowedHosts !== undefined) {
const raw = partial.outboundAllowedHosts
const list = Array.isArray(raw)
@ -1093,6 +1208,7 @@ async function saveRuntimeConfig(partial) {
'workspaceRoot',
'retainSkillCredentialsOnLogout',
'skillCredentialTtlSeconds',
'sessionIdleTimeoutHours',
'outboundAllowedHosts',
'initialAdminEmpNo',
'allowOpenRegistration',
@ -1104,7 +1220,7 @@ async function saveRuntimeConfig(partial) {
if (partial[k] !== undefined) {
if (k === 'retainSkillCredentialsOnLogout' || k === 'allowOpenRegistration' || k === 'allowRawAgentToken') {
next[k] = partial[k] === true || partial[k] === 'true'
} else if (k === 'skillCredentialTtlSeconds') {
} else if (k === 'skillCredentialTtlSeconds' || k === 'sessionIdleTimeoutHours') {
next[k] = Number(partial[k])
} else if (k === 'outboundAllowedHosts') {
// Preserve [] / '' as deny-all; do not String([]) → "[]".
@ -1223,6 +1339,12 @@ async function handleAllRoutes(req, res) {
return await handleConfigGet(req, res)
}
// Read-only liveness probe for the client idle watcher. Deliberately bypasses the auth
// middleware: polling must not slide the idle TTL, or an idle tab would never expire.
if (pathname === '/uds-auth/session-status' && method === 'GET') {
return await handleSessionStatus(req, res)
}
// 下面都是 /uds-auth/api/* — 走 handleRequest(含鉴权)
if (pathname.startsWith('/uds-auth/api/')) {
return await handleRequest(req, res)
@ -1553,6 +1675,7 @@ export async function apply(ctx, config = {}) {
userSearchUrl: next.userSearchUrl,
outboundAllowedHosts: next.outboundAllowedHosts,
skillCredentialTtlSeconds: next.skillCredentialTtlSeconds,
sessionIdleTimeoutHours: next.sessionIdleTimeoutHours,
workspaceRoot: next.workspaceRoot || undefined,
trustedHosts: next.trustedHosts,
})
@ -1571,6 +1694,7 @@ export async function apply(ctx, config = {}) {
userSearchUrl: next.userSearchUrl,
outboundAllowedHosts: next.outboundAllowedHosts,
skillCredentialTtlSeconds: next.skillCredentialTtlSeconds,
sessionIdleTimeoutHours: next.sessionIdleTimeoutHours,
workspaceRoot: next.workspaceRoot || undefined,
trustedHosts: next.trustedHosts,
})
@ -1647,6 +1771,7 @@ async function initServices(ctx, config) {
userSearchUrl: _currentConfig.userSearchUrl,
outboundAllowedHosts: _currentConfig.outboundAllowedHosts,
skillCredentialTtlSeconds: _currentConfig.skillCredentialTtlSeconds,
sessionIdleTimeoutHours: _currentConfig.sessionIdleTimeoutHours,
workspaceRoot: _currentConfig.workspaceRoot || undefined,
trustedHosts: _currentConfig.trustedHosts,
})
@ -1665,6 +1790,9 @@ async function initServices(ctx, config) {
if (!_currentConfig.skillCredentialTtlSeconds) {
_currentConfig.skillCredentialTtlSeconds = CONFIG_DEFAULTS.skillCredentialTtlSeconds
}
if (_currentConfig.sessionIdleTimeoutHours == null || _currentConfig.sessionIdleTimeoutHours === '') {
_currentConfig.sessionIdleTimeoutHours = CONFIG_DEFAULTS.sessionIdleTimeoutHours
}
// R05: only default when field absent — empty string / [] means deny-all.
if (_currentConfig.outboundAllowedHosts === undefined || _currentConfig.outboundAllowedHosts === null) {
_currentConfig.outboundAllowedHosts = CONFIG_DEFAULTS.outboundAllowedHosts
@ -1714,12 +1842,6 @@ async function initServices(ctx, config) {
const rememberSkillCreds = (empNo, token) => {
try { _skillCredentials?.set(empNo, token) } catch { /* ignore */ }
}
const revokeInvalidUdsAccount = async (empNo) => {
await _sessionStore.revokeEmpNo(empNo)
try { _sessionBridge?.revokeEmpNo?.(empNo) } catch { /* ignore */ }
try { _skillCredentials?.delete(empNo) } catch { /* ignore */ }
try { _taskCapabilities?.revokeEmpNo(empNo) } catch { /* ignore */ }
}
_authMiddleware = createAuthMiddleware({
get userSearchUrl() { return _currentConfig?.userSearchUrl },
@ -1738,31 +1860,32 @@ async function initServices(ctx, config) {
sessionBridge: _sessionBridge,
})
// Revalidate QR-authenticated sessions against the UDS department profile.
// The session store still enforces the independent seven-day absolute limit.
const udsSessionCheckTimer = setInterval(async () => {
let sessions
try {
sessions = await _sessionStore.listUdsSessionsForValidation()
} catch (err) {
ctx.logger?.warn?.('[uds-auth] failed to enumerate UDS sessions for validation:', err?.message || err)
return
// The UAC token is what actually expires. Check it every 2 hours; a rejected token is
// only flagged (client prompts a QR refresh) — never a sign-out, so conversations and
// night-time cron keep running. See uds/session-revalidation.js.
const {
UdsTokenMonitor,
UDS_TOKEN_CHECK_INTERVAL_MS,
UDS_TOKEN_FIRST_CHECK_MS,
} = await import('./uds/session-revalidation.js')
_tokenMonitor = new UdsTokenMonitor({
sessionStore: _sessionStore,
verify: (empNo, token, opts) => _authMiddleware.verifyEmpNoAndToken(empNo, token, opts),
logger: ctx.logger,
})
const runTokenCheck = async () => {
try { await _tokenMonitor.runRound() } catch (err) {
ctx.logger?.warn?.('[uds-auth] UAC token check failed: %s', err?.message || err)
}
for (const session of sessions) {
if (!await _sessionStore.getBySessionId(session.sessionId)) continue
try {
if (!session.token || !await _authMiddleware.verifyEmpNoAndToken(session.empNo, session.token)) {
await revokeInvalidUdsAccount(session.empNo)
ctx.logger?.info?.('[uds-auth] UDS sessions revoked after profile validation failed for %s', session.empNo)
}
} catch (err) {
await revokeInvalidUdsAccount(session.empNo)
ctx.logger?.info?.('[uds-auth] UDS sessions revoked after profile validation error for %s: %s', session.empNo, err?.message || err)
}
}
}, 6 * 60 * 60 * 1000)
udsSessionCheckTimer.unref?.()
ctx.effect?.(() => () => clearInterval(udsSessionCheckTimer), 'uds-auth: UDS session validation')
}
const udsTokenFirstCheck = setTimeout(runTokenCheck, UDS_TOKEN_FIRST_CHECK_MS)
const udsTokenCheckTimer = setInterval(runTokenCheck, UDS_TOKEN_CHECK_INTERVAL_MS)
udsTokenFirstCheck.unref?.()
udsTokenCheckTimer.unref?.()
ctx.effect?.(() => () => {
clearTimeout(udsTokenFirstCheck)
clearInterval(udsTokenCheckTimer)
}, 'uds-auth: UAC token check')
_apiHandlers = createApiHandlers(
{ session: INTERNAL.session },
@ -1876,6 +1999,7 @@ async function initServices(ctx, config) {
getSessionStore: () => _sessionStore,
ensureUserWorkspace,
getWorkspaceRegistry: () => _workspaceRegistry,
onPrincipalActive: slideActiveSession,
})
const { resolveWorkspaceRoot } = await import('./workspace-provision.js')

View file

@ -32,8 +32,10 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
authMode: config.udsAuth?.authMode || 'token+profile',
}
const udsValidator = new UdsValidator(validatorConfig)
const { cookieMaxAge, slidingExpiration } = config.session
const ttlSeconds = Math.floor(cookieMaxAge / 1000)
const { slidingExpiration } = config.session
// Read per request: cookieMaxAge follows the live sessionIdleTimeoutHours setting.
const ttlSeconds = () => Math.floor(config.session.cookieMaxAge / 1000)
const slidingIntervalMs = Math.max(0, Number(config.session.slidingInterval) || 0)
const userSearchUrl = config.userSearchUrl || config.udsAuth?.userSearchUrl
const uacBaseUrl = config.udsAuth?.baseUrl || ''
/** R05: read live — do not freeze allowOpenRegistration at middleware create time. */
@ -196,7 +198,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
}
const userContext = { ...session.userData, empNo, _sessionId: session.sessionId }
if (persistTouch || slidingExpiration) {
await sessionStore.touch(session.sessionId, ttlSeconds)
await sessionStore.touch(session.sessionId, ttlSeconds())
userContext.lastActiveAt = new Date().toISOString()
}
const role = kind === 'fallback' || kind === 'sealed_box' || rolesStore.hasRole(empNo)
@ -213,17 +215,19 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
ctx.authVia = 'session'
ctx.authKind = kind
if (res && req && session._bearerForCookie) {
const cookie = buildSessionCookie(session._bearerForCookie, ttlSeconds, {
secure: isHttpsRequest(req),
})
const prev = res.getHeader?.('Set-Cookie')
const list = Array.isArray(prev) ? [...prev, cookie] : (prev ? [prev, cookie] : [cookie])
res.setHeader?.('Set-Cookie', list)
appendSessionCookie(req, res, session._bearerForCookie)
}
}
function appendSessionCookie(req, res, bearer) {
const cookie = buildSessionCookie(bearer, ttlSeconds(), { secure: isHttpsRequest(req) })
const prev = res.getHeader?.('Set-Cookie')
const list = Array.isArray(prev) ? [...prev, cookie] : (prev ? [prev, cookie] : [cookie])
res.setHeader?.('Set-Cookie', list)
}
async function mintSession(ctx, userContext, kind, { res, req } = {}) {
const { sessionId, bearer, record } = await sessionStore.create(userContext, ttlSeconds)
const { sessionId, bearer, record } = await sessionStore.create(userContext, ttlSeconds())
if (userContext.token) {
try { onSkillCredentials?.(userContext.empNo, userContext.token) } catch { /* ignore */ }
}
@ -262,7 +266,15 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
throw err
}
// Keep the Desktop bridge alive as long as its session (same bearer).
try { sessionBridge?.touch?.(extracted.bearer, ttlSeconds * 1000) } catch { /* ignore */ }
try { sessionBridge?.touch?.(extracted.bearer, ttlSeconds() * 1000) } catch { /* ignore */ }
// Slide the browser cookie with the server idle TTL. Without this UDS_SESSION kept
// the Max-Age from login and vanished 30 min later even for active users.
// Throttled by slidingInterval so not every request carries Set-Cookie.
if (slidingExpiration && extracted.via === 'cookie' && res && req
&& !res.headersSent
&& Date.now() + ttlSeconds() * 1000 - Number(session.expiresAt || 0) >= slidingIntervalMs) {
try { appendSessionCookie(req, res, extracted.bearer) } catch { /* ignore */ }
}
return next()
}
// Explicit bearer presented but invalid — do not mix with portal cookies.

View file

@ -157,6 +157,21 @@ export class SessionBridgeStore {
return true
}
/**
* Slide every live bridge of an empNo. Used when activity arrives over the WS mux,
* where only the sessionId (not the bearer) is known. Safe: a bridge only carries
* the bearer — the session store still decides whether it authenticates.
* @param {string} empNo
* @param {number} [ttlMs]
*/
touchEmpNo(empNo, ttlMs) {
const e = String(empNo || '').trim()
if (!e) return
for (const [token, row] of this._byToken) {
if (row.empNo === e) this.touch(token, ttlMs)
}
}
/** Clear all bridge tokens for an empNo (logout). */
revokeEmpNo(empNo) {
const e = String(empNo || '').trim()

View file

@ -1,6 +1,6 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { SessionStore, SESSION_ABSOLUTE_MAX_SECONDS } from './store.js'
import { SessionStore, absoluteCapSeconds } from './store.js'
import { atomicWriteJson } from '../utils/atomic-write.js'
import { loadOrCreateSecretKey, sealSecret, openSecret } from '../utils/secret-box.js'
@ -26,6 +26,9 @@ function safeEqualHex(a, b) {
* Persistence (opts.file + opts.dataDir): bearer hashes and expiry in clear,
* userData (may carry the UAC token) sealed with the plugin AES-GCM key.
*/
/** Longest delay setTimeout honors (2^31 - 1 ms). */
const MAX_TIMER_MS = 2_147_483_647
export class MemoryStore extends SessionStore {
/**
* @param {{ file?: string, dataDir?: string, logger?: any }} [opts]
@ -178,10 +181,16 @@ export class MemoryStore extends SessionStore {
this._timers.delete(sessionId)
}
if (!ttlSeconds || ttlSeconds <= 0) return
// setTimeout fires immediately past ~24.8 days; long TTLs re-arm in chunks.
const delay = Math.min(ttlSeconds * 1000, MAX_TIMER_MS)
const timer = setTimeout(() => {
this._timers.delete(sessionId)
const row = this._sessions.get(sessionId)
if (row) this._removeRecord(sessionId, row)
}, ttlSeconds * 1000)
if (!row) return
const left = row.expiresAt - Date.now()
if (left > 0) this._scheduleExpiry(sessionId, Math.ceil(left / 1000))
else this._removeRecord(sessionId, row)
}, delay)
// Do not keep the process alive solely for session TTLs (tests / short-lived hosts).
if (typeof timer.unref === 'function') timer.unref()
this._timers.set(sessionId, timer)
@ -210,7 +219,7 @@ export class MemoryStore extends SessionStore {
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
// Idle TTL slides via touch(); absolute lifetime is a fixed 7 days. (Was min(ttl, 7d),
// which capped every session at its 30-min idle TTL and made sliding a no-op.)
const absoluteCap = SESSION_ABSOLUTE_MAX_SECONDS
const absoluteCap = absoluteCapSeconds(ttl)
const record = {
sessionId,
bearerHash,
@ -305,6 +314,10 @@ export class MemoryStore extends SessionStore {
const row = this._purgeExpired(String(sessionId || ''))
if (!row) return null
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
// "No auto sign-out" TTLs also lift the cap of sessions minted under the 7-day default.
if (absoluteCapSeconds(ttl) > absoluteCapSeconds(0)) {
row.absoluteExpiresAt = Math.max(row.absoluteExpiresAt || 0, Date.now() + ttl * 1000)
}
const absLeft = row.absoluteExpiresAt
? Math.max(0, Math.floor((row.absoluteExpiresAt - Date.now()) / 1000))
: ttl
@ -323,6 +336,14 @@ export class MemoryStore extends SessionStore {
return this._publicRecord(row)
}
async updateUserData(sessionId, patch) {
const row = this._purgeExpired(String(sessionId || ''))
if (!row) return null
row.userData = { ...row.userData, ...patch }
this._scheduleSave()
return this._publicRecord(row)
}
async revokeSession(sessionId) {
const row = this._sessions.get(String(sessionId || ''))
if (!row) return false

View file

@ -1,5 +1,5 @@
import { createHash, randomBytes } from 'node:crypto'
import { SessionStore, SESSION_ABSOLUTE_MAX_SECONDS } from './store.js'
import { SessionStore, absoluteCapSeconds } from './store.js'
function hashBearer(bearer) {
return createHash('sha256').update(String(bearer)).digest('hex')
@ -48,7 +48,7 @@ export class RedisStore extends SessionStore {
userData: { ...userData },
createdAt: now,
expiresAt: now + ttl * 1000,
absoluteExpiresAt: now + SESSION_ABSOLUTE_MAX_SECONDS * 1000,
absoluteExpiresAt: now + absoluteCapSeconds(ttl) * 1000,
}
const payload = JSON.stringify(record)
await this._client.setEx(this._sessKey(sessionId), ttl, payload)
@ -120,6 +120,9 @@ export class RedisStore extends SessionStore {
const row = await this._load(sessionId)
if (!row) return null
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
if (absoluteCapSeconds(ttl) > absoluteCapSeconds(0)) {
row.absoluteExpiresAt = Math.max(row.absoluteExpiresAt || 0, Date.now() + ttl * 1000)
}
const absLeft = row.absoluteExpiresAt
? Math.max(0, Math.floor((row.absoluteExpiresAt - Date.now()) / 1000))
: ttl
@ -135,6 +138,16 @@ export class RedisStore extends SessionStore {
return this._public(row)
}
async updateUserData(sessionId, patch) {
const row = await this._load(sessionId)
if (!row) return null
const left = Math.floor((Number(row.expiresAt) - Date.now()) / 1000)
if (left <= 0) return null
row.userData = { ...row.userData, ...patch }
await this._client.setEx(this._sessKey(sessionId), left, JSON.stringify(row))
return this._public(row)
}
async revokeSession(sessionId) {
const row = await this._load(sessionId)
if (!row) {

View file

@ -36,6 +36,11 @@ export class SessionStore {
throw new Error('SessionStore#touch must be implemented')
}
/** Merge fields into a live session's userData (e.g. a refreshed UAC token). */
async updateUserData(sessionId, patch) {
throw new Error('SessionStore#updateUserData must be implemented')
}
async revokeSession(sessionId) {
throw new Error('SessionStore#revokeSession must be implemented')
}
@ -92,3 +97,11 @@ export const SESSION_COOKIE = 'UDS_SESSION'
export const SESSION_HEADER = 'x-uds-session'
/** Absolute session lifetime; idle expiry (cookieMaxAge) slides within it. */
export const SESSION_ABSOLUTE_MAX_SECONDS = 7 * 24 * 60 * 60
/**
* Absolute lifetime for a session with this idle TTL. A TTL above 7 days means
* "no automatic sign-out" (sessionIdleTimeoutHours = 0), so the cap follows the TTL.
*/
export function absoluteCapSeconds(ttlSeconds) {
return Math.max(SESSION_ABSOLUTE_MAX_SECONDS, Number(ttlSeconds) || 0)
}

View file

@ -0,0 +1,102 @@
/**
* Periodic UAC token check for QR-authenticated sessions.
*
* The UAC token is what actually expires: it must still read the user's department
* profile from icenterapi for UAC-backed work (skills, cron) to succeed. A rejected
* token never signs anyone out — conversations and night-time cron keep their local
* session. The session is only flagged; the client then shows a QR prompt and a scan
* refreshes the token in place (see handleQrProxy → refreshSessionToken).
*
* Network errors, 5xx and malformed responses say nothing about the token and leave
* the previous verdict unchanged.
*/
/** 2 hours between rounds. */
export const UDS_TOKEN_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000
/** First round shortly after start so flags lost on restart come back quickly. */
export const UDS_TOKEN_FIRST_CHECK_MS = 60 * 1000
/** searchUserByEmpNoToken / verifyEmpNoAndToken failure reasons that reject the token. */
export const DEFINITIVE_PROFILE_FAILURES = new Set([
'http_auth', // 401 / 403
'biz_code', // icenterapi business error
'empty', // token cannot see this employee
'emp_mismatch',
'ambiguous',
'reserved_subject',
'missing_args', // session carries no token
])
export class UdsTokenMonitor {
/**
* @param {{
* sessionStore: { listUdsSessionsForValidation(): Promise<any[]> },
* verify: (empNo: string, token: string, opts: { onFailure?: (out: any) => void }) => Promise<object|null>,
* logger?: { info?: Function, warn?: Function },
* }} deps
*/
constructor(deps) {
this.deps = deps
/** sessionId → { empNo, reason, since } for sessions whose UAC token was rejected. */
this.rejected = new Map()
}
async _classify(empNo, token) {
if (!token) return { verdict: 'invalid', reason: 'missing_args' }
let failure = null
try {
const profile = await this.deps.verify(empNo, token, { onFailure: (out) => { failure = out } })
if (profile) return { verdict: 'ok' }
} catch (err) {
return { verdict: 'unknown', reason: err?.message || 'error' }
}
const reason = String(failure?.reason || 'unknown')
return { verdict: DEFINITIVE_PROFILE_FAILURES.has(reason) ? 'invalid' : 'unknown', reason }
}
/** True when this session's UAC token was rejected and has not been refreshed since. */
isTokenRejected(sessionId) {
return this.rejected.has(String(sessionId || ''))
}
/** A fresh, verified token was written into these sessions. */
markRefreshed(sessionIds) {
for (const id of sessionIds) this.rejected.delete(String(id))
}
/** Verify every live QR session's token (one icenterapi call per empNo+token). */
async runRound() {
const { sessionStore, logger } = this.deps
const sessions = await sessionStore.listUdsSessionsForValidation()
const verdicts = new Map()
const stats = { checked: 0, ok: 0, rejected: 0, inconclusive: 0 }
for (const session of sessions) {
stats.checked++
const key = `${session.empNo}\0${session.token || ''}`
let result = verdicts.get(key)
if (!result) {
result = await this._classify(session.empNo, session.token)
verdicts.set(key, result)
if (result.verdict === 'unknown') {
logger?.warn?.('[uds-auth] UAC token check inconclusive for %s (%s); keeping previous status', session.empNo, result.reason)
} else if (result.verdict === 'invalid') {
logger?.info?.('[uds-auth] UAC token rejected for %s (%s); prompting a QR refresh', session.empNo, result.reason)
}
}
if (result.verdict === 'ok') {
this.rejected.delete(session.sessionId)
stats.ok++
} else if (result.verdict === 'invalid') {
if (!this.rejected.has(session.sessionId)) {
this.rejected.set(session.sessionId, { empNo: session.empNo, reason: result.reason, since: Date.now() })
}
stats.rejected++
} else {
stats.inconclusive++
}
}
const live = new Set(sessions.map((s) => s.sessionId))
for (const id of this.rejected.keys()) if (!live.has(id)) this.rejected.delete(id)
return stats
}
}

View file

@ -1,6 +1,6 @@
{
"name": "uds-auth",
"version": "0.3.19",
"version": "0.3.20",
"description": "UDS authentication plugin for DeepSeek Harness — trusted single-instance access control (not strong multi-tenant isolation)",
"type": "module",
"main": "lib/index.js",

View file

@ -274,3 +274,62 @@ test('bearer hash is not reversible from store maps', async () => {
assert.ok(store._byBearer.has(hash))
assert.equal([...store._byBearer.keys()].includes(bearer), false)
})
test('active cookie session re-issues UDS_SESSION so the browser cookie slides too', async () => {
const store = new MemoryStore()
const roles = mockRoles({ '12345': ROLES.USER })
const mw = createAuthMiddleware({
userSearchUrl: 'http://127.0.0.1:9/never',
udsAuth: { baseUrl: 'http://127.0.0.1:9' },
session: { cookieMaxAge: 1800000, slidingExpiration: true, slidingInterval: 300000 },
}, store, roles, {})
const { sessionId, bearer } = await store.create({ empNo: '12345', isAuthenticated: true }, 1800)
const call = async () => {
const headers = {}
const res = { getHeader: (k) => headers[k], setHeader: (k, v) => { headers[k] = v } }
const ctx = { req: { headers: { cookie: `${SESSION_COOKIE}=${bearer}` }, socket: {} }, res }
await mw(ctx, async () => {})
assert.equal(ctx.empNo, '12345')
return [].concat(headers['Set-Cookie'] || []).find((c) => c.startsWith(`${SESSION_COOKIE}=`))
}
// Fresh session (within slidingInterval of login): no Set-Cookie churn.
assert.equal(await call(), undefined)
// 20 min later the idle window has drifted past slidingInterval → cookie re-issued.
store._sessions.get(sessionId).expiresAt = Date.now() + 10 * 60 * 1000
const cookie = await call()
assert.ok(cookie, 'UDS_SESSION re-issued')
assert.match(cookie, /Max-Age=1800/)
assert.match(cookie, /HttpOnly/)
})
test('idle TTL follows the live session config (sessionIdleTimeoutHours) without restart', async () => {
const store = new MemoryStore()
const roles = mockRoles({ '12345': ROLES.USER })
let hours = 24
const session = {
get cookieMaxAge() { return hours * 60 * 60 * 1000 },
slidingExpiration: true,
slidingInterval: 300000,
}
const mw = createAuthMiddleware({
userSearchUrl: 'http://127.0.0.1:9/never',
udsAuth: { baseUrl: 'http://127.0.0.1:9' },
session,
}, store, roles, {})
const { sessionId, bearer } = await store.create({ empNo: '12345', isAuthenticated: true }, 60)
const call = async () => {
const headers = {}
const res = { getHeader: (k) => headers[k], setHeader: (k, v) => { headers[k] = v } }
await mw({ req: { headers: { cookie: `${SESSION_COOKIE}=${bearer}` }, socket: {} }, res }, async () => {})
return [].concat(headers['Set-Cookie'] || []).find((c) => c.startsWith(`${SESSION_COOKIE}=`))
}
assert.match(await call(), /Max-Age=86400/)
assert.ok(store._sessions.get(sessionId).expiresAt > Date.now() + 23 * 60 * 60_000)
hours = 2
store._sessions.get(sessionId).expiresAt = Date.now() + 60_000
assert.match(await call(), /Max-Age=7200/)
assert.ok(store._sessions.get(sessionId).expiresAt <= Date.now() + 2 * 60 * 60_000)
})

View file

@ -351,3 +351,42 @@ test('anonymous stream endpoints are parked until the carrier aborts (no termina
for await (const f of authed.method()) frames.push(f)
assert.deepEqual(frames, [{ status: 'credential-stored' }])
})
test('WS activity (assertPrincipalLive) reports the live session so its idle TTL slides', async () => {
const stores = makeStores()
const [, kind, ctxOf] = EMERGENCY_LOGINS[0]
const { bearer } = await login(stores, ctxOf(), kind)
const session = await stores.sessionStore.getByBearer(bearer)
const seen = []
const noop = async () => {}
const wc = {
create: noop, rename: noop, delete: noop, insertBefore: noop, insertSessionBefore: noop,
archiveSession: noop, unarchiveSession: noop, pinSession: noop, unpinSession: noop,
async *follow() { yield { type: 'baseline', value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] } } },
}
installDshAcl({
inject(deps, cb) { if (deps.includes('workspaceController')) cb({ workspaceController: wc }) },
on() { return () => {} },
get() { throw new Error('no service') },
}, {
sessionAcl: { getOwner: () => null, setOwner() {} },
userWorkspaces: new UserWorkspaceStore({}),
getWorkspaceRoot: () => '',
getRolesStore: () => stores.roles,
getSessionStore: () => stores.sessionStore,
ensureUserWorkspace: async () => ({ workspaceId: 'ws-admin' }),
getWorkspaceRegistry: () => ({ list: () => [], get: () => null }),
onPrincipalActive: (sid, emp) => seen.push([sid, emp]),
})
await collect(wc, identityFromSessionRecord(session, stores.roles))
assert.deepEqual(seen[0], [session.sessionId, 'administrator'])
})
test('SessionBridgeStore.touchEmpNo slides every live bridge of that account only', () => {
const store = new SessionBridgeStore({ ttlMs: 60_000 })
const a = store.mint({ empNo: 'alice', kind: 'uds' })
const b = store.mint({ empNo: 'bob', kind: 'uds' })
store.touchEmpNo('alice', 10 * 60_000)
assert.ok(store._byToken.get(a.token).exp > Date.now() + 5 * 60_000)
assert.ok(store._byToken.get(b.token).exp <= Date.now() + 60_000)
})

View file

@ -0,0 +1,119 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { MemoryStore } from '../lib/session/memory-store.js'
import { SESSION_ABSOLUTE_MAX_SECONDS } from '../lib/session/store.js'
import {
UdsTokenMonitor,
UDS_TOKEN_CHECK_INTERVAL_MS,
} from '../lib/uds/session-revalidation.js'
async function login(store, empNo, token, ttl = 24 * 3600) {
const { sessionId } = await store.create({ empNo, token, authMode: 'uds-qr', isAuthenticated: true }, ttl)
return sessionId
}
/** verify() stub: token → 'ok' | failure object | Error. Mutable via `outcomes`. */
function verifier(outcomes) {
const calls = []
const verify = async (empNo, token, opts) => {
calls.push(token)
const out = outcomes[token]
if (out instanceof Error) throw out
if (out === 'ok') return { empNo }
opts?.onFailure?.(out)
return null
}
return { verify, calls }
}
test('UAC token is checked every 2 hours', () => {
assert.equal(UDS_TOKEN_CHECK_INTERVAL_MS, 2 * 60 * 60 * 1000)
})
test('a rejected UAC token is only flagged — the session is never signed out', async () => {
const store = new MemoryStore()
const auth = await login(store, 'u-auth', 'tok-auth')
const biz = await login(store, 'u-biz', 'tok-biz')
const good = await login(store, 'u-ok', 'tok-ok')
const { verify } = verifier({
'tok-auth': { ok: false, reason: 'http_auth', statusCode: 401 },
'tok-biz': { ok: false, reason: 'biz_code', code: '1001' },
'tok-ok': 'ok',
})
const m = new UdsTokenMonitor({ sessionStore: store, verify })
assert.deepEqual(await m.runRound(), { checked: 3, ok: 1, rejected: 2, inconclusive: 0 })
for (const id of [auth, biz, good]) assert.ok(await store.getBySessionId(id), 'session kept')
assert.equal(m.isTokenRejected(auth), true)
assert.equal(m.isTokenRejected(biz), true)
assert.equal(m.isTokenRejected(good), false)
})
test('icenterapi outages neither flag nor clear a token', async () => {
const store = new MemoryStore()
const id = await login(store, 'u1', 'tok')
const outcomes = { tok: { ok: false, reason: 'http_auth' } }
const { verify } = verifier(outcomes)
const m = new UdsTokenMonitor({ sessionStore: store, verify })
await m.runRound()
assert.equal(m.isTokenRejected(id), true)
for (const out of [{ ok: false, reason: 'network' }, { ok: false, reason: 'http_status' }, { ok: false, reason: 'bad_json' }, new Error('reset')]) {
outcomes.tok = out
assert.equal((await m.runRound()).inconclusive, 1)
assert.equal(m.isTokenRejected(id), true, 'previous verdict kept')
}
const fresh = await login(store, 'u2', 'tok2')
outcomes.tok2 = { ok: false, reason: 'network' }
await m.runRound()
assert.equal(m.isTokenRejected(fresh), false, 'outage does not flag a healthy session')
})
test('a token that verifies again (or is refreshed) clears the flag', async () => {
const store = new MemoryStore()
const id = await login(store, 'u1', 'tok')
const outcomes = { tok: { ok: false, reason: 'biz_code' } }
const { verify } = verifier(outcomes)
const m = new UdsTokenMonitor({ sessionStore: store, verify })
await m.runRound()
outcomes.tok = 'ok'
await m.runRound()
assert.equal(m.isTokenRejected(id), false)
outcomes.tok = { ok: false, reason: 'http_auth' }
await m.runRound()
m.markRefreshed([id])
assert.equal(m.isTokenRejected(id), false)
})
test('each empNo+token is verified once per round', async () => {
const store = new MemoryStore()
await login(store, 'u2', 'tok-x')
await login(store, 'u2', 'tok-x')
const { verify, calls } = verifier({ 'tok-x': 'ok' })
await new UdsTokenMonitor({ sessionStore: store, verify }).runRound()
assert.deepEqual(calls, ['tok-x'])
})
test('updateUserData writes a refreshed token in place', async () => {
const store = new MemoryStore()
const id = await login(store, 'u1', 'old')
await store.updateUserData(id, { token: 'new', tokenRefreshedAt: 'now' })
const [row] = await store.listUdsSessionsForValidation()
assert.equal(row.sessionId, id)
assert.equal(row.token, 'new')
})
test('"never sign out" TTL: no absolute cap, long timers do not fire early', async () => {
const TEN_YEARS = 10 * 365 * 24 * 3600
const store = new MemoryStore()
const id = await login(store, 'u1', 'tok', TEN_YEARS)
const row = store._sessions.get(id)
assert.ok(row.absoluteExpiresAt > Date.now() + 9 * 365 * 24 * 3600 * 1000, 'cap follows the TTL, not 7 days')
await new Promise((r) => setTimeout(r, 20))
assert.ok(await store.getBySessionId(id), 'setTimeout overflow would have removed it already')
// Sessions minted under the 7-day default get their cap lifted on activity.
const old = await login(store, 'u2', 'tok2', 1800)
assert.ok(store._sessions.get(old).absoluteExpiresAt <= Date.now() + SESSION_ABSOLUTE_MAX_SECONDS * 1000)
await store.touch(old, TEN_YEARS)
assert.ok(store._sessions.get(old).absoluteExpiresAt > Date.now() + 9 * 365 * 24 * 3600 * 1000)
})

View file

@ -67,3 +67,65 @@ test('other origin/host rejections still fail immediately', async () => {
assert.equal(handled, 0)
assert.ok(Date.now() - started < 1000)
})
test('session-status probe is routed outside the auth middleware and never slides the TTL', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../lib/index.js', import.meta.url), 'utf8')
const probe = src.indexOf("pathname === '/uds-auth/session-status'")
const authed = src.indexOf("if (pathname.startsWith('/uds-auth/api/')) {")
assert.ok(probe > 0 && probe < authed, 'probe answers before /uds-auth/api/* auth dispatch')
const body = src.slice(src.indexOf('async function handleSessionStatus'), src.indexOf('// 客户端 config.get'))
assert.doesNotMatch(body, /touch\(|_authMiddleware/)
// getByBearer (what the probe uses) must not slide the idle TTL.
const store = new MemoryStore()
const { sessionId, bearer } = await store.create({ empNo: 'u1' }, 1800)
const before = store._sessions.get(sessionId).expiresAt
await new Promise((r) => setTimeout(r, 5))
assert.ok(await store.getByBearer(bearer))
assert.equal(store._sessions.get(sessionId).expiresAt, before)
})
test('client signs out on confirmed idle expiry instead of prompting on every call', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
// Expiry is only acted on after the non-sliding probe says alive=false.
assert.match(src, /fetch\('\/uds-auth\/session-status'/)
assert.match(src, /data\.alive === false && isLoggedInInUi\(\)\) \{\s*window\.dispatchEvent\(new Event\('uds-auth-expired'\)\)/)
// RPC failures and HTTP 401s trigger the probe; an idle timer and focus do too.
assert.match(src, /looksLikeSessionGone\(result\.error\)\) \{\s*void checkSessionAlive\(\)/)
assert.match(src, /if \(res\.status === 401\) void checkSessionAlive\(\)/)
assert.match(src, /setInterval\(check, 60_000\)/)
// Expired sign-out skips /api/logout and tells the user why.
assert.match(src, /signOut\(\{ expired: true \}\)/)
assert.match(src, /sessionExpired \? t\('ui\.sessionExpiredSignedOut'\)/)
})
test('QR refresh renews the UAC token in place instead of minting a new session', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../lib/index.js', import.meta.url), 'utf8')
// refresh flag is read locally and never forwarded upstream (body is rebuilt from contract fields).
assert.match(src, /const refreshToken = parsedBody\.refresh === true/)
const branch = src.slice(src.indexOf('if (refreshToken) {'), src.indexOf("const role = await _authMiddleware.resolveRoleOnLogin(id)\n const now"))
assert.match(branch, /peekCallerSession\(req\)/)
assert.match(branch, /token_refresh_emp_mismatch/)
assert.match(branch, /refreshSessionToken\(id, credentials\.token, profile\)/)
assert.doesNotMatch(branch, /mintLoginSession|appendSessionCookie/)
// Default: no automatic sign-out; token rejections are flagged, not revoked.
assert.match(src, /sessionIdleTimeoutHours: 0,/)
assert.match(src, /tokenValid: session \? !_tokenMonitor\?\.isTokenRejected\(session\.sessionId\) : null/)
assert.doesNotMatch(src, /revokeInvalidUdsAccount|UdsSessionRevalidator/)
})
test('client prompts a refresh QR on token expiry without signing out', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
assert.match(src, /new Event\(data\.tokenValid \? 'uds-auth-token-ok' : 'uds-auth-token-expired'\)/)
// expiry opens the panel once; the token handler never calls signOut
const handler = src.slice(src.indexOf('const onTokenExpired = () => {'), src.indexOf('const onTokenOk = () => {'))
assert.match(handler, /setOpen\(true\)/)
assert.doesNotMatch(handler, /signOut/)
assert.match(src, /refreshing \? \{ qrCodeKey, qrCodeValue, refresh: true \}/)
assert.match(src, /if \(auth\?\.success && auth\.refreshed\) \{/)
assert.match(src, /const wantQr = open && loginMode === 'qr' && \(!user \|\| tokenExpired\)/)
})