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

1965 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
}
}