oclaw/uds-auth/lib/uds/user-search.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

200 lines
6.4 KiB
JavaScript

/**
* Outbound HTTP(S) that must NOT use HTTP_PROXY / HTTPS_PROXY.
* ZTE icenter / UAC user APIs are intranet-only; corporate forward proxies
* typically return 401/403 and never reach the real service.
*
* SEC-16: subject must strictly equal requested empNo — no substring / digit-contains match.
*/
import http from 'node:http'
import https from 'node:https'
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024
/**
* @param {string|URL} url
* @param {{ method?: string, headers?: Record<string,string|number>, body?: string|Buffer, timeoutMs?: number, maxBytes?: number }} opts
* @returns {Promise<{ statusCode: number, headers: object, body: string, json: any }>}
*/
export function directRequest(url, opts = {}) {
const target = typeof url === 'string' ? new URL(url) : url
const isHttps = target.protocol === 'https:'
const lib = isHttps ? https : http
const method = (opts.method || 'GET').toUpperCase()
const body = opts.body == null ? null : Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(String(opts.body))
const headers = { ...(opts.headers || {}) }
if (body && headers['Content-Length'] == null && headers['content-length'] == null) {
headers['Content-Length'] = body.length
}
const timeoutMs = Math.min(Math.max(Number(opts.timeoutMs) || 3000, 500), 60000)
const maxBytes = opts.maxBytes ?? MAX_RESPONSE_BYTES
// Fresh Agent — never inherit globalAgent (often patched by proxy bootstraps).
const agent = new lib.Agent({ keepAlive: false })
return new Promise((resolve, reject) => {
const req = lib.request({
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
path: target.pathname + target.search,
method,
headers,
agent,
timeout: timeoutMs,
}, (res) => {
const chunks = []
let total = 0
res.on('data', (c) => {
total += c.length
if (total > maxBytes) {
req.destroy()
reject(new Error(`directRequest response too large (>${maxBytes})`))
return
}
chunks.push(c)
})
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
let json = null
try { json = JSON.parse(text) } catch { json = null }
resolve({
statusCode: res.statusCode || 0,
headers: res.headers,
body: text,
json,
})
})
})
req.on('error', reject)
req.on('timeout', () => {
req.destroy()
reject(new Error(`directRequest timeout after ${timeoutMs}ms: ${target.host}`))
})
if (body) req.write(body)
req.end()
})
}
function extractSubject(row) {
if (!row || typeof row !== 'object') return ''
return String(
row.employeeShortId || row.employeeNO || row.empUIID || row.empNo || '',
).trim()
}
function subjectsEqual(a, b) {
return String(a || '').trim() === String(b || '').trim()
}
/**
* POST userSearchUrl with X-Emp-No + X-Auth-Value (intranet, no proxy).
* Success requires: HTTP 2xx, business success code, exactly one subject-matching row.
*/
export async function searchUserByEmpNoToken({
userSearchUrl,
empNo,
token,
empNoHeader = 'X-Emp-No',
authValueHeader = 'X-Auth-Value',
origin,
timeoutMs = 3000,
}) {
if (!userSearchUrl || !empNo || !token) {
return { ok: false, reason: 'missing_args' }
}
const want = String(empNo).trim()
const body = JSON.stringify({
employeeShortId: want,
enableLabel: true,
keyword: want,
})
const headers = {
'Content-Type': 'application/json;charset=UTF-8',
[empNoHeader]: want,
[authValueHeader]: token,
}
if (origin) {
headers.Origin = origin
headers.Referer = String(origin).replace(/\/?$/, '/')
}
let res
try {
res = await directRequest(userSearchUrl, { method: 'POST', headers, body, timeoutMs })
} catch (err) {
return { ok: false, reason: 'network', error: err.message }
}
// Only accept 2xx — do not treat HTTP 500 + biz success as ok (SEC-16).
if (res.statusCode < 200 || res.statusCode >= 300) {
return {
ok: false,
reason: res.statusCode === 401 || res.statusCode === 403 ? 'http_auth' : 'http_status',
statusCode: res.statusCode,
msg: res.json?.code?.msg || res.json?.msg || res.body.slice(0, 200),
hint: res.statusCode === 401 || res.statusCode === 403
? 'userSearchUrl must be reachable on intranet WITHOUT HTTP(S)_PROXY'
: undefined,
}
}
const result = res.json
if (!result || typeof result !== 'object') {
return { ok: false, reason: 'bad_json', statusCode: res.statusCode, raw: res.body.slice(0, 300) }
}
const code = result?.code?.code ?? result?.code
if (code !== '0000' && code !== 0 && code !== '0') {
return {
ok: false,
reason: 'biz_code',
statusCode: res.statusCode,
code,
msg: result?.code?.msg || result?.msg,
}
}
const list = Array.isArray(result?.bo) ? result.bo
: Array.isArray(result?.bo?.rows) ? result.bo.rows
: Array.isArray(result?.bo?.list) ? result.bo.list
: []
if (!list.length) {
return { ok: false, reason: 'empty', statusCode: res.statusCode }
}
// Strict subject equality — no substring / digit-contains (SEC-16).
const matches = list.filter((row) => subjectsEqual(extractSubject(row), want))
if (matches.length !== 1) {
return {
ok: false,
reason: matches.length === 0 ? 'emp_mismatch' : 'ambiguous',
statusCode: res.statusCode,
matchCount: matches.length,
}
}
const emp = matches[0]
const subject = extractSubject(emp)
if (!subject || !subjectsEqual(subject, want)) {
return { ok: false, reason: 'emp_mismatch', resolvedEmpNo: subject }
}
// Reserved / emergency account names never come from UAC search.
if (subject === 'administrator' || subject.startsWith('__')) {
return { ok: false, reason: 'reserved_subject' }
}
return {
ok: true,
profile: {
empNo: subject,
username: emp.name || emp.empName || emp.userName || subject,
department: emp.deptFullName || emp.deptName || emp.deptShortName || emp.orgNamePath || emp.orgName || emp.department || '',
organization: emp.orgNamePath || emp.orgName || '',
email: emp.email || emp.mail || '',
phone: emp.mobile || emp.phone || '',
// Omit raw upstream blob from default profile to limit accidental logging.
token: undefined,
},
result: { code: result.code },
}
}