mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-13 07:13:20 +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
170
uds-auth/test/acceptance-batch1.test.js
Normal file
170
uds-auth/test/acceptance-batch1.test.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Acceptance batch-1 regressions (R01–R05).
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { MemoryStore } from '../lib/session/memory-store.js'
|
||||
import { createAgentAuthHandlers } from '../lib/agent-auth.js'
|
||||
import { RolesStore, ROLES } from '../lib/roles.js'
|
||||
import { identityFromSessionRecord } from '../lib/dsh-acl.js'
|
||||
import { runWithUserContext } from '../lib/context.js'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
test('R01/A05: client attaches bridge only to same-host http↔ws / https↔wss', () => {
|
||||
const src = readFileSync(join(root, 'lib/client.js'), 'utf8')
|
||||
assert.match(src, /isTrustedHostUrl/)
|
||||
assert.match(src, /locSecure !== urlSecure/)
|
||||
assert.match(src, /!isTrustedHostUrl\(url\)/)
|
||||
assert.match(src, /!isTrustedHostUrl\(input\)/)
|
||||
})
|
||||
|
||||
test('R03: revoked session fails getBySessionIdSync', async () => {
|
||||
const store = new MemoryStore()
|
||||
const { sessionId, bearer } = await store.create({ empNo: 'A001', displayName: 'A' }, 600)
|
||||
assert.ok(await store.getByBearer(bearer))
|
||||
assert.ok(store.getBySessionIdSync(sessionId))
|
||||
await store.revokeSession(sessionId)
|
||||
assert.equal(store.getBySessionIdSync(sessionId), null)
|
||||
assert.equal(await store.getByBearer(bearer), null)
|
||||
})
|
||||
|
||||
test('R03/Q10: fallback identity rejected when emergency password disabled', () => {
|
||||
const roles = new RolesStore({})
|
||||
roles._roles.set('administrator', ROLES.FALLBACK_ADMIN || 'fallback_admin')
|
||||
roles._fallbackPasswordHash = null
|
||||
assert.equal(roles.isFallbackEnabled(), false)
|
||||
const id = identityFromSessionRecord({
|
||||
sessionId: 's1',
|
||||
empNo: 'administrator',
|
||||
kind: 'fallback',
|
||||
userData: { empNo: 'administrator' },
|
||||
}, roles)
|
||||
assert.equal(id, null)
|
||||
})
|
||||
|
||||
test('R03/Q10: fallback identity accepted when emergency password enabled', () => {
|
||||
const roles = new RolesStore({})
|
||||
roles._roles.set('administrator', 'fallback_admin')
|
||||
roles._fallbackPasswordHash = 'abc'
|
||||
assert.equal(roles.isFallbackEnabled(), true)
|
||||
const id = identityFromSessionRecord({
|
||||
sessionId: 's1',
|
||||
empNo: 'administrator',
|
||||
kind: 'fallback',
|
||||
userData: { empNo: 'administrator' },
|
||||
}, roles)
|
||||
assert.equal(id?.empNo, 'administrator')
|
||||
assert.equal(id?.kind, 'fallback')
|
||||
})
|
||||
|
||||
test('R04: create/follow use live permissions; no admin follow bypass', () => {
|
||||
const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
|
||||
assert.match(src, /assertCreateTargetAllowed[\s\S]*?livePermissions\(identity\)/)
|
||||
assert.match(src, /never bypass projection for admin-at-subscribe/)
|
||||
assert.equal(/if \(canSeeAllWorkspaces\(identity\)\) \{\s*yield\* origFollow/.test(src), false)
|
||||
})
|
||||
|
||||
test('R05: closing allowRawAgentToken takes effect immediately', async () => {
|
||||
let allowRaw = true
|
||||
const handlers = createAgentAuthHandlers({
|
||||
allowRawTokenResponse: () => allowRaw,
|
||||
outboundHosts: () => ['example.com'],
|
||||
resolveCallerSession: async () => ({ empNo: 'A', sessionId: 's' }),
|
||||
getSessionOwner: () => 'A',
|
||||
resolveCredentialsForSession: async () => ({ empNo: 'A', token: 'secret' }),
|
||||
})
|
||||
allowRaw = false
|
||||
const chunks = []
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader() {},
|
||||
end(s) { chunks.push(String(s)) },
|
||||
}
|
||||
await handlers.handleAgentCredentials({
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
}, res)
|
||||
assert.equal(res.statusCode, 403)
|
||||
assert.match(chunks.join(''), /raw_token_disabled/)
|
||||
})
|
||||
|
||||
test('R05: empty outbound list does not revive defaults', async () => {
|
||||
const handlers = createAgentAuthHandlers({
|
||||
allowRawTokenResponse: () => false,
|
||||
outboundHosts: () => [],
|
||||
resolveCallerSession: async () => ({ empNo: 'A', sessionId: 's' }),
|
||||
getSessionOwner: () => 'A',
|
||||
resolveCredentialsForSession: async () => ({ empNo: 'A', token: 'secret' }),
|
||||
})
|
||||
const chunks = []
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader() {},
|
||||
end(s) { chunks.push(String(s)) },
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
sessionId: 's',
|
||||
url: 'https://icenterapi.zte.com.cn/x',
|
||||
method: 'GET',
|
||||
})
|
||||
const req = {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield Buffer.from(body)
|
||||
},
|
||||
}
|
||||
await handlers.handleOutbound(req, res)
|
||||
const text = chunks.join('')
|
||||
assert.ok(
|
||||
/host_not_allowed|outbound_denied|forbidden|not_allowed/i.test(text)
|
||||
|| res.statusCode >= 400,
|
||||
text,
|
||||
)
|
||||
})
|
||||
|
||||
test('R05: live config getters wired in index', () => {
|
||||
const src = readFileSync(join(root, 'lib/index.js'), 'utf8')
|
||||
assert.match(src, /getAllowOpenRegistration/)
|
||||
assert.match(src, /allowRawTokenResponse: \(\) =>/)
|
||||
assert.match(src, /empty string \/ \[\] means deny-all|deny-all/)
|
||||
})
|
||||
|
||||
test('R02: job/terminal/fs ACL wraps and principal live check', () => {
|
||||
const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
|
||||
assert.match(src, /jobController/)
|
||||
assert.match(src, /terminalController/)
|
||||
assert.match(src, /file_forbidden/)
|
||||
assert.match(src, /assertPrincipalLive/)
|
||||
assert.match(src, /getSessionStore/)
|
||||
})
|
||||
|
||||
test('R03: assertCanAccess rejects revoked principal via session store', async () => {
|
||||
const store = new MemoryStore()
|
||||
const { sessionId } = await store.create({ empNo: 'A001' }, 600)
|
||||
const roles = new RolesStore({})
|
||||
roles._roles.set('A001', 'user')
|
||||
const identity = identityFromSessionRecord({
|
||||
sessionId,
|
||||
empNo: 'A001',
|
||||
kind: 'uds',
|
||||
userData: { empNo: 'A001' },
|
||||
}, roles)
|
||||
assert.ok(identity)
|
||||
await store.revokeSession(sessionId)
|
||||
assert.equal(store.getBySessionIdSync(sessionId), null)
|
||||
|
||||
// Simulate installDshAcl assertPrincipalLive gate
|
||||
const sid = identity.sessionId
|
||||
assert.ok(sid)
|
||||
assert.equal(store.getBySessionIdSync(sid), null)
|
||||
})
|
||||
|
||||
// silence unused in some runners
|
||||
void runWithUserContext
|
||||
Loading…
Add table
Add a link
Reference in a new issue