diff --git a/uds-auth/README.md b/uds-auth/README.md index b37f0d2c..3be81dd5 100644 --- a/uds-auth/README.md +++ b/uds-auth/README.md @@ -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. diff --git a/uds-auth/README.zh.md b/uds-auth/README.zh.md index 635a3946..ac9794a0 100644 --- a/uds-auth/README.zh.md +++ b/uds-auth/README.zh.md @@ -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 加密。 - 加载时丢弃已过期、解密失败或被篡改的记录;停用账号、应急口令轮换等检查仍在每次请求时执行。 diff --git a/uds-auth/config.default.yaml b/uds-auth/config.default.yaml index f119935a..3d362d46 100644 --- a/uds-auth/config.default.yaml +++ b/uds-auth/config.default.yaml @@ -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: '' diff --git a/uds-auth/docs/skill-auth-standard.zh.md b/uds-auth/docs/skill-auth-standard.zh.md index 7a201a69..6b00945a 100644 --- a/uds-auth/docs/skill-auth-standard.zh.md +++ b/uds-auth/docs/skill-auth-standard.zh.md @@ -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) ## 验收 diff --git a/uds-auth/lib/client.js b/uds-auth/lib/client.js index a5740c92..bcd04214 100644 --- a/uds-auth/lib/client.js +++ b/uds-auth/lib/client.js @@ -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: [] } } diff --git a/uds-auth/lib/config.js b/uds-auth/lib/config.js index 004ea644..db506962 100644 --- a/uds-auth/lib/config.js +++ b/uds-auth/lib/config.js @@ -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, }, } diff --git a/uds-auth/lib/dsh-acl.js b/uds-auth/lib/dsh-acl.js index c863c07b..9be69daf 100644 --- a/uds-auth/lib/dsh-acl.js +++ b/uds-auth/lib/dsh-acl.js @@ -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 diff --git a/uds-auth/lib/i18n.js b/uds-auth/lib/i18n.js index 5821c4dd..3a5d3585 100644 --- a/uds-auth/lib/i18n.js +++ b/uds-auth/lib/i18n.js @@ -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', diff --git a/uds-auth/lib/index.js b/uds-auth/lib/index.js index ad5c0ad5..c3305622 100644 --- a/uds-auth/lib/index.js +++ b/uds-auth/lib/index.js @@ -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') diff --git a/uds-auth/lib/middleware/auth-middleware.js b/uds-auth/lib/middleware/auth-middleware.js index 284e6553..fdd843bc 100644 --- a/uds-auth/lib/middleware/auth-middleware.js +++ b/uds-auth/lib/middleware/auth-middleware.js @@ -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. diff --git a/uds-auth/lib/session-bridge.js b/uds-auth/lib/session-bridge.js index e0443ce2..e725dcd2 100644 --- a/uds-auth/lib/session-bridge.js +++ b/uds-auth/lib/session-bridge.js @@ -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() diff --git a/uds-auth/lib/session/memory-store.js b/uds-auth/lib/session/memory-store.js index 54c69d7c..28e25e89 100644 --- a/uds-auth/lib/session/memory-store.js +++ b/uds-auth/lib/session/memory-store.js @@ -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 diff --git a/uds-auth/lib/session/redis-store.js b/uds-auth/lib/session/redis-store.js index 79f15f3b..ac559d16 100644 --- a/uds-auth/lib/session/redis-store.js +++ b/uds-auth/lib/session/redis-store.js @@ -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) { diff --git a/uds-auth/lib/session/store.js b/uds-auth/lib/session/store.js index 6fb655ff..0bc23f00 100644 --- a/uds-auth/lib/session/store.js +++ b/uds-auth/lib/session/store.js @@ -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) +} diff --git a/uds-auth/lib/uds/session-revalidation.js b/uds-auth/lib/uds/session-revalidation.js new file mode 100644 index 00000000..8a90627a --- /dev/null +++ b/uds-auth/lib/uds/session-revalidation.js @@ -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 }, + * verify: (empNo: string, token: string, opts: { onFailure?: (out: any) => void }) => Promise, + * 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 + } +} diff --git a/uds-auth/package.json b/uds-auth/package.json index fab94054..1dc1f6e7 100644 --- a/uds-auth/package.json +++ b/uds-auth/package.json @@ -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", diff --git a/uds-auth/test/auth-session-p0.test.js b/uds-auth/test/auth-session-p0.test.js index 106ff79c..e6ccd374 100644 --- a/uds-auth/test/auth-session-p0.test.js +++ b/uds-auth/test/auth-session-p0.test.js @@ -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) +}) diff --git a/uds-auth/test/emergency-workspace-follow.test.js b/uds-auth/test/emergency-workspace-follow.test.js index c54f2842..2dc7661a 100644 --- a/uds-auth/test/emergency-workspace-follow.test.js +++ b/uds-auth/test/emergency-workspace-follow.test.js @@ -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) +}) diff --git a/uds-auth/test/session-revalidation.test.js b/uds-auth/test/session-revalidation.test.js new file mode 100644 index 00000000..380c4244 --- /dev/null +++ b/uds-auth/test/session-revalidation.test.js @@ -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) +}) diff --git a/uds-auth/test/startup-and-session-lifetime.test.js b/uds-auth/test/startup-and-session-lifetime.test.js index e8ad82e1..728fd18f 100644 --- a/uds-auth/test/startup-and-session-lifetime.test.js +++ b/uds-auth/test/startup-and-session-lifetime.test.js @@ -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\)/) +})