oclaw/uds-auth/test/session-revalidation.test.js
oliver 0e6de055a9
Some checks failed
ci / test (push) Has been cancelled
ci / test-postgresql (push) Has been cancelled
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

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