/** * uds-auth - UDS Authentication Plugin for DeepSeek Harness */ import { requirePermission } from './middleware/auth-middleware.js' import { computePermissions } from './roles.js' import { apiError, apiOk, resolveLocale } from './i18n.js' import { readFile, writeFile, mkdir } from 'node:fs/promises' import { resolve, dirname, isAbsolute, join } from 'node:path' import { homedir } from 'node:os' import { fileURLToPath } from 'node:url' import { createRequire } from 'node:module' import { createHash, randomBytes } from 'node:crypto' import { logLocalAdminStatus, appendLocalAdminCookie, isLocalAdminBoxConfigured, readLocalAdminBox, openLocalAdminBoxAsync, allowUnlockAttempt, buildLocalAdminUserContext, LOCAL_ADMIN_BOX_ENV, } from './local-admin.js' import { SessionBridgeStore } from './session-bridge.js' import { runAsHost } from './context.js' import { buildSessionCookie, clearSessionCookie, extractSessionBearerAsync, SESSION_COOKIE, } from './session/request-auth.js' import { checkRequestOrigin, requireJsonContentType } from './utils/origin-guard.js' import { requestIsLoopback } from './skill-credentials.js' import { atomicWriteJson } from './utils/atomic-write.js' import { readBodyLimited, MAX_QR_PROXY_BODY, MAX_QR_DATA_LEN, DEFAULT_MAX_BODY } from './utils/read-body.js' import { RateLimiter, clientKey } from './utils/rate-limit.js' import { audit } from './utils/audit-log.js' import { QrChallengeStore, qrLoginCredentials } from './qr-challenge.js' import { migrateState } from './state-migration.js' import { protectProfileDirectory } from './profile-security.js' import { gatewayPolicy, assertGatewayContract } from './gateway-policy.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const require = createRequire(import.meta.url) // DSH plugin exports export const name = 'uds-auth' export const inject = [] export const NS = 'uds-auth' function tryGet(ctx, name) { try { return ctx.get(name) } catch { return undefined } } /** Register /uds-auth once; caller must wrap in ctx.effect so reload disposes. */ function registerWebRoute(ctx) { const register = (server) => { const dispose = server.register({ kind: 'prefix', path: '/uds-auth', handler: handleAllRoutes, }) ctx.logger?.info?.('[uds-auth] Route registered: prefix /uds-auth') return () => dispose?.() } const present = tryGet(ctx, 'webServer') ?? ctx.webServer if (present !== undefined) return register(present) const disposers = [] let registered = false const off = ctx.on('internal/service', (serviceName, value) => { if (serviceName !== 'webServer' || registered) return registered = true disposers.push(register(value)) }) return () => { off() for (const d of disposers) d() } } const UDS_AUTH_SETTINGS_NAMESPACE = 'uds-auth' const UDS_AUTH_RPC_CHANNEL = '/uds-auth' // API prefix for routes const API_PREFIX = '/uds-auth' // Config 默认值 — 不依赖 schemastery,纯 JS 对象 // 可配置字段(部署相关),其余内部常量在 INTERNAL 里 const CONFIG_DEFAULTS = { uacBaseUrl: 'https://uac.zte.com.cn', userSearchUrl: 'https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search', loginSystemCode: '100000455558', originSystemCode: '', workspaceRoot: '', /** Explicit first admin when roles.json is empty (also: UDS_AUTH_INITIAL_ADMIN) */ initialAdminEmpNo: '', /** When roles already exist, auto-register unknown UAC users as role=user */ allowOpenRegistration: true, /** UI 退出时是否保留 skill 凭证缓存(默认保留,供 cron/skill) */ retainSkillCredentialsOnLogout: true, /** skill 凭证 TTL(秒),默认 7 天 */ skillCredentialTtlSeconds: 7 * 24 * 60 * 60, /** outbound 白名单 host(逗号分隔或数组) */ outboundAllowedHosts: 'icenterapi.zte.com.cn,icentermsg.dt.zte.com.cn', /** SEC-05: raw /agent-credentials token response off by default */ allowRawAgentToken: false, /** R13: optional exact Host allowlist (comma/array). Empty = compare Origin↔Host only. */ trustedHosts: '', } // 内部常量(不暴露给用户,UDS 固定协议) const INTERNAL = { uacQrVerifyPath: '/uacqr/auth/qrcode/verify.serv', cookieEmpNo: 'PORTALSSOUser', cookieAuthValue: 'PORTALSSOCookie', altCookieEmpNo: 'ZTEDPGSSOUser', altCookieAuthValue: 'ZTEDPGSSOCookie', empNoHeader: 'X-Emp-No', authValueHeader: 'X-Auth-Value', // Session: opaque bearer in UDS_SESSION cookie / Desktop bridge session: { storeType: 'memory', // 单实例足够;多实例需共享 store,不可静默切 memory redisUrl: 'redis://localhost:6379', cookieMaxAge: 1800000, // 30min idle TTL slidingExpiration: true, slidingInterval: 300000, }, } // 懒加载 schemastery Config schema(只在需要注册 DSH 设置面板时用) // 用 createRequire 因为 schemastery 由 DSH host 运行时提供,不在我们的 package 里 let _ConfigSchema = null let _DshSettings = null function loadConfigSchemaAndSettings(ctx) { if (_ConfigSchema && _DshSettings) return { schema: _ConfigSchema, settings: _DshSettings } try { const z = require('@deepseek-ai/schemastery') _ConfigSchema = z.object({ uacBaseUrl: z.string().default(CONFIG_DEFAULTS.uacBaseUrl), userSearchUrl: z.string().default(CONFIG_DEFAULTS.userSearchUrl), loginSystemCode: z.string().default(CONFIG_DEFAULTS.loginSystemCode), originSystemCode: z.string().default(CONFIG_DEFAULTS.originSystemCode), workspaceRoot: z.string().default(CONFIG_DEFAULTS.workspaceRoot), retainSkillCredentialsOnLogout: z.boolean().default(CONFIG_DEFAULTS.retainSkillCredentialsOnLogout), skillCredentialTtlSeconds: z.number().default(CONFIG_DEFAULTS.skillCredentialTtlSeconds), outboundAllowedHosts: z.string().default(CONFIG_DEFAULTS.outboundAllowedHosts), initialAdminEmpNo: z.string().default(CONFIG_DEFAULTS.initialAdminEmpNo), allowOpenRegistration: z.boolean().default(CONFIG_DEFAULTS.allowOpenRegistration), allowRawAgentToken: z.boolean().default(CONFIG_DEFAULTS.allowRawAgentToken), trustedHosts: z.string().default(CONFIG_DEFAULTS.trustedHosts), }) _DshSettings = require('@deepseek-ai/dsh-settings') ctx?.logger?.info?.('[uds-auth] schemastery + dsh-settings loaded') return { schema: _ConfigSchema, settings: _DshSettings } } catch (err) { ctx?.logger?.warn?.('[uds-auth] schemastery/dsh-settings not available: %s', err.message) return { schema: null, settings: null } } } function mergeConfig(custom = {}) { return { ...CONFIG_DEFAULTS, ...custom } } /** * A11: durable state lives under a profile data dir, not only the install tree. * Override with UDS_AUTH_DATA_DIR (absolute) or config.dataDir. */ function resolvePluginDataDir(config = _currentConfig) { const fromEnv = String(process.env.UDS_AUTH_DATA_DIR || '').trim() if (fromEnv) return isAbsolute(fromEnv) ? fromEnv : resolve(process.cwd(), fromEnv) const fromCfg = String(config?.dataDir || '').trim() if (fromCfg) return isAbsolute(fromCfg) ? fromCfg : resolve(process.cwd(), fromCfg) return join(homedir(), '.uds-auth') } /** DSH/schemastery config objects may be frozen — always clone before mutate. */ function asMutableConfig(config) { return { ...(config && typeof config === 'object' ? config : {}) } } // Store references let _sessionStore = null let _authMiddleware = null let _apiHandlers = null let _rolesStore = null let _currentConfig = null let _qrcodeLib = null let _sessionAcl = null let _userWorkspaces = null let _pluginCtx = null let _logger = console let _skillCredentials = null let _agentAuthHandlers = null /** @type {import('./task-capability.js').TaskCapabilityStore | null} */ let _taskCapabilities = null /** @type {SessionBridgeStore | null} */ let _sessionBridge = null /** SEC-20: false until init completes; routes return 503 when unready. */ let _pluginReady = false let _pluginReadyError = null const _loginLimiter = new RateLimiter({ windowMs: 60_000, max: 20 }) const _qrLimiter = new RateLimiter({ windowMs: 60_000, max: 60 }) /** R13: server-side QR challenge TTL + one-time consume */ const QR_CHALLENGE_TTL_MS = 5 * 60 * 1000 const _qrChallenges = new QrChallengeStore({ ttlMs: QR_CHALLENGE_TTL_MS }) const MAX_QR_UPSTREAM_BYTES = 256 * 1024 /** * Mint Desktop bridge using the same bearer as the local auth session. * @param {string} empNo * @param {string} [kind] * @param {string} [ssoToken] * @param {string} [bearer] local session bearer — must match session store */ function mintBridge(empNo, kind = 'fallback', ssoToken, bearer) { if (!_sessionBridge) return null try { const minted = _sessionBridge.mint({ empNo, kind, ssoToken, token: bearer || undefined, }) return { empNo: minted.empNo, kind: minted.kind, token: minted.token, exp: minted.exp, } } catch (err) { _logger?.warn?.('[uds-auth] mintBridge failed: %s', err.message) return null } } /** Create local session + bridge sharing the same bearer. */ async function mintLoginSession(empNo, userContext, kind = 'uds', ssoToken) { const live = () => !_rolesStore?.isDisabled(empNo) && (kind !== 'fallback' || (_rolesStore.isFallbackEnabled() && userContext.fallbackCredVersion === _rolesStore.getFallbackCredVersion())) if (!live()) throw Object.assign(new Error('invalid_credentials'), { code: 'invalid_credentials' }) const ttl = Math.floor(INTERNAL.session.cookieMaxAge / 1000) const { sessionId, bearer } = await _sessionStore.create( { ...userContext, empNo }, ttl, ) if (!live()) { await _sessionStore.revokeSession(sessionId) throw Object.assign(new Error('invalid_credentials'), { code: 'invalid_credentials' }) } const bridge = mintBridge(empNo, kind, ssoToken, bearer) return { sessionId, bearer, bridge } } function appendSessionCookie(req, res, bearer, extraCookies = []) { const ttl = Math.floor(INTERNAL.session.cookieMaxAge / 1000) const secure = isHttpsRequest(req) const sessionCookie = buildSessionCookie(bearer, ttl, { secure }) const list = [sessionCookie, ...extraCookies] const prev = res.getHeader?.('Set-Cookie') if (prev) { const p = Array.isArray(prev) ? prev : [prev] res.setHeader('Set-Cookie', [...p, ...list]) } else { res.setHeader('Set-Cookie', list) } } function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) { if (/^https?:\/\//.test(uacQrVerifyPath)) return uacQrVerifyPath return (uacBaseUrl.replace(/\/$/, '') + '/' + uacQrVerifyPath.replace(/^\//, '')) } // Prefer offline bundle (shipped in tgz); fall back to npm / legacy vendored tree. async function loadQRCodeLib() { if (_qrcodeLib) return _qrcodeLib const candidates = [ resolve(__dirname, 'qrcode-bundled.cjs'), 'qrcode', resolve(__dirname, 'qrcode-lib', 'lib', 'browser.js'), ] for (const candidate of candidates) { try { _qrcodeLib = require(candidate) return _qrcodeLib } catch { // try next } } _logger.error?.('[uds-auth] Failed to load qrcode library (no bundled/npm/vendored copy)') return null } /** Start a UAC TwoDIMAuth QR challenge (no browser jQuery plugins required). */ function createQrChallenge(config = _currentConfig || {}) { return _qrChallenges.create({ loginSystemCode: String(config.loginSystemCode || CONFIG_DEFAULTS.loginSystemCode), originSystemCode: String(config.originSystemCode || '') }) } function peekQrChallenge(key, value, binding) { return _qrChallenges.peek(key, value, binding) } function consumeQrChallenge(key, value, binding) { return _qrChallenges.consume(key, value, binding) } function isQrLoginSuccessPayload(payload) { return !!qrLoginCredentials(payload) } async function handleQrStart(req, res) { if (req.method !== 'POST' && req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('method_not_allowed', localeOf(req)))) return } if (!_qrLimiter.allow(clientKey(req))) { res.writeHead(429, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('rate_limited', localeOf(req)))) return } const challenge = createQrChallenge(_currentConfig) res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', }) res.end(JSON.stringify(challenge)) } // CORS proxy for UAC API async function handleQRProxy(req, res) { if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('method_not_allowed', localeOf(req)))) return } if (!_qrLimiter.allow(clientKey(req))) { res.writeHead(429, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('rate_limited', localeOf(req)))) return } let body try { body = await readBodyLimited(req, { maxBytes: MAX_QR_PROXY_BODY }) } catch (err) { const status = err.statusCode || 413 res.writeHead(status, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError(err.code || 'payload_too_large', localeOf(req)))) return } try { let parsedBody = {} try { parsedBody = JSON.parse(body) } catch { parsedBody = {} } // A04: peek only — do not consume until upstream confirms login. const peeked = peekQrChallenge(parsedBody.qrCodeKey, parsedBody.qrCodeValue, req.headers['x-uds-qr-binding']) if (!peeked.ok) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError(peeked.reason || 'qr_unknown', localeOf(req)))) return } // Only send contract fields. Browser binding and arbitrary body fields stay local. parsedBody = { qrCodeKey: parsedBody.qrCodeKey, qrCodeValue: parsedBody.qrCodeValue, loginClientIp: '127.0.0.1', loginSystemCode: peeked.row.loginSystemCode, originSystemCode: peeked.row.originSystemCode, verifyCode: calculateVerifyCode(parsedBody.qrCodeKey, parsedBody.qrCodeValue, '127.0.0.1', peeked.row.loginSystemCode, peeked.row.originSystemCode), } body = JSON.stringify(parsedBody) const https = await import('node:https') const url = new URL(buildVerifyUrl(_currentConfig.uacBaseUrl, INTERNAL.uacQrVerifyPath)) const options = { hostname: url.hostname, port: url.port || 443, path: url.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'Origin': _currentConfig.uacBaseUrl, 'Referer': _currentConfig.uacBaseUrl + '/', 'X-Requested-With': 'XMLHttpRequest' }, timeout: 15_000, } const proxyReq = https.request(options, (proxyRes) => { const chunks = [] let total = 0 let aborted = false proxyRes.on('data', (chunk) => { if (aborted) return total += chunk.length if (total > MAX_QR_UPSTREAM_BYTES) { aborted = true try { proxyRes.destroy() } catch { /* ignore */ } if (!res.headersSent) { res.writeHead(502, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('upstream_too_large', localeOf(req)))) } return } chunks.push(chunk) }) proxyRes.on('end', () => { if (aborted || res.headersSent) return void (async () => { let payload try { payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return sendErr(res, req, 502, 'invalid_upstream_response') } if (proxyRes.statusCode < 200 || proxyRes.statusCode >= 300) { return sendErr(res, req, 502, 'upstream_error') } const credentials = qrLoginCredentials(payload) if (!credentials) { // Pending polls preserve the challenge. Do not return upstream secrets/errors verbatim. const top = String(payload?.code?.code ?? payload?.code ?? '') const boCode = String(payload?.bo?.code ?? '') if (top === '0000' && ['4002', '1002'].includes(boCode)) { return sendJSON(res, 200, { code: { code: top }, bo: { code: boCode } }) } return sendErr(res, req, 502, 'invalid_upstream_response') } const consumed = consumeQrChallenge(parsedBody.qrCodeKey, parsedBody.qrCodeValue, req.headers['x-uds-qr-binding']) if (!consumed.ok) return sendErr(res, req, 409, consumed.reason) // Reserve once before asynchronous profile validation or session minting. const profile = await _authMiddleware.verifyEmpNoAndToken(credentials.empNo, credentials.token) if (!profile) return sendErr(res, req, 401, 'invalid_credentials') const id = profile.empNo || credentials.empNo const role = await _authMiddleware.resolveRoleOnLogin(id) const now = new Date().toISOString() const userContext = { empNo: id, userId: id, username: profile.username || id, displayName: profile.username || id, department: profile.department || '', organization: profile.organization || profile.department || '', email: profile.email || '', phone: profile.phone || '', token: credentials.token, isAuthenticated: true, authMode: 'token+profile', authenticatedAt: now, lastActiveAt: now, sessionCreatedAt: now } await ensureUserWorkspace(id) const { bearer, bridge } = await mintLoginSession(id, userContext, 'uds', credentials.token) appendSessionCookie(req, res, bearer) sendJSON(res, 200, { code: { code: '0000' }, bo: { code: '0000' }, auth: { success: true, empNo: id, role, bridge, userContext: { empNo: id, username: userContext.username, role, isAuthenticated: true } } }) })().catch(err => { if (!res.headersSent) sendErr(res, req, 502, err.code || 'upstream_error') }) }) proxyRes.on('error', () => { if (!res.headersSent) sendErr(res, req, 502, 'upstream_error') }) }) proxyReq.on('timeout', () => { try { proxyReq.destroy() } catch { /* ignore */ } }) proxyReq.on('error', (err) => { if (!res.headersSent) sendErr(res, req, 502, 'upstream_error') }) proxyReq.write(body) proxyReq.end() } catch (err) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } } // SEC-01: generic /uds-auth/lib/* and /vendor/* file serving removed (path traversal). // QR library is bundled as qrcode-bundled.cjs and loaded in-process only. async function handleQRCode(req, res) { if (!_qrLimiter.allow(clientKey(req))) { res.writeHead(429, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('rate_limited', localeOf(req)))) return } const url = new URL(req.url, 'http://localhost') const data = url.searchParams.get('data') if (!data) { res.writeHead(400, { 'Content-Type': 'text/plain' }) res.end('Missing data parameter') return } if (String(data).length > MAX_QR_DATA_LEN) { res.writeHead(413, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('payload_too_large', localeOf(req)))) return } try { const qr = await loadQRCodeLib() if (!qr || typeof qr.toString !== 'function') { throw new Error('qrcode library not available') } const svg = await new Promise((resolve, reject) => { qr.toString(data, { type: 'svg', width: 200, margin: 2 }, (err, str) => { if (err) reject(err) else resolve(str) }) }) res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache' }) res.end(svg) } catch (err) { // 不再生成无纠错码/掩码的无效二维码(会显示成扫不出来的"乱码") console.error('[uds-auth] QR generation error:', err) res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }) res.end('QR generation failed') } } // 客户端 config.get —— RPC 在 web 端以 HTTP 方式请求 /uds-auth/config.get async function handleConfigGet(req, res) { const c = _currentConfig || {} res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' }) res.end(JSON.stringify({ ok: true, value: { uacBaseUrl: c.uacBaseUrl, userSearchUrl: c.userSearchUrl, loginSystemCode: c.loginSystemCode, originSystemCode: c.originSystemCode, workspaceRoot: c.workspaceRoot, retainSkillCredentialsOnLogout: c.retainSkillCredentialsOnLogout !== false, skillCredentialTtlSeconds: c.skillCredentialTtlSeconds, outboundAllowedHosts: c.outboundAllowedHosts, }, })) } // Calculate verifyCode using Node.js crypto (reliable) function calculateVerifyCode(qrCodeKey, qrCodeValue, loginClientIp, loginSystemCode, originSystemCode) { const source = qrCodeKey + qrCodeValue + loginClientIp + loginSystemCode + originSystemCode return createHash('md5').update(source).digest('hex') } // VerifyCode calculation endpoint async function handleVerifyCode(req, res) { if (req.method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('method_not_allowed', localeOf(req)))) return } const url = new URL(req.url, 'http://localhost') const qrCodeKey = url.searchParams.get('qrCodeKey') const qrCodeValue = url.searchParams.get('qrCodeValue') const loginClientIp = url.searchParams.get('loginClientIp') || '127.0.0.1' const loginSystemCode = url.searchParams.get('loginSystemCode') || '100000455558' const originSystemCode = url.searchParams.get('originSystemCode') || '' if (!qrCodeKey || !qrCodeValue) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('missing_qr_params', localeOf(req)))) return } const challenge = peekQrChallenge(qrCodeKey, qrCodeValue, req.headers['x-uds-qr-binding']) if (!challenge.ok) return sendErr(res, req, 400, challenge.reason) const verifyCode = calculateVerifyCode(qrCodeKey, qrCodeValue, loginClientIp, loginSystemCode, originSystemCode) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ qrCodeKey, qrCodeValue, verifyCode, loginClientIp, loginSystemCode, originSystemCode, source: qrCodeKey + qrCodeValue + loginClientIp + loginSystemCode + originSystemCode })) } // User info proxy — intranet direct (no HTTP_PROXY). T03: token never via URL query. async function handleUserInfo(req, res) { const method = (req.method || 'GET').toUpperCase() if (method !== 'POST' && method !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('method_not_allowed', localeOf(req)))) return } const url = new URL(req.url, 'http://localhost') // Reject token in query on any method (access logs / Referer leak). if (url.searchParams.get('token') || url.searchParams.get('authValue')) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('token_in_query_forbidden', localeOf(req)))) return } let empNo = null let token = null const headers = req.headers || {} if (method === 'POST') { let body = {} try { const raw = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY }) try { body = JSON.parse(raw || '{}') } catch { body = {} } } catch (err) { res.writeHead(err.statusCode || 413, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError(err.code || 'payload_too_large', localeOf(req)))) return } empNo = body.empNo || headers[INTERNAL.empNoHeader.toLowerCase()] || headers['x-emp-no'] token = body.token || body.authValue || headers[INTERNAL.authValueHeader.toLowerCase()] || headers['x-auth-value'] } else { // GET: headers only (no query credentials). empNo = headers[INTERNAL.empNoHeader.toLowerCase()] || headers['x-emp-no'] token = headers[INTERNAL.authValueHeader.toLowerCase()] || headers['x-auth-value'] } empNo = empNo != null ? String(empNo).trim() : '' token = token != null ? String(token).trim() : '' if (!empNo || !token) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(apiError('missing_emp_token', localeOf(req)))) return } try { const { searchUserByEmpNoToken } = await import('./uds/user-search.js') const out = await searchUserByEmpNoToken({ userSearchUrl: _currentConfig.userSearchUrl, empNo, token, empNoHeader: INTERNAL.empNoHeader, authValueHeader: INTERNAL.authValueHeader, origin: _currentConfig.uacBaseUrl, }) if (!out.ok) { const status = out.statusCode === 401 || out.statusCode === 403 ? out.statusCode : 502 res.writeHead(status, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ...apiError('user_search_failed', localeOf(req)), reason: out.reason, hint: out.hint || 'Ensure userSearchUrl is intranet-reachable and Host does not force HTTP(S)_PROXY for *.zte.com.cn', detail: { code: out.code, msg: out.msg, statusCode: out.statusCode }, })) return } // R07: client expects code + non-empty bo list (not only upstream code). // Return whitelist profile only — never raw UAC blob / full token. const profile = out.profile || {} const safeRow = { employeeShortId: profile.empNo, empNo: profile.empNo, name: profile.username || profile.empNo, deptFullName: profile.department || '', orgNamePath: profile.organization || '', email: profile.email || '', } res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ code: { code: '0000' }, bo: [safeRow], profile: { empNo: profile.empNo, username: profile.username || null, displayName: profile.username || null, department: profile.department || null, organization: profile.organization || null, }, })) } catch (err) { res.writeHead(502, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } } /** Desktop renderer (dsh-app://app) talks to loopback Host across origins. */ function applyDesktopCors(req, res) { const origin = String(req?.headers?.origin || '').trim() if (origin !== 'dsh-app://app') return false if (!requestIsLoopback(req)) return false res.setHeader('Access-Control-Allow-Origin', 'dsh-app://app') res.setHeader('Access-Control-Allow-Credentials', 'true') res.setHeader( 'Access-Control-Allow-Headers', 'Content-Type, X-UDS-Bridge-EmpNo, X-UDS-Bridge-Token, X-UDS-Bridge-Kind, X-UDS-Session', ) res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') res.setHeader('Vary', 'Origin') return true } function sendJSON(res, code, data) { res.statusCode = code >= 200 && code < 300 ? 200 : code res.setHeader('Content-Type', 'application/json; charset=utf-8') res.end(JSON.stringify(data)) } function localeOf(req, userContext) { return resolveLocale(req, userContext) } function sendErr(res, req, status, code, vars, userContext) { return sendJSON(res, status, apiError(code, localeOf(req, userContext), vars)) } function sendOkMsg(res, req, code, vars, userContext, extra = {}) { return sendJSON(res, 200, { ...apiOk(code, localeOf(req, userContext), vars), ...extra }) } /** Prefer Secure cookies only on HTTPS — http://127.0.0.1 drops Secure cookies from WS. */ function isHttpsRequest(req) { if (req?.socket?.encrypted) return true const xf = String(req?.headers?.['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase() return xf === 'https' } /** * UI display hints only (SEC-02) — never prove login. * Auth proof is UDS_SESSION / bridge bearer minted separately. */ function setFallbackHintCookies(req, res, extraCookies = []) { const fbMaxAge = 7 * 24 * 60 * 60 const secure = isHttpsRequest(req) const partsUi = [ 'UDS_FALLBACK_UI=administrator', `Max-Age=${fbMaxAge}`, 'Path=/', 'SameSite=Lax', ] if (secure) partsUi.push('Secure') const list = [partsUi.join('; '), ...extraCookies] res.setHeader('Set-Cookie', list) } async function handleFallbackLogin(req, res) { if (!_loginLimiter.allow(clientKey(req))) { audit({ action: 'fallback_login', decision: 'deny', reasonCode: 'rate_limited' }) return sendErr(res, req, 429, 'rate_limited') } let body try { body = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY }) } catch (err) { return sendErr(res, req, err.statusCode || 413, err.code || 'payload_too_large') } let parsed try { parsed = JSON.parse(body) } catch { parsed = {} } const { username, password } = parsed const ip = req.socket?.remoteAddress || 'unknown' if (!username || !password) { return sendErr(res, req, 400, 'username_password_required') } if (username !== 'administrator') { // 不泄露"administrator"是唯一用户名 audit({ action: 'fallback_login', decision: 'deny', reasonCode: 'invalid_credentials' }) return sendErr(res, req, 401, 'invalid_credentials') } const verifiedGeneration = _rolesStore.getFallbackCredVersion() let passwordOk try { passwordOk = await _rolesStore.verifyFallbackAsync(password, ip) } catch (err) { return sendErr(res, req, err.code === 'kdf_busy' ? 429 : 503, err.code || 'roles_persist_failed') } if (!passwordOk || verifiedGeneration !== _rolesStore.getFallbackCredVersion()) { audit({ action: 'fallback_login', decision: 'deny', reasonCode: 'invalid_credentials' }) return sendErr(res, req, 401, 'invalid_credentials') } // 登录成功:签发随机本地会话;fallback UI cookie 仅提示,不能单独重建登录 const empNo = 'administrator' const userContext = { empNo, username: 'Fallback Administrator', isAuthenticated: true, role: 'fallback_admin', authMode: 'fallback-password', fallbackCredVersion: verifiedGeneration, authenticatedAt: new Date().toISOString(), lastActiveAt: new Date().toISOString(), } const { bearer, bridge } = await mintLoginSession(empNo, userContext, 'fallback') await ensureUserWorkspace(empNo) setFallbackHintCookies(req, res) appendSessionCookie(req, res, bearer) audit({ action: 'fallback_login', decision: 'allow', actor: empNo }) sendOkMsg(res, req, 'fallback_login', null, { empNo, username: userContext.username, isAuthenticated: true, role: 'fallback_admin', }, { success: true, empNo, role: 'fallback_admin', bridge, }) } /** * Bind empNo+SSO token → session + bridge (and Set-Cookie when host supports it). * Used after QR login on Desktop where document.cookie / Set-Cookie are no-ops. */ async function handleBridgeBind(req, res) { let body try { body = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY }) } catch (err) { return sendErr(res, req, err.statusCode || 413, err.code || 'payload_too_large') } let parsed try { parsed = JSON.parse(body) } catch { parsed = {} } const empNo = String(parsed.empNo || parsed.account || '').trim() const token = String(parsed.token || parsed.authValue || '').trim() if (!empNo || !token) { return sendErr(res, req, 400, 'key_required') } if (!_authMiddleware?.verifyEmpNoAndToken) { return sendErr(res, req, 503, 'config_not_ready') } const profile = await _authMiddleware.verifyEmpNoAndToken(empNo, token) if (!profile) { return sendErr(res, req, 401, 'invalid_credentials') } const now = new Date().toISOString() const userContext = { empNo: profile.empNo || empNo, userId: profile.empNo || empNo, username: profile.username || empNo, displayName: profile.username || empNo, department: profile.department || '', organization: profile.organization || profile.department || '', email: profile.email || '', phone: profile.phone || '', token, isAuthenticated: true, authMode: 'token+profile', authenticatedAt: now, lastActiveAt: now, sessionCreatedAt: now, } const id = userContext.empNo let role try { role = await _authMiddleware.resolveRoleOnLogin(id) } catch (err) { return sendErr(res, req, 403, err?.code || 'bootstrap_required') } const { bearer, bridge } = await mintLoginSession(id, userContext, 'uds', token) await ensureUserWorkspace(id) // Local session cookie is the auth proof; portal cookies are upstream leftovers only. const secure = isHttpsRequest(req) const maxAge = Math.floor(INTERNAL.session.cookieMaxAge / 1000) const cookieParts = (name, value, httpOnly) => { const parts = [ `${name}=${encodeURIComponent(value)}`, `Max-Age=${maxAge}`, 'Path=/', 'SameSite=Lax', ] if (httpOnly) parts.push('HttpOnly') if (secure) parts.push('Secure') return parts.join('; ') } res.setHeader('Set-Cookie', [ buildSessionCookie(bearer, maxAge, { secure }), cookieParts(INTERNAL.cookieEmpNo, id, false), ]) sendOkMsg(res, req, 'fallback_login', null, { empNo: id, username: userContext.username, displayName: userContext.displayName, department: userContext.department, isAuthenticated: true, role, }, { success: true, empNo: id, role, bridge, }) } /** * Decrypt UDS_AUTH_LOCAL_ADMIN_BOX with operator passphrase → admin session. * Env ciphertext alone never grants login. */ async function handleLocalAdminUnlock(req, res) { if (!isLocalAdminBoxConfigured()) { return sendErr(res, req, 404, 'local_admin_not_configured') } const ip = req.socket?.remoteAddress || 'unknown' if (!allowUnlockAttempt(ip)) { audit({ action: 'local_admin_unlock', decision: 'deny', reasonCode: 'rate_limited' }) return sendErr(res, req, 429, 'rate_limited') } let body try { body = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY }) } catch (err) { return sendErr(res, req, err.statusCode || 413, err.code || 'payload_too_large') } let parsed try { parsed = JSON.parse(body) } catch { parsed = {} } const key = String(parsed.key || parsed.passphrase || parsed.password || '').trim() if (!key) { return sendErr(res, req, 400, 'key_required') } let opened try { opened = await openLocalAdminBoxAsync(readLocalAdminBox(), key) } catch (err) { return sendErr(res, req, err.code === 'kdf_busy' ? 429 : 400, err.code || 'decrypt_failed') } if (!opened.ok) { audit({ action: 'local_admin_unlock', decision: 'deny', reasonCode: 'decrypt_failed' }) return sendErr(res, req, 401, 'decrypt_failed') } const empNo = opened.empNo const userContext = buildLocalAdminUserContext() const { bearer, bridge } = await mintLoginSession(empNo, userContext, 'sealed_box') await ensureUserWorkspace(empNo) audit({ action: 'local_admin_unlock', decision: 'allow', actor: empNo }) const fakeRes = { headersSent: false, _cookies: [], getHeader(name) { if (String(name).toLowerCase() === 'set-cookie') return this._cookies return undefined }, setHeader(name, value) { if (String(name).toLowerCase() === 'set-cookie') { this._cookies = Array.isArray(value) ? value : [value] } }, } appendLocalAdminCookie(fakeRes, req) setFallbackHintCookies(req, res, fakeRes._cookies) appendSessionCookie(req, res, bearer) sendOkMsg(res, req, 'local_admin_unlock', null, { empNo, username: userContext.username || 'Local Administrator', isAuthenticated: true, role: 'fallback_admin', }, { success: true, empNo, role: 'fallback_admin', bridge, }) } /** Legacy install-root runtime path (pre-0.3.6); prefer profile data dir. */ const LEGACY_RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json') const LEGACY_PLUGIN_ROOT = resolve(__dirname, '..') let _runtimeConfigFile = LEGACY_RUNTIME_CONFIG_FILE function runtimeConfigPath(dataDir) { return dataDir ? resolve(dataDir, 'config.runtime.json') : LEGACY_RUNTIME_CONFIG_FILE } /** * F05: copy 0.3.5 install-root state into the profile data dir once. * Refuses silent empty bootstrap when legacy security state exists. */ async function migrateLegacyStateIfNeeded(dataDir, logger) { return migrateState(dataDir, LEGACY_PLUGIN_ROOT, logger) } function validateConfigPatch(partial) { const err = (code) => { const e = new Error(code) e.code = code return e } if (partial == null || typeof partial !== 'object' || Array.isArray(partial)) { throw err('invalid_config') } // Reject unknown security-sensitive fields being smuggled in. const allowed = new Set([ 'uacBaseUrl', 'userSearchUrl', 'loginSystemCode', 'originSystemCode', 'workspaceRoot', 'retainSkillCredentialsOnLogout', 'skillCredentialTtlSeconds', 'outboundAllowedHosts', 'initialAdminEmpNo', 'allowOpenRegistration', 'allowRawAgentToken', 'trustedHosts', ]) for (const k of Object.keys(partial)) { if (!allowed.has(k)) throw err('unknown_config_field') } const checkHttpsUrl = (v, code) => { if (v === undefined) return const s = String(v).trim() if (!s) throw err(code) let u try { u = new URL(s) } catch { throw err(code) } if (u.protocol !== 'https:') throw err(code) } checkHttpsUrl(partial.uacBaseUrl, 'invalid_uac_url') checkHttpsUrl(partial.userSearchUrl, 'invalid_user_search_url') if (partial.workspaceRoot !== undefined && String(partial.workspaceRoot).trim()) { const root = String(partial.workspaceRoot).trim() if (!isAbsolute(root)) throw err('invalid_workspace_root') } if (partial.skillCredentialTtlSeconds !== undefined) { const n = Number(partial.skillCredentialTtlSeconds) if (!Number.isFinite(n) || n < 60 || n > 30 * 24 * 60 * 60) { throw err('invalid_ttl') } } if (partial.outboundAllowedHosts !== undefined) { const raw = partial.outboundAllowedHosts const list = Array.isArray(raw) ? raw : String(raw).split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean) for (const h of list) { if (!/^[a-z0-9.-]+$/i.test(h) || h.includes('..')) throw err('invalid_outbound_host') } } } async function saveRuntimeConfig(partial) { if (!_currentConfig) { const err = new Error('config_not_ready') err.code = 'config_not_ready' throw err } validateConfigPatch(partial) const allowed = [ 'uacBaseUrl', 'userSearchUrl', 'loginSystemCode', 'originSystemCode', 'workspaceRoot', 'retainSkillCredentialsOnLogout', 'skillCredentialTtlSeconds', 'outboundAllowedHosts', 'initialAdminEmpNo', 'allowOpenRegistration', 'allowRawAgentToken', 'trustedHosts', ] const next = asMutableConfig(_currentConfig) for (const k of allowed) { if (partial[k] !== undefined) { if (k === 'retainSkillCredentialsOnLogout' || k === 'allowOpenRegistration' || k === 'allowRawAgentToken') { next[k] = partial[k] === true || partial[k] === 'true' } else if (k === 'skillCredentialTtlSeconds') { next[k] = Number(partial[k]) } else if (k === 'outboundAllowedHosts') { // Preserve [] / '' as deny-all; do not String([]) → "[]". next[k] = Array.isArray(partial[k]) ? partial[k].map(String) : String(partial[k]) } else { next[k] = typeof partial[k] === 'string' ? partial[k] : String(partial[k]) } } } // Persist first — only then swap memory (SEC-22). F05: write under profile data dir. await atomicWriteJson(_runtimeConfigFile, next, { mode: 0o600 }) _currentConfig = next } // 统一入口:单个 prefix 路由 handler,内部自行分发 async function handleAllRoutes(req, res) { try { const url = new URL(req.url, 'http://localhost') const pathname = url.pathname.replace(/\/+$/, '') || '/' const method = (req.method || 'GET').toUpperCase() // Desktop dsh-app:// → loopback Host: answer CORS preflight before auth. if (pathname.startsWith('/uds-auth') && applyDesktopCors(req, res) && method === 'OPTIONS') { res.statusCode = 204 res.end() return } // SEC-20 health: always answer readiness without auth. if (pathname === '/uds-auth/health' && method === 'GET') { const ready = _pluginReady && !_rolesStore?.loadFailed return sendJSON(res, ready ? 200 : 503, { ok: ready, ready, error: ready ? null : (_pluginReadyError || 'unready'), }) } if (!_pluginReady) { return sendJSON(res, 503, { ...apiError('plugin_unready', localeOf(req)), detail: _pluginReadyError || 'initializing', }) } // A09/SEC-19: Host trust on all methods (incl. GET qr-start); Origin/CT on mutating. const isMutating = !['GET', 'HEAD', 'OPTIONS'].includes(method) const trustedHostsFn = () => { const raw = _currentConfig?.trustedHosts if (raw == null || raw === '') return [] if (Array.isArray(raw)) return raw.map(String) return String(raw).split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean) } const originCheck = checkRequestOrigin(req, { trustedHosts: trustedHostsFn }) if (!originCheck.ok) { return sendJSON(res, 403, { ...apiError('origin_forbidden', localeOf(req)), reason: originCheck.reason }) } if (isMutating) { if (pathname.startsWith('/uds-auth/api/') || pathname === '/uds-auth/outbound') { const ct = requireJsonContentType(req) if (!ct.ok) { return sendJSON(res, 415, { ...apiError('content_type', localeOf(req)), reason: ct.reason }) } } } // SEC-01: generic static file routes removed (path traversal). // QR 生成 if (pathname === '/uds-auth/qr' && method === 'GET') { return await handleQRCode(req, res) } // QR 挑战(扫码登录入口) if (pathname === '/uds-auth/qr-start' && (method === 'GET' || method === 'POST')) { return await handleQrStart(req, res) } // QR 代理 if (pathname === '/uds-auth/qr-proxy' && method === 'POST') { return await handleQRProxy(req, res) } // verifyCode 计算 if (pathname === '/uds-auth/verify-code' && method === 'GET') { return await handleVerifyCode(req, res) } // 用户信息代理(T03: POST body/headers;GET 仅 headers,禁止 query token) if (pathname === '/uds-auth/user-info' && (method === 'GET' || method === 'POST')) { return await handleUserInfo(req, res) } // Skill / agent:本机凭证与出站代理(loopback-only) if (pathname === '/uds-auth/agent-credentials' && (method === 'GET' || method === 'POST')) { if (!_agentAuthHandlers) { res.statusCode = 503 res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify(apiError('skill_credentials_not_ready', localeOf(req)))) return } return await _agentAuthHandlers.handleAgentCredentials(req, res) } if (pathname === '/uds-auth/outbound' && method === 'POST') { if (!_agentAuthHandlers) { res.statusCode = 503 res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify(apiError('outbound_not_ready', localeOf(req)))) return } return await _agentAuthHandlers.handleOutbound(req, res) } // 客户端配置(RPC config.get 在 web 端走 HTTP) if (pathname === '/uds-auth/config.get' && (method === 'GET' || method === 'POST')) { return await handleConfigGet(req, res) } // 下面都是 /uds-auth/api/* — 走 handleRequest(含鉴权) if (pathname.startsWith('/uds-auth/api/')) { return await handleRequest(req, res) } // 未知路径 res.statusCode = 404 res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify({ ...apiError('not_found', localeOf(req)), path: pathname })) } catch (err) { console.error('[uds-auth] handleAllRoutes error:', err) res.statusCode = 500 res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify({ error: err.message || 'Internal error' })) } } function handleRequest(req, res) { const ctx2 = { req, res } _authMiddleware(ctx2, async () => { if (ctx2.empNo && ctx2.empNo !== 'administrator') { try { await ensureUserWorkspace(ctx2.empNo) } catch { /* ignore */ } } // bootstrap 已在 auth-middleware.resolveRole 中完成(勿用 !ctx2.role,getRole 恒有值) const url = new URL(req.url, 'http://localhost').pathname.replace(API_PREFIX, '') || '/' const method = req.method // 公开端点(不需要登录态也能访问) if (url === '/api/fallback/login' && method === 'POST') { await handleFallbackLogin(req, res) return } if (url === '/api/fallback/status' && method === 'GET') { return sendJSON(res, 200, { enabled: _rolesStore.isFallbackEnabled() }) } if (url === '/api/local-admin/status' && method === 'GET') { return sendJSON(res, 200, { enabled: isLocalAdminBoxConfigured(), env: LOCAL_ADMIN_BOX_ENV, }) } if (url === '/api/local-admin/unlock' && method === 'POST') { await handleLocalAdminUnlock(req, res) return } // QR / SSO → bridge (Desktop); also refreshes cookies when the host supports them. if (url === '/api/bridge/bind' && method === 'POST') { await handleBridgeBind(req, res) return } // 以下都需要登录态 if (!ctx2.empNo) { return sendErr(res, req, 401, 'not_logged_in', null, ctx2.userContext) } // 用户管理 (super_admin only) if (url === '/api/users' && method === 'GET') { await _apiHandlers.listUsers(ctx2); return } if (url === '/api/users/role' && method === 'POST') { await _apiHandlers.setUserRole(ctx2); return } if (url === '/api/users' && method === 'POST') { await _apiHandlers.addUser(ctx2); return } if (url === '/api/users/delete' && method === 'POST') { await _apiHandlers.removeUser(ctx2); return } // Fallback admin 管理 (super_admin only) if (url === '/api/fallback/password' && method === 'POST') { await _apiHandlers.setFallbackPassword(ctx2); return } if (url === '/api/fallback/clear' && method === 'POST') { await _apiHandlers.clearFallbackPassword(ctx2); return } if (url === '/api/me/view-all-sessions' && method === 'POST') { await _apiHandlers.setViewAllSessions(ctx2); return } // T01: short-TTL one-time WS handshake ticket (never put long-lived bearer in WS URL). if (url === '/api/ws-ticket' && method === 'POST') { const extracted = await extractSessionBearerAsync(req, { sessionBridge: _sessionBridge, isLiveBearer: async (b) => !!(await _sessionStore?.getByBearer(b)), }) const bearer = extracted?.bearer if (!bearer || !_sessionBridge) { return sendErr(res, req, 401, 'not_logged_in', null, ctx2.userContext) } try { const minted = _sessionBridge.mintWsTicket({ empNo: ctx2.empNo, bearer, ttlMs: 60_000, }) return sendJSON(res, 200, { ticket: minted.ticket, expiresAt: minted.expiresAt, expiresIn: minted.expiresIn, }) } catch (err) { return sendErr(res, req, 400, err.message || 'ws_ticket_failed', null, ctx2.userContext) } } // 配置端点 (admin / super_admin:canAccessSettings) if (url === '/api/config' && method === 'GET') { if (!requirePermission(ctx2, 'canAccessSettings')) { return sendErr(res, req, 403, 'forbidden_settings', null, ctx2.userContext) } sendJSON(res, 200, { config: _currentConfig }) return } if (url === '/api/config' && method === 'POST') { if (!requirePermission(ctx2, 'canAccessSettings')) { return sendErr(res, req, 403, 'forbidden_settings', null, ctx2.userContext) } let body try { body = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY }) } catch (err) { return sendErr(res, req, err.statusCode || 413, err.code || 'payload_too_large', null, ctx2.userContext) } let parsed try { parsed = JSON.parse(body) } catch { parsed = {} } try { await saveRuntimeConfig(parsed) audit({ action: 'config_save', decision: 'allow', actor: ctx2.empNo, meta: { keys: Object.keys(parsed || {}) }, }) sendOkMsg(res, req, 'config_saved', null, ctx2.userContext) } catch (err) { const code = err?.code || err?.message audit({ action: 'config_save', decision: 'deny', actor: ctx2.empNo, reasonCode: code, }) if (code === 'config_not_ready') { sendErr(res, req, 400, 'config_not_ready', null, ctx2.userContext) } else if (typeof code === 'string' && !code.includes(' ')) { sendErr(res, req, 400, code, null, ctx2.userContext) } else { sendJSON(res, 400, { error: 'request_failed', message: err.message }) } } return } // 基础端点 if (url === '/api/me' && method === 'GET') { if (ctx2.empNo) { // Never block login badge / ACL on workspaceRegistry (can wait up to 30s). // Serve cached mapping immediately; provision in background. try { ctx2.provisionedWorkspace = _userWorkspaces?.get?.(ctx2.empNo) || null } catch { ctx2.provisionedWorkspace = null } void ensureUserWorkspace(ctx2.empNo).catch(() => {}) } await _apiHandlers.getCurrentUser(ctx2); return } if (url === '/api/logout' && method === 'POST') { await _apiHandlers.logout(ctx2); return } // SEC-07: unknown API → 404 (do not echo userContext/token) sendJSON(res, 404, { error: 'not_found' }) }).catch(err => { sendJSON(res, 500, { error: err.message }) }) } function installSettingsSection(ctx, entry, hooks) { const { schema: ConfigSchema, settings: DshSettings } = loadConfigSchemaAndSettings(ctx) if (!ConfigSchema || !DshSettings) { ctx.logger?.warn?.('[uds-auth] cannot register settings panel — schemastery/dsh-settings unavailable') return } const hooksArg = { setSource: (current) => { // Validation + assign happen inside hooks.setSource (F04). hooks.setSource?.(current) }, onChange: () => { hooks.onChange?.() } } // 和 netxops 完全对齐的注册方式 const legacy = DshSettings.installSettingsSection if (typeof legacy === 'function') { ctx.logger?.info?.('[uds-auth] registering settings panel (legacy API)') legacy(ctx, UDS_AUTH_SETTINGS_NAMESPACE, ConfigSchema, entry, hooksArg) return } ctx.inject(['settings'], (settingsCtx) => { const settings = settingsCtx.settings if (typeof settings?.installSection === 'function') { ctx.logger?.info?.('[uds-auth] registering settings panel (new API)') settings.installSection(ctx, UDS_AUTH_SETTINGS_NAMESPACE, ConfigSchema, entry, hooksArg) return } // DSH ≥0.1.7 / 0.2.0 — Config projection via describe() (no installSection). const describeRows = (describe) => { if (typeof describe !== 'function') return [] try { const raw = describe() if (Array.isArray(raw)) return raw if (raw && typeof raw === 'object' && Array.isArray(raw.namespaces)) return raw.namespaces } catch { /* settling */ } return [] } const readLive = () => { const row = describeRows(settings?.describe).find((item) => item.ns === UDS_AUTH_SETTINGS_NAMESPACE) if (row?.value !== null && typeof row?.value === 'object' && !Array.isArray(row.value)) { return asMutableConfig({ ...entry, ...row.value }) } return asMutableConfig(entry) } hooksArg.setSource(readLive) hooksArg.onChange() let last = JSON.stringify(readLive()) const timer = setInterval(() => { const next = readLive() const fingerprint = JSON.stringify(next) if (fingerprint === last) return last = fingerprint hooksArg.onChange() }, 2_000) settingsCtx.effect(() => () => clearInterval(timer), 'uds-auth: settings describe poll') ctx.logger?.info?.('[uds-auth] following settings via describe() (DSH ≥0.1.7 path)') }) } let _workspaceRegistry = null let _workspaceRegistryWaiters = [] function notifyWorkspaceRegistryReady(registry) { _workspaceRegistry = registry || null const waiters = _workspaceRegistryWaiters.splice(0) for (const w of waiters) { try { clearTimeout(w.timer) } catch { /* ignore */ } try { w.resolve(_workspaceRegistry) } catch { /* ignore */ } } } /** Wait until workspaceRegistry inject fires (or timeout). */ function whenWorkspaceRegistry(timeoutMs = 2500) { if (_workspaceRegistry) return Promise.resolve(_workspaceRegistry) return new Promise((resolve) => { const entry = { resolve, timer: setTimeout(() => { const i = _workspaceRegistryWaiters.indexOf(entry) if (i >= 0) _workspaceRegistryWaiters.splice(i, 1) resolve(_workspaceRegistry || null) }, timeoutMs), } _workspaceRegistryWaiters.push(entry) }) } async function ensureUserWorkspace(empNo) { if (!_userWorkspaces) return null try { let registry = _workspaceRegistry if (!registry) { registry = await whenWorkspaceRegistry(2500) } const root = _currentConfig?.workspaceRoot if (!registry) { console.warn( '[uds-auth] workspaceRegistry still unavailable after wait; mkdir only under', root || '(default $DSH_HOME/user-workspaces)', ) } return await _userWorkspaces.ensureUserWorkspace( { workspaceRegistryHandle: registry }, root, empNo, ) } catch (err) { console.warn('[uds-auth] ensureUserWorkspace failed:', err.message) return null } } async function rebindPendingUserWorkspaces() { if (!_userWorkspaces || !_workspaceRegistry) return try { const users = _userWorkspaces._map if (!users || typeof users.keys !== 'function') return for (const empNo of users.keys()) { const row = users.get(empNo) if (row && row.workspaceId) continue try { await ensureUserWorkspace(empNo) } catch { /* ignore */ } } } catch (err) { console.warn('[uds-auth] rebindPendingUserWorkspaces failed:', err.message) } } export async function apply(ctx, config = {}) { let source = () => mergeConfig(config) _currentConfig = asMutableConfig(source()) _logger = ctx.logger || console ctx.logger?.info?.('[uds-auth] Loading...') installSettingsSection(ctx, source(), { setSource: (current) => { const next = asMutableConfig(typeof current === 'function' ? current() : current) // F04: same HTTPS/field validation as config API / startup. try { validateConfigPatch({ uacBaseUrl: next.uacBaseUrl, userSearchUrl: next.userSearchUrl, outboundAllowedHosts: next.outboundAllowedHosts, skillCredentialTtlSeconds: next.skillCredentialTtlSeconds, workspaceRoot: next.workspaceRoot || undefined, trustedHosts: next.trustedHosts, }) } catch (err) { ctx.logger?.warn?.('[uds-auth] settings rejected: %s', err.code || err.message) throw err } source = typeof current === 'function' ? current : () => next _currentConfig = next }, onChange: () => { const next = asMutableConfig(source()) try { validateConfigPatch({ uacBaseUrl: next.uacBaseUrl, userSearchUrl: next.userSearchUrl, outboundAllowedHosts: next.outboundAllowedHosts, skillCredentialTtlSeconds: next.skillCredentialTtlSeconds, workspaceRoot: next.workspaceRoot || undefined, trustedHosts: next.trustedHosts, }) } catch (err) { ctx.logger?.warn?.('[uds-auth] settings onChange rejected: %s', err.code || err.message) throw err } _currentConfig = next ctx.logger?.info?.('[uds-auth] settings updated') } }) // Do NOT rpc.handle('/uds-auth') — same path as webServer prefix → duplicate route / 415. // Client uses HTTP /uds-auth/config.get instead. await initServices(ctx, source()) // Register prefix once; wrap in effect so reload disposes cleanly. ctx.inject(['webServer'], (wctx) => { wctx.effect(() => registerWebRoute(wctx), 'uds-auth: web route') }) ctx.logger?.info?.('[uds-auth] Host ready') } async function initServices(ctx, config) { _pluginReady = false _pluginReadyError = 'initializing' try { const existingGateway = tryGet(ctx, 'typertGateway') if (existingGateway) assertGatewayContract(existingGateway) const { createSessionStore } = await import('./session/factory.js') const { createAuthMiddleware } = await import('./middleware/auth-middleware.js') const { createApiHandlers } = await import('./api.js') const { RolesStore } = await import('./roles.js') const { SessionAclStore } = await import('./session-acl.js') const { UserWorkspaceStore } = await import('./workspace-provision.js') const { patchWebServerWithIdentity, resolveIdentityFromRequest, resolveIdentityFromRequestSync, installDshAcl } = await import('./dsh-acl.js') const { SkillCredentialCache } = await import('./skill-credentials.js') const { createAgentAuthHandlers } = await import('./agent-auth.js') _pluginCtx = ctx _currentConfig = asMutableConfig(config) // A11/F05: resolve profile data dir first, migrate legacy install-root state, then load runtime. const pluginDataDir = resolvePluginDataDir(_currentConfig) await mkdir(pluginDataDir, { recursive: true, mode: 0o700 }) await protectProfileDirectory(pluginDataDir) ctx.logger?.info?.('[uds-auth] profile data dir: %s', pluginDataDir) await migrateLegacyStateIfNeeded(pluginDataDir, ctx.logger || console) await protectProfileDirectory(pluginDataDir) _runtimeConfigFile = runtimeConfigPath(pluginDataDir) try { const raw = await readFile(_runtimeConfigFile, 'utf-8') const rt = JSON.parse(raw) if (rt && typeof rt === 'object') { _currentConfig = { ..._currentConfig, ...rt } } } catch { // Fall back to legacy install-root runtime once if profile copy missing. try { const raw = await readFile(LEGACY_RUNTIME_CONFIG_FILE, 'utf-8') const rt = JSON.parse(raw) if (rt && typeof rt === 'object') { _currentConfig = { ..._currentConfig, ...rt } } } catch { /* runtime config optional */ } } // A09: same HTTPS + field validation as config POST / settings. try { validateConfigPatch({ uacBaseUrl: _currentConfig.uacBaseUrl, userSearchUrl: _currentConfig.userSearchUrl, outboundAllowedHosts: _currentConfig.outboundAllowedHosts, skillCredentialTtlSeconds: _currentConfig.skillCredentialTtlSeconds, workspaceRoot: _currentConfig.workspaceRoot || undefined, trustedHosts: _currentConfig.trustedHosts, }) } catch (err) { throw new Error(`invalid_startup_config:${err.code || err.message}`) } // Normalize skill-related config after merge if (_currentConfig.retainSkillCredentialsOnLogout === undefined) { _currentConfig.retainSkillCredentialsOnLogout = CONFIG_DEFAULTS.retainSkillCredentialsOnLogout } else { _currentConfig.retainSkillCredentialsOnLogout = _currentConfig.retainSkillCredentialsOnLogout === true || _currentConfig.retainSkillCredentialsOnLogout === 'true' } if (!_currentConfig.skillCredentialTtlSeconds) { _currentConfig.skillCredentialTtlSeconds = CONFIG_DEFAULTS.skillCredentialTtlSeconds } // R05: only default when field absent — empty string / [] means deny-all. if (_currentConfig.outboundAllowedHosts === undefined || _currentConfig.outboundAllowedHosts === null) { _currentConfig.outboundAllowedHosts = CONFIG_DEFAULTS.outboundAllowedHosts } _sessionStore = await createSessionStore(INTERNAL.session, { file: resolve(pluginDataDir, 'sessions.json'), dataDir: pluginDataDir, logger: ctx.logger || console, }) const ttlMs = Math.max(60, Number(_currentConfig.skillCredentialTtlSeconds) || CONFIG_DEFAULTS.skillCredentialTtlSeconds) * 1000 _skillCredentials = new SkillCredentialCache({ file: resolve(pluginDataDir, 'skill-credentials.json'), dataDir: pluginDataDir, ttlMs, logger: ctx.logger || console, }) await _skillCredentials.init() const rolesFile = resolve(pluginDataDir, 'roles.json') _rolesStore = new RolesStore({ rolesFile }) await _rolesStore.init() if (_rolesStore.loadFailed) { throw new Error('roles_store_corrupt') } logLocalAdminStatus(ctx.logger || console) _sessionBridge = new SessionBridgeStore({ file: resolve(pluginDataDir, 'session-bridge.json'), ttlMs: INTERNAL.session.cookieMaxAge, }) await _sessionBridge.init() ctx.logger?.info?.('[uds-auth] session bridge ready (cookie + header dual path)') _sessionAcl = new SessionAclStore({ ownersFile: resolve(pluginDataDir, 'session-owners.json') }) await _sessionAcl.init() if (_sessionAcl.loadFailed) { throw new Error('session_owners_corrupt') } _userWorkspaces = new UserWorkspaceStore({ mapFile: resolve(pluginDataDir, 'user-workspaces.json') }) await _userWorkspaces.init() const { TaskCapabilityStore } = await import('./task-capability.js') _taskCapabilities = new TaskCapabilityStore({ defaultTtlSeconds: 3600 }) const rememberSkillCreds = (empNo, token) => { try { _skillCredentials?.set(empNo, token) } catch { /* ignore */ } } _authMiddleware = createAuthMiddleware({ get userSearchUrl() { return _currentConfig?.userSearchUrl }, getInitialAdminEmpNo: () => _currentConfig?.initialAdminEmpNo || process.env.UDS_AUTH_INITIAL_ADMIN || '', getAllowOpenRegistration: () => _currentConfig?.allowOpenRegistration !== false, udsAuth: { get baseUrl() { return _currentConfig?.uacBaseUrl }, get systemCode() { return _currentConfig?.loginSystemCode }, get userSearchUrl() { return _currentConfig?.userSearchUrl }, empNoHeader: INTERNAL.empNoHeader, authValueHeader: INTERNAL.authValueHeader, }, session: INTERNAL.session, }, _sessionStore, _rolesStore, { onSkillCredentials: rememberSkillCreds, sessionBridge: _sessionBridge, }) _apiHandlers = createApiHandlers( { session: INTERNAL.session }, _sessionStore, _rolesStore, { skillCredentials: _skillCredentials, retainSkillCredentialsOnLogout: () => _currentConfig?.retainSkillCredentialsOnLogout !== false, sessionBridge: _sessionBridge, revokeTaskCapabilitiesForEmpNo: (empNo) => _taskCapabilities?.revokeEmpNo(empNo) || 0, }, ) function parseOutboundHosts() { const raw = _currentConfig?.outboundAllowedHosts // R05: undefined → packaged defaults; '' / [] → deny all (do not revive defaults). if (raw === undefined || raw === null) { return String(CONFIG_DEFAULTS.outboundAllowedHosts) .split(/[,;\s]+/) .map((s) => s.trim()) .filter(Boolean) } if (Array.isArray(raw)) return raw.map(String).map((s) => s.trim()).filter(Boolean) const s = String(raw).trim() if (!s) return [] return s.split(/[,;\s]+/).map((x) => x.trim()).filter(Boolean) } async function resolveCredentialsForEmpNo(empNo) { if (!empNo) return null const row = _skillCredentials?.get(empNo) if (!row?.token) return null return { empNo: row.empNo, token: row.token, updatedAt: row.updatedAt } } async function resolveCredentialsForSession(sessionId) { if (!sessionId) return null const empNo = _sessionAcl?.getOwner(sessionId) || null if (!empNo) return null return resolveCredentialsForEmpNo(empNo) } _agentAuthHandlers = createAgentAuthHandlers({ resolveCredentialsForSession, getSessionOwner: (sessionId) => _sessionAcl?.getOwner(sessionId) || null, resolveCallerSession: async (req) => { const extracted = await extractSessionBearerAsync(req, { sessionBridge: _sessionBridge, isLiveBearer: async (b) => !!(await _sessionStore?.getByBearer(b)), }) if (!extracted?.bearer) return null const session = await _sessionStore.getByBearer(extracted.bearer) if (!session?.empNo) return null return { empNo: session.empNo, sessionId: session.sessionId } }, allowRawTokenResponse: () => _currentConfig?.allowRawAgentToken === true, empNoHeader: INTERNAL.empNoHeader, authValueHeader: INTERNAL.authValueHeader, outboundHosts: () => parseOutboundHosts(), sessionBridge: _sessionBridge, taskCapabilities: _taskCapabilities, isAccountActive: (empNo) => !(_rolesStore?.isDisabled?.(empNo)), }) const identityDeps = { sessionStore: _sessionStore, rolesStore: _rolesStore, sessionBridge: _sessionBridge, } const resolveIdentity = (req) => resolveIdentityFromRequest(req, identityDeps) const resolveIdentitySync = (req) => resolveIdentityFromRequestSync(req, identityDeps) const patchServer = (server) => { patchWebServerWithIdentity(server, resolveIdentity, resolveIdentitySync, (req, upgrade) => { const gateway = tryGet(ctx, 'typertGateway') const pathname = new URL(req.url || '/', 'http://uds.local').pathname if (pathname.startsWith('/api/') && gateway && !gateway.__udsGatewayPolicy?.installed) { return { ok: false, reason: 'gateway_acl_not_ready' } } return checkRequestOrigin(req, { upgrade, trustedHosts: () => { const raw = _currentConfig?.trustedHosts return Array.isArray(raw) ? raw : String(raw || '').split(/[,;\s]+/).filter(Boolean) } }) }) } const present = tryGet(ctx, 'webServer') if (present) patchServer(present) ctx.inject(['webServer'], (wctx) => { patchServer(wctx.webServer) }) ctx.inject(['workspaceRegistry'], (wctx) => { notifyWorkspaceRegistryReady(wctx.workspaceRegistry) ctx.logger?.info?.('[uds-auth] workspaceRegistry ready') void rebindPendingUserWorkspaces() }) installDshAcl(ctx, { sessionAcl: _sessionAcl, userWorkspaces: _userWorkspaces, getWorkspaceRoot: () => _currentConfig?.workspaceRoot, getRolesStore: () => _rolesStore, getSessionStore: () => _sessionStore, ensureUserWorkspace, getWorkspaceRegistry: () => _workspaceRegistry, }) const { resolveWorkspaceRoot } = await import('./workspace-provision.js') const { join } = await import('node:path') function displayNameOf(identity) { const uc = identity?.userContext || {} return String( uc.displayName || uc.userName || uc.username || uc.name || identity?.empNo || '', ).trim() || (identity?.empNo || '') } const udsAuth = { runAsHost, /** * R06: mint a short-lived task capability for skill/cron (Host-trusted callers). * Inject as env UDS_TASK_CAPABILITY or header X-UDS-Task-Capability. */ mintTaskCapability(input) { if (!_taskCapabilities) throw new Error('task_capabilities_unavailable') const sid = String(input?.dshSessionId || '').trim() const owner = sid ? _sessionAcl?.getOwner(sid) : null if (!owner || owner !== input?.empNo || _rolesStore.isDisabled(owner)) { throw new Error('capability_session_owner_required') } return _taskCapabilities.mint({ ...input, empNo: owner, dshSessionId: sid, audience: 'uds-auth-agent', scopes: input?.scopes || ['outbound'], mintedBy: 'host:udsAuth' }) }, revokeTaskCapabilitiesForEmpNo(empNo) { return _taskCapabilities?.revokeEmpNo(empNo) || 0 }, async resolveRequestIdentity(req) { const identity = await resolveIdentity(req) if (!identity?.empNo) return null return { empNo: identity.empNo, role: identity.role, permissions: identity.permissions || {}, displayName: displayNameOf(identity), kind: identity.kind || 'uds', userContext: identity.userContext || null, } }, resolveRequestIdentitySync(req) { const identity = resolveIdentitySync(req) if (!identity?.empNo) return null return { empNo: identity.empNo, role: identity.role, permissions: identity.permissions || {}, displayName: displayNameOf(identity), kind: identity.kind || 'uds', userContext: identity.userContext || null, } }, canViewAllJobs(identity) { const empNo = identity?.empNo || identity?.userContext?.empNo if (empNo && _rolesStore?.resolvePermissions) { return !!_rolesStore.resolvePermissions(empNo, identity?.role).canViewAllSessions } return !!identity?.permissions?.canViewAllSessions }, getRole(empNo) { const id = String(empNo || '').trim() if (!id || id.startsWith('__')) return 'user' return _rolesStore?.getRole?.(id) || 'user' }, /** * Resolve role + permissions for an empNo (cron tools / fire-time cwd). * Does not touch request cookies — pure lookup from roles store. */ resolveIdentityForEmpNo(empNo) { const id = String(empNo || '').trim() if (!id || id.startsWith('__')) return null const role = _rolesStore?.getRole?.(id) || 'user' const permissions = _rolesStore?.resolvePermissions?.(id, role) || computePermissions(role) const workspacePath = (() => { try { const row = _userWorkspaces?.get(id) if (row?.path) return row.path return join(resolveWorkspaceRoot(_currentConfig?.workspaceRoot), id) } catch { return null } })() return { empNo: id, role, permissions, displayName: id, workspacePath, } }, getProvisionedWorkspacePath(empNo) { if (!empNo) return null const row = _userWorkspaces?.get(String(empNo)) if (row?.path) return row.path return join(resolveWorkspaceRoot(_currentConfig?.workspaceRoot), String(empNo)) }, async ensureProvisionedWorkspace(empNo) { return ensureUserWorkspace(empNo) }, isUserPath(empNo, candidatePath) { if (!_userWorkspaces) return false return _userWorkspaces.isUserPath( empNo, candidatePath, _currentConfig?.workspaceRoot, ) }, stampSessionOwner(sessionId, empNo) { if (!_sessionAcl || !sessionId || !empNo || String(empNo).startsWith('__') || _rolesStore.isDisabled(empNo)) return false return _sessionAcl.setOwner(sessionId, empNo) }, getSessionOwner(sessionId) { return _sessionAcl?.getOwner(sessionId) || null }, getGatewayInventory() { return runAsHost(() => { const gateway = tryGet(ctx, 'typertGateway') if (!gateway) return [] const endpoints = new Set(gateway.collectSrcClaims?.() || []) for (const row of gateway.ctx?.get?.('typert')?.local?.list?.() || []) { endpoints.add(`${row.namespace}/${row.method}`) } const rows = [...endpoints].sort().map(endpoint => { const [namespace, method] = endpoint.split('/') const descriptor = gateway.resolveDescriptor(namespace, method, endpoint) return { endpoint, service: descriptor.service, policy: gatewayPolicy(descriptor), mode: descriptor.mode || 'unary', invocation: descriptor.invocation, parameters: descriptor.parameters.map(p => ({ name: p.name, wire: p.wire, source: p.source })) } }) return [...rows, { endpoint: '$events', mode: 'stream', policy: 'authenticated-owner-filtered-events' }, { endpoint: '$events/result', mode: 'unary', policy: 'login-session-and-agent-bound-result' }] }) }, resolveCredentialsForSession, resolveCredentialsForEmpNo, rememberSkillCredentials(empNo, token) { rememberSkillCreds(empNo, token) }, } ctx.provide('udsAuth', udsAuth) const { createTaskEnvironment } = await import('./task-environment.js') ctx.inject(['shellEnv', 'webServer'], (sctx) => { const env = createTaskEnvironment({ getOwner: sid => _sessionAcl?.getOwner(sid), rolesStore: _rolesStore, capabilities: _taskCapabilities, getWebServer: () => tryGet(sctx, 'webServer') }) sctx.shellEnv.register(env.contributor) sctx.on('tools/result', (execution, result) => { env.finish(execution, result, tryGet(sctx, 'jobs')) }) sctx.effect(() => () => env.dispose(), 'uds-auth: task capabilities') }) _pluginReady = true _pluginReadyError = null ctx.logger?.info?.('[uds-auth] Initialized (ACL + workspaces + skill credentials + udsAuth service)') } catch (err) { _pluginReady = false _pluginReadyError = String(err?.message || err) ctx.logger?.error?.('[uds-auth] Init failed: ' + _pluginReadyError) // SEC-20: do not silently report Host ready with open routes. throw err } }