mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-17 12:03:20 +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
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