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

312 lines
11 KiB
JavaScript

/**
* Loopback agent APIs: credentials + outbound proxy.
* SEC-05: no XFF trust; no arbitrary empNo credential fetch.
* Caller must prove a local session bearer whose empNo owns the DSH sessionId
* (or matches an explicit capability in a later phase).
*/
import { directRequest } from './uds/user-search.js'
import { requestIsLoopback } from './skill-credentials.js'
import { apiError, resolveLocale } from './i18n.js'
import { extractSessionBearer } from './session/request-auth.js'
import { readJsonBodyLimited, DEFAULT_MAX_BODY } from './utils/read-body.js'
import { audit } from './utils/audit-log.js'
const DEFAULT_OUTBOUND_HOSTS = [
'icenterapi.zte.com.cn',
'icentermsg.dt.zte.com.cn',
]
function sendJSON(res, code, data) {
res.statusCode = code >= 200 && code < 300 ? 200 : code
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.end(JSON.stringify(data))
}
function sendErr(req, res, status, code, vars) {
return sendJSON(res, status, apiError(code, resolveLocale(req), vars))
}
function readSessionId(req, body) {
const h = req.headers || {}
return (
h['x-dsh-session-id']
|| h['X-DSH-Session-Id']
|| body?.sessionId
|| body?.dshSessionId
|| null
)
}
async function readJsonBody(req) {
try {
return await readJsonBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY })
} catch (err) {
if (err?.code === 'payload_too_large' || err?.code === 'invalid_json') throw err
return {}
}
}
/**
* @param {{
* resolveCredentialsForSession: (sessionId: string) => Promise<{empNo:string,token:string}|null>,
* resolveCallerSession: (req: any) => Promise<{empNo:string,sessionId?:string}|null>,
* getSessionOwner: (sessionId: string) => string|null,
* empNoHeader?: string,
* authValueHeader?: string,
* outboundHosts?: string[],
* sessionBridge?: any,
* allowRawTokenResponse?: boolean|(() => boolean),
* taskCapabilities?: import('./task-capability.js').TaskCapabilityStore,
* }} deps
*/
export function createAgentAuthHandlers(deps) {
const empNoHeader = deps.empNoHeader || 'X-Emp-No'
const authValueHeader = deps.authValueHeader || 'X-Auth-Value'
/** R05: live flag — closing allowRawAgentToken must take effect without restart. */
function allowRawToken() {
if (typeof deps.allowRawTokenResponse === 'function') {
return deps.allowRawTokenResponse() === true
}
return deps.allowRawTokenResponse === true
}
function allowedHosts() {
const list = typeof deps.outboundHosts === 'function'
? deps.outboundHosts()
: deps.outboundHosts
// Empty whitelist = deny all (SEC-18); do not fall back to defaults when explicitly [].
if (Array.isArray(list)) return list.map(String).map((s) => s.trim().toLowerCase()).filter(Boolean)
// Explicit empty string also denies all; only undefined/null uses packaged defaults.
if (list === '') return []
if (list == null) return DEFAULT_OUTBOUND_HOSTS.map((s) => s.toLowerCase())
return String(list).split(/[,;\s]+/).map((s) => s.trim().toLowerCase()).filter(Boolean)
}
/** Exact hostname match only — no automatic subdomain wildcard (SEC-18). */
function hostAllowed(hostname) {
const host = String(hostname || '').toLowerCase()
if (!host) return false
const list = allowedHosts()
if (!list.length) return false
return list.includes(host)
}
async function resolveCaller(req, body = {}) {
// R06: task capability (skill/cron) — preferred over requiring a browser session.
const caps = deps.taskCapabilities
if (caps) {
const raw = caps.extractFromRequest?.(req)
|| body?.taskCapability
|| body?.capability
|| null
if (raw) {
const sessionId = readSessionId(req, body)
const verified = caps.verify(raw, {
audience: 'uds-auth-agent',
dshSessionId: sessionId || undefined,
})
if (verified?.empNo) {
return {
empNo: verified.empNo,
sessionId: verified.dshSessionId || sessionId || undefined,
via: 'task_capability',
capabilityId: verified.id,
scopes: verified.scopes,
}
}
return null
}
}
if (typeof deps.resolveCallerSession === 'function') {
return deps.resolveCallerSession(req)
}
return null
}
/**
* Authorize: loopback + (local session bearer OR task capability)
* + sessionId owned by caller. body.empNo is ignored as an auth input.
* @param {string} [requiredScope] A06: exact scope for the endpoint ('credentials'|'outbound')
*/
async function resolveAuthorizedCreds(req, body = {}, requiredScope) {
const caller = await resolveCaller(req, body)
if (!caller?.empNo) return { error: 'caller_unauthenticated', creds: null, sessionId: null }
const sessionId = readSessionId(req, body)
if (!sessionId) {
return { error: 'session_id_required', creds: null, sessionId: null }
}
const owner = deps.getSessionOwner?.(String(sessionId)) || null
if (!owner || String(owner) !== String(caller.empNo)) {
return { error: 'session_owner_mismatch', creds: null, sessionId: String(sessionId) }
}
// A06: credentials vs outbound require their own scope — not either-or.
if (caller.via === 'task_capability' && requiredScope) {
if (!Array.isArray(caller.scopes) || !caller.scopes.includes(requiredScope)) {
return { error: 'capability_scope_denied', creds: null, sessionId: String(sessionId) }
}
}
// Live account check when roles store is wired.
if (typeof deps.isAccountActive === 'function' && !deps.isAccountActive(caller.empNo)) {
return { error: 'account_disabled', creds: null, sessionId: String(sessionId) }
}
const creds = await deps.resolveCredentialsForSession(String(sessionId))
if (!creds || String(creds.empNo) !== String(caller.empNo)) {
return { error: 'no_skill_credentials', creds: null, sessionId: String(sessionId) }
}
return { error: null, creds, sessionId: String(sessionId), caller }
}
async function handleAgentCredentials(req, res) {
if (!requestIsLoopback(req)) {
audit({ action: 'agent_credentials', decision: 'deny', reasonCode: 'loopback_only' })
return sendErr(req, res, 403, 'loopback_only_credentials')
}
if (!allowRawToken()) {
audit({ action: 'agent_credentials', decision: 'deny', reasonCode: 'raw_token_disabled' })
return sendJSON(res, 403, {
...apiError('raw_token_disabled', resolveLocale(req)),
hint: 'use /uds-auth/api/agent-outbound with a verified local session',
})
}
const method = (req.method || 'GET').toUpperCase()
if (method !== 'GET' && method !== 'POST') {
return sendErr(req, res, 405, 'method_not_allowed')
}
let body = {}
try {
body = method === 'POST' ? await readJsonBody(req) : {}
} catch (err) {
return sendErr(req, res, err.statusCode || 400, err.code || 'invalid_json')
}
const { error, creds, sessionId } = await resolveAuthorizedCreds(req, body, 'credentials')
if (error || !creds) {
audit({
action: 'agent_credentials',
decision: 'deny',
reasonCode: error || 'no_skill_credentials',
meta: { sessionId },
})
return sendJSON(res, 401, {
...apiError(error || 'no_skill_credentials', resolveLocale(req)),
sessionId: sessionId || null,
})
}
audit({
action: 'agent_credentials',
decision: 'allow',
actor: creds.empNo,
resource: sessionId,
})
return sendJSON(res, 200, {
empNo: creds.empNo,
token: creds.token,
updatedAt: creds.updatedAt || null,
})
}
async function handleOutbound(req, res) {
if (!requestIsLoopback(req)) {
return sendErr(req, res, 403, 'loopback_only_outbound')
}
const method = (req.method || 'POST').toUpperCase()
if (method !== 'POST') {
return sendErr(req, res, 405, 'method_not_allowed')
}
const body = await readJsonBody(req)
const { error, creds } = await resolveAuthorizedCreds(req, body, 'outbound')
if (error || !creds) {
return sendErr(req, res, 401, error || 'no_skill_credentials')
}
const targetUrl = body.url
if (!targetUrl || typeof targetUrl !== 'string') {
return sendErr(req, res, 400, 'url_required')
}
let parsed
try {
parsed = new URL(targetUrl)
} catch {
return sendErr(req, res, 400, 'invalid_url')
}
// Credentials must never travel over cleartext HTTP (SEC-18).
if (parsed.protocol !== 'https:') {
return sendErr(req, res, 400, 'https_required')
}
if (parsed.username || parsed.password) {
return sendErr(req, res, 400, 'invalid_url')
}
if (!hostAllowed(parsed.hostname)) {
return sendJSON(res, 403, apiError('host_not_allowed', resolveLocale(req), { host: parsed.hostname }))
}
const upstreamMethod = String(body.method || 'POST').toUpperCase()
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(upstreamMethod)) {
return sendErr(req, res, 400, 'method_not_allowed')
}
// Rebuild headers from whitelist only — never forward caller Authorization/Cookie.
const allowedHeaderNames = new Set([
'content-type', 'accept', 'accept-language', 'x-lang-id', 'x-requested-with',
])
const headers = {}
if (body.headers && typeof body.headers === 'object') {
for (const [k, v] of Object.entries(body.headers)) {
if (allowedHeaderNames.has(String(k).toLowerCase()) && v != null) {
headers[k] = String(v).slice(0, 1024)
}
}
}
headers[empNoHeader] = creds.empNo
headers[authValueHeader] = creds.token
if (!headers['Content-Type'] && !headers['content-type'] && body.body != null) {
headers['Content-Type'] = 'application/json;charset=UTF-8'
}
let upstreamBody = body.body
if (upstreamBody != null && typeof upstreamBody !== 'string' && !Buffer.isBuffer(upstreamBody)) {
upstreamBody = JSON.stringify(upstreamBody)
}
try {
const out = await directRequest(parsed, {
method: upstreamMethod,
headers,
body: upstreamBody,
timeoutMs: Math.min(Number(body.timeoutMs) || 30000, 60000),
})
res.statusCode = out.statusCode || 502
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Uds-Outbound-Status', String(out.statusCode || 0))
res.end(JSON.stringify({
statusCode: out.statusCode,
headers: sanitizeUpstreamHeaders(out.headers),
body: out.body,
json: out.json,
}))
} catch (err) {
const payload = apiError('upstream_failed', resolveLocale(req))
payload.message = payload.message + ': ' + (err.message || String(err))
return sendJSON(res, 502, payload)
}
}
return { handleAgentCredentials, handleOutbound, DEFAULT_OUTBOUND_HOSTS, extractSessionBearer }
}
function sanitizeUpstreamHeaders(headers) {
if (!headers || typeof headers !== 'object') return {}
const out = {}
for (const [k, v] of Object.entries(headers)) {
const lower = k.toLowerCase()
if (lower === 'x-auth-value' || lower.includes('token') || lower.includes('cookie')) continue
out[k] = v
}
return out
}