oclaw/uds-auth/test/auth-session-p0.test.js
oliver 07cfbefb6c
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run
Enable default emergency account
2026-10-12 10:46:39 +08:00

276 lines
9.7 KiB
JavaScript

/**
* Phase-1 P0 negative tests (SEC-01..06, partial 07-09).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { createHash } from 'node:crypto'
import { MemoryStore } from '../lib/session/memory-store.js'
import { SESSION_COOKIE } from '../lib/session/store.js'
import {
extractSessionBearer,
extractPortalCredentials,
buildSessionCookie,
} from '../lib/session/request-auth.js'
import { createAuthMiddleware } from '../lib/middleware/auth-middleware.js'
import {
resolveIdentityFromRequest,
resolveIdentityFromRequestSync,
} from '../lib/dsh-acl.js'
import { requestIsLoopback } from '../lib/skill-credentials.js'
import { createAgentAuthHandlers } from '../lib/agent-auth.js'
import { RolesStore, ROLES, DEFAULT_FALLBACK_PASSWORD } from '../lib/roles.js'
import { SessionBridgeStore } from '../lib/session-bridge.js'
import { peekBrowserIdentity, rememberBrowserIdentity } from '../lib/identity-cache.js'
function mockRoles(seed = {}) {
const store = new RolesStore({})
for (const [empNo, role] of Object.entries(seed)) {
store._roles.set(empNo, role)
}
return store
}
test('SEC-01: static generic handlers are not exported from index route helpers', async () => {
// Regression: makeStaticHandler / open path resolve must not exist.
const src = await import('node:fs/promises').then((fs) =>
fs.readFile(new URL('../lib/index.js', import.meta.url), 'utf-8'),
)
assert.equal(src.includes('makeStaticHandler'), false)
assert.equal(src.includes('handleQrcodeStatic'), false)
assert.equal(src.includes('handleVendorStatic'), false)
assert.match(src, /static file routes removed/)
})
test('SEC-02/04: MemoryStore keys by bearer — empNo alone cannot prove auth', async () => {
const store = new MemoryStore()
const { bearer, sessionId } = await store.create({
empNo: '10001',
username: 'Alice',
isAuthenticated: true,
token: 'upstream-secret',
}, 600)
assert.ok(bearer.length >= 32)
assert.ok(sessionId)
const byBearer = await store.getByBearer(bearer)
assert.equal(byBearer.empNo, '10001')
assert.equal(store.getByBearerSync(bearer).empNo, '10001')
assert.equal(await store.getByBearer('deadbeef'.repeat(8)), null)
assert.equal(await store.getByBearer(''), null)
// Profile-like get(empNo) must not be treated as auth by middleware (documented).
const cached = await store.get('10001')
assert.equal(cached.empNo, '10001')
assert.ok(cached.token, 'cache may hold token but must not grant request auth alone')
})
test('SEC-02: fallback USER/UI cookies are not session bearers', () => {
const req = {
headers: {
cookie: 'UDS_FALLBACK_USER=administrator; UDS_FALLBACK_UI=administrator',
},
}
assert.equal(extractSessionBearer(req), null)
assert.equal(extractPortalCredentials(req), null)
})
test('SEC-02/03: sync/async ACL identity requires verified local session', async () => {
const store = new MemoryStore()
const roles = mockRoles({ administrator: ROLES.FALLBACK_ADMIN, '10001': ROLES.SUPER_ADMIN })
const deps = { sessionStore: store, rolesStore: roles, sessionBridge: null }
// Forged fallback cookies + arbitrary portal token → no principal
const forged = {
headers: {
cookie: 'UDS_FALLBACK_USER=administrator; PORTALSSOUser=10001; PORTALSSOCookie=arbitrary',
},
}
assert.equal(resolveIdentityFromRequestSync(forged, deps), null)
assert.equal(await resolveIdentityFromRequest(forged, deps), null)
// EmpNo cache hit alone is not enough
await store.create({ empNo: '10001', username: 'A', isAuthenticated: true }, 600)
assert.equal(resolveIdentityFromRequestSync({
headers: { cookie: 'PORTALSSOUser=10001' },
}, deps), null)
// Valid bearer → principal (no upstream token on identity)
const { bearer } = await store.create({
empNo: '10001',
username: 'A',
token: 'uac-secret',
isAuthenticated: true,
}, 600)
const req = { headers: { cookie: `${SESSION_COOKIE}=${bearer}` } }
const sync = resolveIdentityFromRequestSync(req, deps)
assert.equal(sync.empNo, '10001')
assert.equal(sync.userContext.token, undefined)
const asyncId = await resolveIdentityFromRequest(req, deps)
assert.equal(asyncId.empNo, '10001')
assert.equal(asyncId.userContext.token, undefined)
})
test('SEC-03: ACL path does not bootstrap first user', async () => {
const store = new MemoryStore()
const roles = mockRoles({}) // empty
const { bearer } = await store.create({
empNo: 'attacker00001',
isAuthenticated: true,
}, 600)
const identity = await resolveIdentityFromRequest({
headers: { cookie: `${SESSION_COOKIE}=${bearer}` },
}, { sessionStore: store, rolesStore: roles })
assert.equal(identity.empNo, 'attacker00001')
assert.equal(roles.isEmpty(), true, 'roles table must stay empty')
assert.equal(identity.role, ROLES.USER)
})
test('SEC-04: two devices — wrong bearer / mismatched bridge rejected', async () => {
const store = new MemoryStore()
const bridge = new SessionBridgeStore({ ttlMs: 60_000 })
const a = await store.create({ empNo: 'A', isAuthenticated: true }, 600)
const b = await store.create({ empNo: 'B', isAuthenticated: true }, 600)
bridge.mint({ empNo: 'A', kind: 'uds', token: a.bearer })
bridge.mint({ empNo: 'B', kind: 'uds', token: b.bearer })
// A bearer with B empNo bridge headers
const bad = extractSessionBearer({
headers: {
'x-uds-bridge-empno': 'B',
'x-uds-bridge-token': a.bearer,
'x-uds-bridge-kind': 'uds',
},
}, { sessionBridge: bridge })
// verify fails (empNo mismatch) → null
assert.equal(bad, null)
const ok = extractSessionBearer({
headers: {
cookie: buildSessionCookie(a.bearer, 600),
},
}, { sessionBridge: bridge })
assert.equal(ok.bearer, a.bearer)
assert.equal((await store.getByBearer(ok.bearer)).empNo, 'A')
})
test('SEC-05: requestIsLoopback ignores X-Forwarded-For', () => {
assert.equal(requestIsLoopback({
socket: { remoteAddress: '10.1.2.3' },
headers: { 'x-forwarded-for': '127.0.0.1' },
}), false)
assert.equal(requestIsLoopback({
socket: { remoteAddress: '127.0.0.1' },
headers: {},
}), true)
assert.equal(requestIsLoopback({
socket: { remoteAddress: '::ffff:127.0.0.1' },
headers: { 'x-forwarded-for': '8.8.8.8' },
}), true)
})
test('SEC-05: agent-credentials rejects empNo-only and disables raw token by default', async () => {
const handlers = createAgentAuthHandlers({
resolveCredentialsForSession: async () => ({ empNo: 'A', token: 't' }),
getSessionOwner: () => 'A',
resolveCallerSession: async () => null,
allowRawTokenResponse: false,
})
const res = {
statusCode: 0,
headers: {},
body: '',
setHeader(k, v) { this.headers[k] = v },
end(s) { this.body = s },
}
await handlers.handleAgentCredentials({
method: 'POST',
socket: { remoteAddress: '127.0.0.1' },
headers: {},
async *[Symbol.asyncIterator]() { yield Buffer.from(JSON.stringify({ empNo: 'A' })) },
}, res)
assert.equal(res.statusCode, 403)
assert.match(res.body, /raw_token_disabled|error/)
})
test('SEC-05: outbound requires caller session matching session owner', async () => {
let called = false
const handlers = createAgentAuthHandlers({
resolveCredentialsForSession: async () => {
called = true
return { empNo: 'A', token: 't' }
},
getSessionOwner: (sid) => (sid === 'sess-a' ? 'A' : 'B'),
resolveCallerSession: async () => ({ empNo: 'A', sessionId: 'local' }),
allowRawTokenResponse: true,
outboundHosts: ['example.com'],
})
const res = {
statusCode: 0,
headers: {},
body: '',
setHeader(k, v) { this.headers[k] = v },
end(s) { this.body = s },
}
// Caller A + session owned by B
await handlers.handleOutbound({
method: 'POST',
socket: { remoteAddress: '127.0.0.1' },
headers: {},
async *[Symbol.asyncIterator]() {
yield Buffer.from(JSON.stringify({
sessionId: 'sess-b',
url: 'https://example.com/x',
empNo: 'B',
}))
},
}, res)
assert.equal(res.statusCode, 401)
assert.equal(called, false)
})
test('SEC-06: identity-cache never restores a principal', () => {
rememberBrowserIdentity({ empNo: 'admin', role: 'super_admin' })
assert.equal(peekBrowserIdentity(), null)
})
test('SEC-08: new RolesStore enables the default emergency password', async () => {
const store = new RolesStore({})
await store.init()
assert.equal(store.isFallbackEnabled(), true)
assert.equal(store.verifyFallback(DEFAULT_FALLBACK_PASSWORD, '127.0.0.1'), true)
})
test('SEC-09: empty roles + non-initial admin cannot bootstrap via middleware helper', async () => {
const store = new MemoryStore()
const roles = mockRoles({})
const mw = createAuthMiddleware({
userSearchUrl: 'http://127.0.0.1:9/never',
initialAdminEmpNo: '99999',
allowOpenRegistration: true,
udsAuth: { baseUrl: 'http://127.0.0.1:9' },
session: { cookieMaxAge: 600000, slidingExpiration: true },
}, store, roles, {})
await assert.rejects(
() => mw.resolveRoleOnLogin('11111'),
(err) => err.code === 'bootstrap_required',
)
const role = await mw.resolveRoleOnLogin('99999')
assert.equal(role, ROLES.SUPER_ADMIN)
assert.equal(roles.getRole('99999'), ROLES.SUPER_ADMIN)
})
test('SEC-07: session cookie helper is HttpOnly', () => {
const c = buildSessionCookie('abc123def456abc123def456abc123de', 600, { secure: false })
assert.match(c, /^UDS_SESSION=/)
assert.match(c, /HttpOnly/)
assert.match(c, /SameSite=Lax/)
})
test('bearer hash is not reversible from store maps', async () => {
const store = new MemoryStore()
const { bearer } = await store.create({ empNo: 'x', isAuthenticated: true }, 60)
const hash = createHash('sha256').update(bearer).digest('hex')
assert.ok(store._byBearer.has(hash))
assert.equal([...store._byBearer.keys()].includes(bearer), false)
})