oclaw/uds-auth/lib/uds/session-revalidation.js
oliver 0e6de055a9
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run
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>
2026-10-13 19:59:48 +08:00

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
}
}