mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-18 13:33:53 +08:00
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>
This commit is contained in:
parent
fa4d5d6d97
commit
df97c5e042
72 changed files with 8436 additions and 1192 deletions
353
uds-auth/test/emergency-workspace-follow.test.js
Normal file
353
uds-auth/test/emergency-workspace-follow.test.js
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
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' }])
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue