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

@ -32,8 +32,10 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
authMode: config.udsAuth?.authMode || 'token+profile',
}
const udsValidator = new UdsValidator(validatorConfig)
const { cookieMaxAge, slidingExpiration } = config.session
const ttlSeconds = Math.floor(cookieMaxAge / 1000)
const { slidingExpiration } = config.session
// Read per request: cookieMaxAge follows the live sessionIdleTimeoutHours setting.
const ttlSeconds = () => Math.floor(config.session.cookieMaxAge / 1000)
const slidingIntervalMs = Math.max(0, Number(config.session.slidingInterval) || 0)
const userSearchUrl = config.userSearchUrl || config.udsAuth?.userSearchUrl
const uacBaseUrl = config.udsAuth?.baseUrl || ''
/** R05: read live — do not freeze allowOpenRegistration at middleware create time. */
@ -196,7 +198,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
}
const userContext = { ...session.userData, empNo, _sessionId: session.sessionId }
if (persistTouch || slidingExpiration) {
await sessionStore.touch(session.sessionId, ttlSeconds)
await sessionStore.touch(session.sessionId, ttlSeconds())
userContext.lastActiveAt = new Date().toISOString()
}
const role = kind === 'fallback' || kind === 'sealed_box' || rolesStore.hasRole(empNo)
@ -213,17 +215,19 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
ctx.authVia = 'session'
ctx.authKind = kind
if (res && req && session._bearerForCookie) {
const cookie = buildSessionCookie(session._bearerForCookie, ttlSeconds, {
secure: isHttpsRequest(req),
})
const prev = res.getHeader?.('Set-Cookie')
const list = Array.isArray(prev) ? [...prev, cookie] : (prev ? [prev, cookie] : [cookie])
res.setHeader?.('Set-Cookie', list)
appendSessionCookie(req, res, session._bearerForCookie)
}
}
function appendSessionCookie(req, res, bearer) {
const cookie = buildSessionCookie(bearer, ttlSeconds(), { secure: isHttpsRequest(req) })
const prev = res.getHeader?.('Set-Cookie')
const list = Array.isArray(prev) ? [...prev, cookie] : (prev ? [prev, cookie] : [cookie])
res.setHeader?.('Set-Cookie', list)
}
async function mintSession(ctx, userContext, kind, { res, req } = {}) {
const { sessionId, bearer, record } = await sessionStore.create(userContext, ttlSeconds)
const { sessionId, bearer, record } = await sessionStore.create(userContext, ttlSeconds())
if (userContext.token) {
try { onSkillCredentials?.(userContext.empNo, userContext.token) } catch { /* ignore */ }
}
@ -262,7 +266,15 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
throw err
}
// Keep the Desktop bridge alive as long as its session (same bearer).
try { sessionBridge?.touch?.(extracted.bearer, ttlSeconds * 1000) } catch { /* ignore */ }
try { sessionBridge?.touch?.(extracted.bearer, ttlSeconds() * 1000) } catch { /* ignore */ }
// Slide the browser cookie with the server idle TTL. Without this UDS_SESSION kept
// the Max-Age from login and vanished 30 min later even for active users.
// Throttled by slidingInterval so not every request carries Set-Cookie.
if (slidingExpiration && extracted.via === 'cookie' && res && req
&& !res.headersSent
&& Date.now() + ttlSeconds() * 1000 - Number(session.expiresAt || 0) >= slidingIntervalMs) {
try { appendSessionCookie(req, res, extracted.bearer) } catch { /* ignore */ }
}
return next()
}
// Explicit bearer presented but invalid — do not mix with portal cookies.