mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-15 11:00:48 +08:00
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>
This commit is contained in:
parent
dfdf298184
commit
0e6de055a9
20 changed files with 904 additions and 119 deletions
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -351,3 +351,42 @@ test('anonymous stream endpoints are parked until the carrier aborts (no termina
|
|||
for await (const f of authed.method()) frames.push(f)
|
||||
assert.deepEqual(frames, [{ status: 'credential-stored' }])
|
||||
})
|
||||
|
||||
test('WS activity (assertPrincipalLive) reports the live session so its idle TTL slides', async () => {
|
||||
const stores = makeStores()
|
||||
const [, kind, ctxOf] = EMERGENCY_LOGINS[0]
|
||||
const { bearer } = await login(stores, ctxOf(), kind)
|
||||
const session = await stores.sessionStore.getByBearer(bearer)
|
||||
const seen = []
|
||||
const noop = async () => {}
|
||||
const wc = {
|
||||
create: noop, rename: noop, delete: noop, insertBefore: noop, insertSessionBefore: noop,
|
||||
archiveSession: noop, unarchiveSession: noop, pinSession: noop, unpinSession: noop,
|
||||
async *follow() { yield { type: 'baseline', value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] } } },
|
||||
}
|
||||
installDshAcl({
|
||||
inject(deps, cb) { if (deps.includes('workspaceController')) cb({ workspaceController: wc }) },
|
||||
on() { return () => {} },
|
||||
get() { throw new Error('no service') },
|
||||
}, {
|
||||
sessionAcl: { getOwner: () => null, setOwner() {} },
|
||||
userWorkspaces: new UserWorkspaceStore({}),
|
||||
getWorkspaceRoot: () => '',
|
||||
getRolesStore: () => stores.roles,
|
||||
getSessionStore: () => stores.sessionStore,
|
||||
ensureUserWorkspace: async () => ({ workspaceId: 'ws-admin' }),
|
||||
getWorkspaceRegistry: () => ({ list: () => [], get: () => null }),
|
||||
onPrincipalActive: (sid, emp) => seen.push([sid, emp]),
|
||||
})
|
||||
await collect(wc, identityFromSessionRecord(session, stores.roles))
|
||||
assert.deepEqual(seen[0], [session.sessionId, 'administrator'])
|
||||
})
|
||||
|
||||
test('SessionBridgeStore.touchEmpNo slides every live bridge of that account only', () => {
|
||||
const store = new SessionBridgeStore({ ttlMs: 60_000 })
|
||||
const a = store.mint({ empNo: 'alice', kind: 'uds' })
|
||||
const b = store.mint({ empNo: 'bob', kind: 'uds' })
|
||||
store.touchEmpNo('alice', 10 * 60_000)
|
||||
assert.ok(store._byToken.get(a.token).exp > Date.now() + 5 * 60_000)
|
||||
assert.ok(store._byToken.get(b.token).exp <= Date.now() + 60_000)
|
||||
})
|
||||
|
|
|
|||
119
uds-auth/test/session-revalidation.test.js
Normal file
119
uds-auth/test/session-revalidation.test.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
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)
|
||||
})
|
||||
|
|
@ -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\)/)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue