mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-16 07:50:45 +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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue