mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 05:23:14 +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
|
|
@ -1,9 +1,15 @@
|
|||
/**
|
||||
* 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',
|
||||
|
|
@ -33,68 +39,171 @@ function readSessionId(req, body) {
|
|||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
let raw = ''
|
||||
for await (const chunk of req) raw += chunk
|
||||
if (!raw) return {}
|
||||
try { return JSON.parse(raw) } catch { return {} }
|
||||
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>,
|
||||
* resolveCredentialsForEmpNo: (empNo: 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
|
||||
if (Array.isArray(list) && list.length) return list.map(String)
|
||||
return DEFAULT_OUTBOUND_HOSTS.slice()
|
||||
// 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()
|
||||
return allowedHosts().some((h) => host === h.toLowerCase() || host.endsWith('.' + h.toLowerCase()))
|
||||
if (!host) return false
|
||||
const list = allowedHosts()
|
||||
if (!list.length) return false
|
||||
return list.includes(host)
|
||||
}
|
||||
|
||||
async function resolveFromRequest(req, body = {}) {
|
||||
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) {
|
||||
const creds = await deps.resolveCredentialsForSession(String(sessionId))
|
||||
if (creds) return { creds, sessionId: String(sessionId) }
|
||||
if (!sessionId) {
|
||||
return { error: 'session_id_required', creds: null, sessionId: null }
|
||||
}
|
||||
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 }
|
||||
|
||||
const owner = deps.getSessionOwner?.(String(sessionId)) || null
|
||||
if (!owner || String(owner) !== String(caller.empNo)) {
|
||||
return { error: 'session_owner_mismatch', creds: null, sessionId: String(sessionId) }
|
||||
}
|
||||
return { creds: null, sessionId: sessionId ? String(sessionId) : null }
|
||||
|
||||
// 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')
|
||||
}
|
||||
const body = method === 'POST' ? await readJsonBody(req) : {}
|
||||
const { creds, sessionId } = await resolveFromRequest(req, body)
|
||||
if (!creds) {
|
||||
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('no_skill_credentials', resolveLocale(req)),
|
||||
...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,
|
||||
|
|
@ -111,9 +220,9 @@ export function createAgentAuthHandlers(deps) {
|
|||
return sendErr(req, res, 405, 'method_not_allowed')
|
||||
}
|
||||
const body = await readJsonBody(req)
|
||||
const { creds } = await resolveFromRequest(req, body)
|
||||
if (!creds) {
|
||||
return sendErr(req, res, 401, 'no_skill_credentials')
|
||||
const { error, creds } = await resolveAuthorizedCreds(req, body, 'outbound')
|
||||
if (error || !creds) {
|
||||
return sendErr(req, res, 401, error || 'no_skill_credentials')
|
||||
}
|
||||
|
||||
const targetUrl = body.url
|
||||
|
|
@ -126,19 +235,31 @@ export function createAgentAuthHandlers(deps) {
|
|||
} catch {
|
||||
return sendErr(req, res, 400, 'invalid_url')
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
return sendErr(req, res, 400, 'unsupported_protocol')
|
||||
// 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()
|
||||
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]
|
||||
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
|
||||
|
|
@ -157,13 +278,12 @@ export function createAgentAuthHandlers(deps) {
|
|||
method: upstreamMethod,
|
||||
headers,
|
||||
body: upstreamBody,
|
||||
timeoutMs: Number(body.timeoutMs) || 30000,
|
||||
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))
|
||||
// Wrap so skill gets status + body without leaking injected auth headers
|
||||
res.end(JSON.stringify({
|
||||
statusCode: out.statusCode,
|
||||
headers: sanitizeUpstreamHeaders(out.headers),
|
||||
|
|
@ -177,7 +297,7 @@ export function createAgentAuthHandlers(deps) {
|
|||
}
|
||||
}
|
||||
|
||||
return { handleAgentCredentials, handleOutbound, DEFAULT_OUTBOUND_HOSTS }
|
||||
return { handleAgentCredentials, handleOutbound, DEFAULT_OUTBOUND_HOSTS, extractSessionBearer }
|
||||
}
|
||||
|
||||
function sanitizeUpstreamHeaders(headers) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue