mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-15 07:30:46 +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>
119 lines
4.9 KiB
JavaScript
119 lines
4.9 KiB
JavaScript
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)
|
|
})
|