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

337 lines
12 KiB
JavaScript

import { ROLES, ROLE_LABELS } from './roles.js'
import { requirePermission } from './middleware/auth-middleware.js'
import { apiError, apiOk, resolveLocale, roleLabel } from './i18n.js'
import { clearBrowserIdentity } from './identity-cache.js'
import { SESSION_COOKIE, clearSessionCookie } from './session/request-auth.js'
import { extractSessionBearerAsync } from './session/request-auth.js'
import { readBodyLimited, DEFAULT_MAX_BODY } from './utils/read-body.js'
import { audit } from './utils/audit-log.js'
import { identityFromSessionRecord } from './dsh-acl.js'
/**
* API handlers — permission guards + localized messages via stable error codes.
*/
/**
* @param {object} config
* @param {import('./session/store.js').SessionStore} sessionStore
* @param {*} rolesStore
* @param {{ skillCredentials?: { delete: (empNo: string) => void }, retainSkillCredentialsOnLogout?: () => boolean }} [extra]
*/
export function createApiHandlers(config, sessionStore, rolesStore, extra = {}) {
// Read again inside the RolesStore mutation queue, after a slow body or a
// concurrent demotion/logout; the role captured at request admission is stale.
const liveActorRole = ctx => () => {
if (rolesStore.isDisabled?.(ctx.empNo)) return ROLES.USER
const sid = ctx.sessionId || ctx.userContext?._sessionId
if (sid && typeof sessionStore.getBySessionIdSync === 'function') {
const row = sessionStore.getBySessionIdSync(sid)
return identityFromSessionRecord(row, rolesStore)?.role || ROLES.USER
}
return rolesStore.getRole?.(ctx.empNo) || ctx.role
}
async function sendRes(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 locale(ctx) {
return resolveLocale(ctx.req, ctx.userContext)
}
function fail(ctx, code, status, vars) {
return sendRes(ctx.res, status, apiError(code, locale(ctx), vars))
}
function ok(ctx, code, vars, extraFields = {}) {
return sendRes(ctx.res, 200, { ...apiOk(code, locale(ctx), vars), ...extraFields })
}
function mapThrown(ctx, err, status = 400) {
const code = err?.code || err?.message
if (code && typeof code === 'string' && !code.includes(' ') && !/[\u4e00-\u9fff]/.test(code)) {
return fail(ctx, code, status)
}
return sendRes(ctx.res, status, {
error: 'request_failed',
message: err?.message || String(err),
})
}
async function logout(ctx) {
const empNo = ctx.empNo
const sessionId = ctx.sessionId || ctx.userContext?._sessionId
// Prefer revoke current device; fall back to all devices for legacy callers.
if (sessionId && typeof sessionStore.revokeSession === 'function') {
await sessionStore.revokeSession(sessionId)
} else if (empNo) {
await sessionStore.delete(empNo)
}
try {
const extracted = await extractSessionBearerAsync(ctx.req, {
sessionBridge: extra.sessionBridge,
isLiveBearer: async (bearer) => !!(await sessionStore.getByBearer?.(bearer)),
})
if (extracted?.bearer) {
extra.sessionBridge?.revokeToken?.(extracted.bearer)
if (typeof sessionStore.revokeBearer === 'function') {
await sessionStore.revokeBearer(extracted.bearer)
}
} else if (empNo) {
extra.sessionBridge?.revokeEmpNo?.(empNo)
}
} catch { /* ignore */ }
try { clearBrowserIdentity() } catch { /* ignore */ }
const retain = extra.retainSkillCredentialsOnLogout
? extra.retainSkillCredentialsOnLogout() !== false
: true
if (empNo && !retain) {
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
}
// Task capabilities outlive UI logout only if Host minted them for cron;
// revoke current-user caps when logout is full-account style (no retain path).
if (empNo && typeof extra.revokeTaskCapabilitiesForEmpNo === 'function' && !retain) {
try { extra.revokeTaskCapabilitiesForEmpNo(empNo) } catch { /* ignore */ }
}
const clear = [
clearSessionCookie({ secure: false }),
clearSessionCookie({ secure: true }),
]
for (const name of [
'UDS_FALLBACK_USER',
'UDS_FALLBACK_UI',
'UDS_LOCAL_ADMIN',
'PORTALSSOUser',
'PORTALSSOCookie',
'ZTEDPGSSOUser',
'ZTEDPGSSOCookie',
]) {
const httpOnly = name === 'UDS_FALLBACK_USER' || name === 'UDS_LOCAL_ADMIN' || name === SESSION_COOKIE
const base = httpOnly
? (name + '=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax')
: (name + '=; Max-Age=0; Path=/; SameSite=Lax')
clear.push(base)
clear.push(base + '; Secure')
}
ctx.res.setHeader('Set-Cookie', clear)
ctx.res.setHeader('Cache-Control', 'no-store')
audit({
action: 'logout',
decision: 'allow',
actor: empNo || null,
resource: sessionId || null,
})
await ok(ctx, 'logged_out')
}
async function getCurrentUser(ctx) {
const ws = ctx.provisionedWorkspace || null
ctx.res.setHeader('Cache-Control', 'no-store')
// SEC-07: field whitelist — never return upstream token / authValue
const uc = ctx.userContext
const safe = uc ? {
empNo: ctx.empNo,
userId: uc.userId || ctx.empNo,
username: uc.username || null,
displayName: uc.displayName || uc.username || null,
department: uc.department || null,
organization: uc.organization || null,
lang: uc.lang || null,
isAuthenticated: true,
authMode: uc.authMode || null,
role: ctx.role,
permissions: ctx.permissions,
workspaceId: ws?.workspaceId || null,
workspacePath: ws?.path || null,
authenticatedAt: uc.authenticatedAt || null,
lastActiveAt: uc.lastActiveAt || null,
} : null
await sendRes(ctx.res, 200, {
data: safe,
authenticated: !!safe,
})
}
async function listUsers(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return fail(ctx, 'forbidden_list_users', 403)
}
const url = new URL(ctx.req.url, 'http://localhost')
const page = url.searchParams.get('page')
const pageSize = url.searchParams.get('pageSize')
const q = url.searchParams.get('q') || ''
const result = rolesStore.listPage({ page, pageSize, q })
const loc = locale(ctx)
if (Array.isArray(result.users)) {
result.users = result.users.map((u) => ({
...u,
roleLabel: roleLabel(u.role, loc) || ROLE_LABELS[u.role] || u.role,
}))
}
await sendRes(ctx.res, 200, result)
}
async function setUserRole(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return fail(ctx, 'forbidden_set_role', 403)
}
const body = await readBody(ctx.req)
const { empNo, role } = body || {}
if (!empNo || !role || !Object.values(ROLES).includes(role)) {
return fail(ctx, 'invalid_role_params', 400)
}
try {
await rolesStore.setRole(empNo, role, liveActorRole(ctx))
audit({
action: 'set_role',
decision: 'allow',
actor: ctx.empNo,
target: empNo,
meta: { role },
})
await ok(ctx, 'role_updated', { empNo, role: roleLabel(role, locale(ctx)) })
} catch (err) {
await mapThrown(ctx, err)
}
}
async function addUser(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return fail(ctx, 'forbidden_add_user', 403)
}
const body = await readBody(ctx.req)
const { empNo, role } = body || {}
if (!empNo) {
return fail(ctx, 'emp_no_required', 400)
}
const targetRole = role && Object.values(ROLES).includes(role) ? role : ROLES.USER
try {
await rolesStore.ensureUser(empNo, liveActorRole(ctx))
if (role) await rolesStore.setRole(empNo, role, liveActorRole(ctx))
await ok(ctx, 'user_added', { empNo, role: roleLabel(targetRole, locale(ctx)) })
} catch (err) {
await mapThrown(ctx, err)
}
}
async function removeUser(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return fail(ctx, 'forbidden_remove_user', 403)
}
const body = await readBody(ctx.req)
const { empNo } = body || {}
if (!empNo) {
return fail(ctx, 'emp_no_required', 400)
}
try {
await rolesStore.removeUser(empNo, liveActorRole(ctx))
await sessionStore.delete(empNo)
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
// A06: revoke task capabilities when account is disabled/removed.
try { extra.revokeTaskCapabilitiesForEmpNo?.(empNo) } catch { /* ignore */ }
await ok(ctx, 'user_removed', { empNo })
} catch (err) {
await mapThrown(ctx, err)
}
}
function isPasswordModeSession(row) {
const mode = String(row?.userData?.authMode || row?.authMode || row?.kind || '')
if (mode.includes('local-admin') || mode === 'sealed_box' || mode === 'local_admin') {
return false
}
return mode.includes('fallback') || mode === 'fallback' || row?.kind === 'fallback'
}
async function revokePasswordFallbackSessions() {
// D04/F02: revoke password-mode only — keep independent sealed-box sessions/bridges.
const empNo = 'administrator'
try {
if (typeof sessionStore.revokeEmpNoMatching === 'function') {
await sessionStore.revokeEmpNoMatching(empNo, isPasswordModeSession)
} else if (typeof sessionStore.revokeEmpNo === 'function') {
// Stores without matching API: do not wipe sealed-box with a blanket delete.
}
} catch { /* ignore */ }
try {
extra.sessionBridge?.revokeEmpNoKinds?.(empNo, [
'fallback', 'fallback_password', 'fallback-password', 'fallback-login',
])
} catch { /* ignore */ }
}
async function setFallbackPassword(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return fail(ctx, 'forbidden_set_fallback', 403)
}
const body = await readBody(ctx.req)
const { password } = body || {}
try {
await rolesStore.setFallbackPassword(password, liveActorRole(ctx))
await revokePasswordFallbackSessions()
await ok(ctx, 'fallback_password_set')
} catch (err) {
await mapThrown(ctx, err)
}
}
async function clearFallbackPassword(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return fail(ctx, 'forbidden_clear_fallback', 403)
}
try {
await rolesStore.clearFallbackPassword(liveActorRole(ctx))
await revokePasswordFallbackSessions()
await ok(ctx, 'fallback_password_cleared')
} catch (err) {
await mapThrown(ctx, err)
}
}
async function fallbackStatus(ctx) {
await sendRes(ctx.res, 200, {
enabled: rolesStore.isFallbackEnabled(),
})
}
async function setViewAllSessions(ctx) {
if (!requirePermission(ctx, 'canToggleViewAllSessions')) {
return fail(ctx, 'forbidden_view_all_sessions', 403)
}
const body = await readBody(ctx.req)
const enabled = !!(body && body.enabled)
try {
await rolesStore.setViewAllSessionsDurable(ctx.empNo, enabled)
ctx.permissions = rolesStore.resolvePermissions(ctx.empNo, ctx.role)
await ok(ctx, enabled ? 'view_all_sessions_on' : 'view_all_sessions_off', null, {
permissions: ctx.permissions,
viewAllSessions: enabled,
})
} catch (err) {
await mapThrown(ctx, err, 403)
}
}
return {
logout,
getCurrentUser,
listUsers,
setUserRole,
addUser,
removeUser,
setFallbackPassword,
clearFallbackPassword,
fallbackStatus,
setViewAllSessions,
}
}
async function readBody(req) {
try {
const raw = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY })
if (!raw) return null
return JSON.parse(raw)
} catch {
return null
}
}