oclaw/uds-auth/lib/agent-auth.js
oliver 64761c8300 Add parallel skill credential path to uds-auth for agent/cron use.
UI session behavior stays unchanged; login writes a separate skill cache, with optional retain-on-logout and loopback agent-credentials/outbound APIs plus helpers/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 23:01:11 +08:00

189 lines
6.3 KiB
JavaScript

/**
* Loopback agent APIs: credentials + outbound proxy.
*/
import { directRequest } from './uds/user-search.js'
import { requestIsLoopback } from './skill-credentials.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 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) {
let raw = ''
for await (const chunk of req) raw += chunk
if (!raw) return {}
try { return JSON.parse(raw) } catch { return {} }
}
/**
* @param {{
* resolveCredentialsForSession: (sessionId: string) => Promise<{empNo:string,token:string}|null>,
* resolveCredentialsForEmpNo: (empNo: string) => Promise<{empNo:string,token:string}|null>,
* empNoHeader?: string,
* authValueHeader?: string,
* outboundHosts?: string[],
* }} deps
*/
export function createAgentAuthHandlers(deps) {
const empNoHeader = deps.empNoHeader || 'X-Emp-No'
const authValueHeader = deps.authValueHeader || 'X-Auth-Value'
function allowedHosts() {
const list = typeof deps.outboundHosts === 'function'
? deps.outboundHosts()
: deps.outboundHosts
if (Array.isArray(list) && list.length) return list.map(String)
return DEFAULT_OUTBOUND_HOSTS.slice()
}
function hostAllowed(hostname) {
const host = String(hostname || '').toLowerCase()
return allowedHosts().some((h) => host === h.toLowerCase() || host.endsWith('.' + h.toLowerCase()))
}
async function resolveFromRequest(req, body = {}) {
const sessionId = readSessionId(req, body)
if (sessionId) {
const creds = await deps.resolveCredentialsForSession(String(sessionId))
if (creds) return { creds, sessionId: String(sessionId) }
}
const empNo = body.empNo || req.headers['x-uds-emp-no']
if (empNo) {
const creds = await deps.resolveCredentialsForEmpNo(String(empNo))
if (creds) return { creds, sessionId: sessionId ? String(sessionId) : null }
}
return { creds: null, sessionId: sessionId ? String(sessionId) : null }
}
async function handleAgentCredentials(req, res) {
if (!requestIsLoopback(req)) {
return sendJSON(res, 403, { error: 'agent-credentials is loopback-only' })
}
const method = (req.method || 'GET').toUpperCase()
if (method !== 'GET' && method !== 'POST') {
return sendJSON(res, 405, { error: 'Method not allowed' })
}
const body = method === 'POST' ? await readJsonBody(req) : {}
const { creds, sessionId } = await resolveFromRequest(req, body)
if (!creds) {
return sendJSON(res, 401, {
error: 'no_skill_credentials',
message: '请先完成 UDS 扫码登录',
sessionId: sessionId || null,
})
}
return sendJSON(res, 200, {
empNo: creds.empNo,
token: creds.token,
updatedAt: creds.updatedAt || null,
})
}
async function handleOutbound(req, res) {
if (!requestIsLoopback(req)) {
return sendJSON(res, 403, { error: 'outbound is loopback-only' })
}
const method = (req.method || 'POST').toUpperCase()
if (method !== 'POST') {
return sendJSON(res, 405, { error: 'Method not allowed' })
}
const body = await readJsonBody(req)
const { creds } = await resolveFromRequest(req, body)
if (!creds) {
return sendJSON(res, 401, {
error: 'no_skill_credentials',
message: '请先完成 UDS 扫码登录',
})
}
const targetUrl = body.url
if (!targetUrl || typeof targetUrl !== 'string') {
return sendJSON(res, 400, { error: 'url required' })
}
let parsed
try {
parsed = new URL(targetUrl)
} catch {
return sendJSON(res, 400, { error: 'invalid url' })
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return sendJSON(res, 400, { error: 'unsupported protocol' })
}
if (!hostAllowed(parsed.hostname)) {
return sendJSON(res, 403, { error: 'host_not_allowed', host: parsed.hostname })
}
const upstreamMethod = String(body.method || 'POST').toUpperCase()
const headers = { ...(body.headers && typeof body.headers === 'object' ? body.headers : {}) }
for (const k of Object.keys(headers)) {
const lower = k.toLowerCase()
if (lower === 'x-auth-value' || lower === 'x-emp-no' || lower === 'host' || lower === 'content-length') {
delete headers[k]
}
}
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: Number(body.timeoutMs) || 30000,
})
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))
// Wrap so skill gets status + body without leaking injected auth headers
res.end(JSON.stringify({
statusCode: out.statusCode,
headers: sanitizeUpstreamHeaders(out.headers),
body: out.body,
json: out.json,
}))
} catch (err) {
return sendJSON(res, 502, { error: 'upstream_failed', message: err.message || String(err) })
}
}
return { handleAgentCredentials, handleOutbound, DEFAULT_OUTBOUND_HOSTS }
}
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
}