fix(uds-auth): keep sessions until manual sign-out; refresh expired UAC tokens by QR (0.3.20)
Some checks failed
ci / test (push) Has been cancelled
ci / test-postgresql (push) Has been cancelled

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:
oliver 2026-10-13 19:59:48 +08:00
parent dfdf298184
commit 0e6de055a9
20 changed files with 904 additions and 119 deletions

View file

@ -274,3 +274,62 @@ test('bearer hash is not reversible from store maps', async () => {
assert.ok(store._byBearer.has(hash))
assert.equal([...store._byBearer.keys()].includes(bearer), false)
})
test('active cookie session re-issues UDS_SESSION so the browser cookie slides too', async () => {
const store = new MemoryStore()
const roles = mockRoles({ '12345': ROLES.USER })
const mw = createAuthMiddleware({
userSearchUrl: 'http://127.0.0.1:9/never',
udsAuth: { baseUrl: 'http://127.0.0.1:9' },
session: { cookieMaxAge: 1800000, slidingExpiration: true, slidingInterval: 300000 },
}, store, roles, {})
const { sessionId, bearer } = await store.create({ empNo: '12345', isAuthenticated: true }, 1800)
const call = async () => {
const headers = {}
const res = { getHeader: (k) => headers[k], setHeader: (k, v) => { headers[k] = v } }
const ctx = { req: { headers: { cookie: `${SESSION_COOKIE}=${bearer}` }, socket: {} }, res }
await mw(ctx, async () => {})
assert.equal(ctx.empNo, '12345')
return [].concat(headers['Set-Cookie'] || []).find((c) => c.startsWith(`${SESSION_COOKIE}=`))
}
// Fresh session (within slidingInterval of login): no Set-Cookie churn.
assert.equal(await call(), undefined)
// 20 min later the idle window has drifted past slidingInterval → cookie re-issued.
store._sessions.get(sessionId).expiresAt = Date.now() + 10 * 60 * 1000
const cookie = await call()
assert.ok(cookie, 'UDS_SESSION re-issued')
assert.match(cookie, /Max-Age=1800/)
assert.match(cookie, /HttpOnly/)
})
test('idle TTL follows the live session config (sessionIdleTimeoutHours) without restart', async () => {
const store = new MemoryStore()
const roles = mockRoles({ '12345': ROLES.USER })
let hours = 24
const session = {
get cookieMaxAge() { return hours * 60 * 60 * 1000 },
slidingExpiration: true,
slidingInterval: 300000,
}
const mw = createAuthMiddleware({
userSearchUrl: 'http://127.0.0.1:9/never',
udsAuth: { baseUrl: 'http://127.0.0.1:9' },
session,
}, store, roles, {})
const { sessionId, bearer } = await store.create({ empNo: '12345', isAuthenticated: true }, 60)
const call = async () => {
const headers = {}
const res = { getHeader: (k) => headers[k], setHeader: (k, v) => { headers[k] = v } }
await mw({ req: { headers: { cookie: `${SESSION_COOKIE}=${bearer}` }, socket: {} }, res }, async () => {})
return [].concat(headers['Set-Cookie'] || []).find((c) => c.startsWith(`${SESSION_COOKIE}=`))
}
assert.match(await call(), /Max-Age=86400/)
assert.ok(store._sessions.get(sessionId).expiresAt > Date.now() + 23 * 60 * 60_000)
hours = 2
store._sessions.get(sessionId).expiresAt = Date.now() + 60_000
assert.match(await call(), /Max-Age=7200/)
assert.ok(store._sessions.get(sessionId).expiresAt <= Date.now() + 2 * 60 * 60_000)
})