mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 20:35:56 +08:00
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>
102 lines
4.1 KiB
JavaScript
102 lines
4.1 KiB
JavaScript
/**
|
|
* 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
|
|
}
|
|
}
|