mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 19:05:49 +08:00
fix(uds-auth): keep sessions until manual sign-out; refresh expired UAC tokens by QR (0.3.20)
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:
parent
dfdf298184
commit
0e6de055a9
20 changed files with 904 additions and 119 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 加密。
|
||||
- 加载时丢弃已过期、解密失败或被篡改的记录;停用账号、应急口令轮换等检查仍在每次请求时执行。
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
## 验收
|
||||
|
|
|
|||
|
|
@ -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: [] } }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
102
uds-auth/lib/uds/session-revalidation.js
Normal file
102
uds-auth/lib/uds/session-revalidation.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
119
uds-auth/test/session-revalidation.test.js
Normal file
119
uds-auth/test/session-revalidation.test.js
Normal 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)
|
||||
})
|
||||
|
|
@ -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\)/)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue