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

@ -67,3 +67,65 @@ test('other origin/host rejections still fail immediately', async () => {
assert.equal(handled, 0)
assert.ok(Date.now() - started < 1000)
})
test('session-status probe is routed outside the auth middleware and never slides the TTL', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../lib/index.js', import.meta.url), 'utf8')
const probe = src.indexOf("pathname === '/uds-auth/session-status'")
const authed = src.indexOf("if (pathname.startsWith('/uds-auth/api/')) {")
assert.ok(probe > 0 && probe < authed, 'probe answers before /uds-auth/api/* auth dispatch')
const body = src.slice(src.indexOf('async function handleSessionStatus'), src.indexOf('// 客户端 config.get'))
assert.doesNotMatch(body, /touch\(|_authMiddleware/)
// getByBearer (what the probe uses) must not slide the idle TTL.
const store = new MemoryStore()
const { sessionId, bearer } = await store.create({ empNo: 'u1' }, 1800)
const before = store._sessions.get(sessionId).expiresAt
await new Promise((r) => setTimeout(r, 5))
assert.ok(await store.getByBearer(bearer))
assert.equal(store._sessions.get(sessionId).expiresAt, before)
})
test('client signs out on confirmed idle expiry instead of prompting on every call', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
// Expiry is only acted on after the non-sliding probe says alive=false.
assert.match(src, /fetch\('\/uds-auth\/session-status'/)
assert.match(src, /data\.alive === false && isLoggedInInUi\(\)\) \{\s*window\.dispatchEvent\(new Event\('uds-auth-expired'\)\)/)
// RPC failures and HTTP 401s trigger the probe; an idle timer and focus do too.
assert.match(src, /looksLikeSessionGone\(result\.error\)\) \{\s*void checkSessionAlive\(\)/)
assert.match(src, /if \(res\.status === 401\) void checkSessionAlive\(\)/)
assert.match(src, /setInterval\(check, 60_000\)/)
// Expired sign-out skips /api/logout and tells the user why.
assert.match(src, /signOut\(\{ expired: true \}\)/)
assert.match(src, /sessionExpired \? t\('ui\.sessionExpiredSignedOut'\)/)
})
test('QR refresh renews the UAC token in place instead of minting a new session', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../lib/index.js', import.meta.url), 'utf8')
// refresh flag is read locally and never forwarded upstream (body is rebuilt from contract fields).
assert.match(src, /const refreshToken = parsedBody\.refresh === true/)
const branch = src.slice(src.indexOf('if (refreshToken) {'), src.indexOf("const role = await _authMiddleware.resolveRoleOnLogin(id)\n const now"))
assert.match(branch, /peekCallerSession\(req\)/)
assert.match(branch, /token_refresh_emp_mismatch/)
assert.match(branch, /refreshSessionToken\(id, credentials\.token, profile\)/)
assert.doesNotMatch(branch, /mintLoginSession|appendSessionCookie/)
// Default: no automatic sign-out; token rejections are flagged, not revoked.
assert.match(src, /sessionIdleTimeoutHours: 0,/)
assert.match(src, /tokenValid: session \? !_tokenMonitor\?\.isTokenRejected\(session\.sessionId\) : null/)
assert.doesNotMatch(src, /revokeInvalidUdsAccount|UdsSessionRevalidator/)
})
test('client prompts a refresh QR on token expiry without signing out', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
assert.match(src, /new Event\(data\.tokenValid \? 'uds-auth-token-ok' : 'uds-auth-token-expired'\)/)
// expiry opens the panel once; the token handler never calls signOut
const handler = src.slice(src.indexOf('const onTokenExpired = () => {'), src.indexOf('const onTokenOk = () => {'))
assert.match(handler, /setOpen\(true\)/)
assert.doesNotMatch(handler, /signOut/)
assert.match(src, /refreshing \? \{ qrCodeKey, qrCodeValue, refresh: true \}/)
assert.match(src, /if \(auth\?\.success && auth\.refreshed\) \{/)
assert.match(src, /const wantQr = open && loginMode === 'qr' && \(!user \|\| tokenExpired\)/)
})