oclaw/uds-auth/test/emergency-workspace-follow.test.js
oliver df97c5e042
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run
fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw
"sign-in failed / add API key" on DSH Desktop because the remote.mux opens
anonymously at boot and every early call was rejected terminally.

- gateway: anonymous workspace/follow reaches dsh-acl's empty baseline;
  anonymous streams are parked until the carrier aborts instead of failing;
  anonymous workspace/initializeDefault answers "nothing created"
- request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket
  or bridge (sync + async variants used by all callers)
- dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup
  instead of 403, so the Desktop welcome read no longer fails
- desktop-bootstrap: key/account projection is best-effort per reference
- client: emit connection/reset after login so boot-time caches (settings
  describe mirror -> Settings > Models) re-read under the real principal
- sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min);
  bridge expiry slides with its session; MemoryStore persisted to
  sessions.json (bearer hash only, userData AES-256-GCM sealed)
- README: session lifetime/persistence and DSH compatibility notes

Also includes previously uncommitted uds-auth 0.3.x work in this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:11:09 +08:00

353 lines
15 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { RolesStore } from '../lib/roles.js'
import { MemoryStore } from '../lib/session/memory-store.js'
import { SessionBridgeStore } from '../lib/session-bridge.js'
import { UserWorkspaceStore } from '../lib/workspace-provision.js'
import {
extractSessionBearer,
extractSessionBearerAsync,
} from '../lib/session/request-auth.js'
import {
installDshAcl,
identityFromSessionRecord,
resolveIdentityFromRequest,
resolveIdentityFromRequestSync,
} from '../lib/dsh-acl.js'
import { runWithUserContext } from '../lib/context.js'
import { installGatewayPolicy } from '../lib/gateway-policy.js'
import { createAuthMiddleware } from '../lib/middleware/auth-middleware.js'
import { buildLocalAdminUserContext, sealLocalAdminBox } from '../lib/local-admin.js'
process.env.UDS_AUTH_LOCAL_ADMIN_BOX = sealLocalAdminBox('test-passphrase-ok')
const STALE = 'deadbeef'.repeat(8)
const EMERGENCY_LOGINS = [
['password fallback', 'fallback', () => ({
empNo: 'administrator',
username: 'Fallback Administrator',
isAuthenticated: true,
role: 'fallback_admin',
authMode: 'fallback-password',
fallbackCredVersion: 1,
})],
['sealed box', 'sealed_box', () => buildLocalAdminUserContext()],
]
function makeStores() {
const roles = new RolesStore({})
roles._fallbackPasswordHash = 'scrypt$test'
roles._fallbackCredVersion = 1
roles._prefs.set('administrator', { viewAllSessions: true })
return { roles, sessionStore: new MemoryStore(), sessionBridge: new SessionBridgeStore({}) }
}
async function login(stores, userContext, kind) {
const { bearer } = await stores.sessionStore.create(userContext, 1800)
const bridge = stores.sessionBridge.mint({ empNo: userContext.empNo, kind, token: bearer })
return { bearer, bridge }
}
function muxUpgrade(query, headers = {}) {
return {
method: 'GET',
url: '/api/remote.mux?' + query,
socket: { remoteAddress: '127.0.0.1' },
headers: { upgrade: 'websocket', connection: 'Upgrade', origin: 'dsh-app://app', ...headers },
}
}
for (const [label, kind, buildContext] of EMERGENCY_LOGINS) {
test(`${label}: stale UDS_SESSION cookie no longer shadows the WS ticket`, async () => {
const stores = makeStores()
const { bearer } = await login(stores, buildContext(), kind)
const deps = { ...stores, rolesStore: stores.roles }
const cookie = { cookie: `UDS_SESSION=${STALE}` }
const t1 = stores.sessionBridge.mintWsTicket({ empNo: 'administrator', bearer })
const sync = resolveIdentityFromRequestSync(muxUpgrade('udsWsTicket=' + t1.ticket, cookie), deps)
assert.equal(sync?.empNo, 'administrator')
assert.equal(sync?.kind, kind)
const t2 = stores.sessionBridge.mintWsTicket({ empNo: 'administrator', bearer })
const asyncId = await resolveIdentityFromRequest(muxUpgrade('udsWsTicket=' + t2.ticket, cookie), deps)
assert.equal(asyncId?.empNo, 'administrator')
assert.equal(asyncId?.kind, kind)
})
test(`${label}: stale cookie + loopback bridge query still authenticates remote.mux`, async () => {
const stores = makeStores()
const { bridge } = await login(stores, buildContext(), kind)
const req = muxUpgrade(
`udsBridgeEmpNo=administrator&udsBridgeToken=${bridge.token}&udsBridgeKind=${kind}`,
{ cookie: `UDS_SESSION=${STALE}` },
)
const id = resolveIdentityFromRequestSync(req, { ...stores, rolesStore: stores.roles })
assert.equal(id?.empNo, 'administrator')
})
test(`${label}: workspace follow shows every workspace (view-all on)`, async () => {
const stores = makeStores()
const { bearer } = await login(stores, buildContext(), kind)
const identity = identityFromSessionRecord(stores.sessionStore.getByBearerSync(bearer), stores.roles)
const { wc } = installFollowHarness(stores)
const frames = await collect(wc, identity)
assert.deepEqual(frames[0].value.items.map((w) => w.id), ['ws-admin', 'ws-shared'])
})
}
test('live cookie still wins and the WS ticket is not consumed', async () => {
const stores = makeStores()
const a = await login(stores, buildLocalAdminUserContext(), 'sealed_box')
const b = await login(stores, { empNo: '1001', authMode: 'token+profile' }, 'uds')
const ticket = stores.sessionBridge.mintWsTicket({ empNo: '1001', bearer: b.bearer })
const req = muxUpgrade('udsWsTicket=' + ticket.ticket, { cookie: `UDS_SESSION=${a.bearer}` })
const got = extractSessionBearer(req, {
sessionBridge: stores.sessionBridge,
isLiveBearer: (x) => !!stores.sessionStore.getByBearerSync(x),
})
assert.equal(got.via, 'cookie')
assert.equal(got.bearer, a.bearer)
assert.ok(stores.sessionBridge.consumeWsTicket(ticket.ticket), 'ticket left unconsumed')
})
test('dead cookie with no other credential keeps legacy cookie result', async () => {
const req = { method: 'GET', url: '/uds-auth/api/me', headers: { cookie: `UDS_SESSION=${STALE}` } }
const got = await extractSessionBearerAsync(req, {
sessionBridge: new SessionBridgeStore({}),
isLiveBearer: async () => false,
})
assert.deepEqual(got, { bearer: STALE, via: 'cookie' })
})
test('auth middleware: stale cookie falls through to bridge headers for /api/me', async () => {
const stores = makeStores()
const { bridge } = await login(stores, buildLocalAdminUserContext(), 'sealed_box')
const mw = createAuthMiddleware({
udsAuth: { baseUrl: 'https://uac.example.test', systemCode: 'test' },
session: { cookieMaxAge: 1_800_000, slidingExpiration: true },
}, stores.sessionStore, stores.roles, { sessionBridge: stores.sessionBridge })
const ctx = {
req: {
method: 'GET',
url: '/uds-auth/api/me',
socket: { remoteAddress: '127.0.0.1' },
headers: {
cookie: `UDS_SESSION=${STALE}`,
'x-uds-bridge-empno': 'administrator',
'x-uds-bridge-token': bridge.token,
'x-uds-bridge-kind': 'sealed_box',
},
},
res: { setHeader() {}, getHeader() {} },
}
await mw(ctx, async () => {})
assert.equal(ctx.empNo, 'administrator')
assert.equal(ctx.authKind, 'sealed_box')
assert.equal(ctx.permissions.canViewAllSessions, true)
})
test('auth middleware slides bridge expiry with the session', async () => {
const stores = makeStores()
const { bridge } = await login(stores, buildLocalAdminUserContext(), 'sealed_box')
const row = stores.sessionBridge._byToken.get(bridge.token)
row.exp = Date.now() + 5_000
const mw = createAuthMiddleware({
udsAuth: { baseUrl: 'https://uac.example.test', systemCode: 'test' },
session: { cookieMaxAge: 1_800_000, slidingExpiration: true },
}, stores.sessionStore, stores.roles, { sessionBridge: stores.sessionBridge })
const ctx = {
req: {
method: 'GET',
url: '/uds-auth/api/me',
headers: { 'x-uds-bridge-empno': 'administrator', 'x-uds-bridge-token': bridge.token },
},
res: { setHeader() {}, getHeader() {} },
}
await mw(ctx, async () => {})
assert.equal(ctx.empNo, 'administrator')
assert.ok(row.exp > Date.now() + 25 * 60_000, 'bridge exp extended to ~30min')
})
test('SessionBridgeStore.touch never revives an expired bridge', () => {
const store = new SessionBridgeStore({ ttlMs: 60_000 })
const live = store.mint({ empNo: 'administrator', kind: 'sealed_box' })
const dead = store.mint({ empNo: 'administrator', kind: 'sealed_box' })
store._byToken.get(dead.token).exp = Date.now() - 1
assert.equal(store.touch(live.token, 600_000), true)
assert.ok(store._byToken.get(live.token).exp > Date.now() + 500_000)
assert.equal(store.touch(dead.token, 600_000), false)
assert.equal(store.verify('administrator', dead.token), null)
assert.equal(store.touch('unknown'), false)
})
test('gateway lets anonymous workspace/follow reach dsh-acl (empty baseline, not an error)', async () => {
let principal = null
const descriptors = {
'workspace/follow': { namespace: 'workspace', method: 'follow', parameters: [], invocation: { kind: 'direct' }, mode: 'stream' },
'session/list': { namespace: 'session', method: 'list', parameters: [], invocation: { kind: 'direct' } },
}
const gateway = {
async *openRemoteEvents() {}, receiveRemoteEventResult() {},
resolveDescriptor(ns, method) { return descriptors[`${ns}/${method}`] },
async prepareInvocation(request) {
if (request.method === 'follow') {
return { method: async function* () { yield { type: 'baseline', value: { items: [] } } } }
}
return { method: () => 'ok' }
},
}
installGatewayPolicy(gateway, {
identity: () => principal,
assertPrincipal: (p) => { if (!p?.empNo) throw new Error('login_required_session') },
permissions: () => ({ canAccessSettings: false }),
assertSession: () => {},
deny: (code) => { throw new Error(code) },
})
const prepared = await gateway.prepareInvocation({ namespace: 'workspace', method: 'follow', args: {} })
const frames = []
for await (const f of prepared.method()) frames.push(f)
assert.equal(frames[0].type, 'baseline')
// Other endpoints stay locked for anonymous principals.
await assert.rejects(
gateway.prepareInvocation({ namespace: 'session', method: 'list', args: {} }),
/login_required_session/,
)
principal = {}
await assert.doesNotReject(gateway.prepareInvocation({ namespace: 'workspace', method: 'follow', args: {} }))
})
test('dsh-acl follow: anonymous gets one empty baseline and holds until abort', async () => {
const stores = makeStores()
const { wc } = installFollowHarness(stores)
const ac = new AbortController()
const frames = []
const done = runWithUserContext(null, async () => {
for await (const f of wc.follow(ac.signal)) frames.push(f)
})
await new Promise((r) => setTimeout(r, 20))
assert.equal(frames.length, 1)
assert.deepEqual(frames[0].value.items, [])
ac.abort()
await done
})
function installFollowHarness(stores) {
const items = [
{ id: 'ws-admin', workspaceId: 'ws-admin', path: 'C:/u/administrator', sessionIds: [] },
{ id: 'ws-shared', workspaceId: 'ws-shared', path: 'D:/project/harness', sessionIds: ['s1'] },
]
const uw = new UserWorkspaceStore({})
uw.set('administrator', { path: 'C:/u/administrator', workspaceId: 'ws-admin' })
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: uw,
getWorkspaceRoot: () => '',
getRolesStore: () => stores.roles,
getSessionStore: () => stores.sessionStore,
ensureUserWorkspace: async () => ({ workspaceId: 'ws-admin' }),
getWorkspaceRegistry: () => ({ list: () => items, get: (id) => items.find((i) => i.id === id) }),
})
return { wc, items }
}
async function collect(wc, identity) {
const frames = []
await runWithUserContext(identity, async () => {
for await (const f of wc.follow(new AbortController().signal)) frames.push(f)
})
return frames
}
test('client emits connection/reset after login so boot-time caches (settings mirror) reload', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
// connection.reconnect() alone never emits connection/reset (DSH only emits it on onConnected).
assert.match(src, /window\.__udsAuthEmitReset = \(\) => \{\s*try \{ cctx\.emit\('connection\/reset'\) \}/)
assert.match(src, /function scheduleAuthReset\(/)
// fires after a soft reconnect and on the first authenticated /api/me per empNo
assert.match(src, /_softReconnectQuietUntil = Date\.now\(\) \+ 2800\s*\n\s*scheduleAuthReset\(/)
assert.match(src, /window\.__udsAuthResetEmp !== resetEmp/)
// cleared on logout / unauthenticated so the next login resets again
assert.equal((src.match(/window\.__udsAuthResetEmp = null/g) || []).length, 2)
})
test('anonymous workspace/initializeDefault is a no-op (no default-workspace error toast)', async () => {
let principal = null
let invoked = 0
const gateway = {
async *openRemoteEvents() {}, receiveRemoteEventResult() {},
resolveDescriptor: (namespace, method) => ({ namespace, method, parameters: [], invocation: { kind: 'direct' } }),
async prepareInvocation() { return { method: () => { invoked++; return { workspace: { workspaceId: 'w' } } } } },
}
installGatewayPolicy(gateway, {
identity: () => principal,
assertPrincipal: (p) => { if (!p?.empNo) throw new Error('login_required_session') },
permissions: () => ({ canAccessSettings: true }),
assertSession: () => {},
deny: (code) => { throw new Error(code) },
})
const anon = await gateway.prepareInvocation({ namespace: 'workspace', method: 'initializeDefault', args: {} })
assert.equal(anon.method(), undefined)
assert.equal(invoked, 0, 'real controller never runs for anonymous callers')
principal = { empNo: 'administrator' }
const authed = await gateway.prepareInvocation({ namespace: 'workspace', method: 'initializeDefault', args: {} })
assert.deepEqual(authed.method(), { workspace: { workspaceId: 'w' } })
assert.equal(invoked, 1)
principal = null
await assert.rejects(
gateway.prepareInvocation({ namespace: 'workspace', method: 'create', args: {} }),
/login_required_session/,
)
})
test('anonymous stream endpoints are parked until the carrier aborts (no terminal error)', async () => {
let principal = null
let invoked = 0
const control = new AbortController()
const gateway = {
async *openRemoteEvents() {}, receiveRemoteEventResult() {},
resolveDescriptor: (namespace, method) => ({ namespace, method, parameters: [], invocation: { kind: 'direct' }, mode: 'stream' }),
async prepareInvocation() {
return {
invocation: { signal: control.signal },
method: async function* () { invoked++; yield { status: 'credential-stored' } },
}
},
}
installGatewayPolicy(gateway, {
identity: () => principal,
assertPrincipal: (p) => { if (!p?.empNo) throw new Error('login_required_session') },
permissions: () => ({ canAccessSettings: true }),
assertSession: () => {},
deny: (code) => { throw new Error(code) },
})
const prepared = await gateway.prepareInvocation({ namespace: 'account', method: 'watch', args: {} })
const it = prepared.method()[Symbol.asyncIterator]()
let settled = false
const next = it.next().then((r) => { settled = true; return r })
await new Promise((r) => setTimeout(r, 30))
assert.equal(settled, false, 'anonymous stream yields nothing and does not fail')
control.abort()
assert.deepEqual(await next, { value: undefined, done: true })
assert.equal(invoked, 0, 'real stream never opened for anonymous callers')
principal = { empNo: 'administrator' }
const authed = await gateway.prepareInvocation({ namespace: 'account', method: 'watch', args: {} })
const frames = []
for await (const f of authed.method()) frames.push(f)
assert.deepEqual(frames, [{ status: 'credential-stored' }])
})