diff --git a/uds-auth/.gitignore b/uds-auth/.gitignore index ec6f9b44..c654a6be 100644 --- a/uds-auth/.gitignore +++ b/uds-auth/.gitignore @@ -15,3 +15,7 @@ skill-credentials.json scripts/find-dsh-process.ps1 scripts/inspect-session-sample.cjs scripts/merge-sessions-into-workspace.ps1 + +session-bridge.json +roles.json + diff --git a/uds-auth/lib/api.js b/uds-auth/lib/api.js index abbda807..0a75bb54 100644 --- a/uds-auth/lib/api.js +++ b/uds-auth/lib/api.js @@ -1,6 +1,7 @@ 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' /** * API handlers — permission guards + localized messages via stable error codes. @@ -44,6 +45,8 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {}) async function logout(ctx) { const empNo = ctx.empNo if (empNo) await sessionStore.delete(empNo) + try { extra.sessionBridge?.revokeEmpNo?.(empNo) } catch { /* ignore */ } + try { clearBrowserIdentity() } catch { /* ignore */ } const retain = extra.retainSkillCredentialsOnLogout ? extra.retainSkillCredentialsOnLogout() !== false : true diff --git a/uds-auth/lib/client.js b/uds-auth/lib/client.js index 6c27c4cc..ad25cfd0 100644 --- a/uds-auth/lib/client.js +++ b/uds-auth/lib/client.js @@ -516,8 +516,126 @@ window.__ModuleLoader__.load({ } } + /** Desktop dsh-app:// drops cookies — persist verified bridge token in localStorage. */ + const BRIDGE_STORAGE_KEY = 'uds-auth.session-bridge' + + function readBridge() { + try { + const raw = window.localStorage.getItem(BRIDGE_STORAGE_KEY) + if (!raw) return null + const o = JSON.parse(raw) + if (o && o.empNo && o.token) return o + } catch { /* ignore */ } + return null + } + + function writeBridge(bridge) { + try { + if (!bridge || !bridge.empNo || !bridge.token) { + window.localStorage.removeItem(BRIDGE_STORAGE_KEY) + return + } + window.localStorage.setItem(BRIDGE_STORAGE_KEY, JSON.stringify({ + empNo: String(bridge.empNo), + token: String(bridge.token), + kind: String(bridge.kind || 'fallback'), + exp: bridge.exp || null, + })) + } catch { /* ignore */ } + } + + function clearBridge() { + writeBridge(null) + } + + /** Adopt server-issued bridge; still try cookies for http hosts. */ + function adoptAuthSession(data) { + if (data?.bridge?.empNo && data?.bridge?.token) { + writeBridge(data.bridge) + } + const emp = data?.bridge?.empNo || data?.empNo + const kind = data?.bridge?.kind || (emp === 'administrator' ? 'fallback' : 'uds') + if (emp && kind === 'fallback') { + try { setCookie('UDS_FALLBACK_UI', emp, 7) } catch { /* Desktop may ignore */ } + } + // Ensure mux reconnects with bridge query (soft path before full reload). + try { + window.__udsAuthBridgeMuxPrimed = false + if (typeof window.__udsAuthReconnect === 'function') { + setTimeout(() => { + try { window.__udsAuthReconnect() } catch { /* ignore */ } + }, 50) + } + } catch { /* ignore */ } + } + + function bridgeAuthHeaders() { + const b = readBridge() + if (!b) return {} + return { + 'X-UDS-Bridge-EmpNo': b.empNo, + 'X-UDS-Bridge-Token': b.token, + 'X-UDS-Bridge-Kind': b.kind || 'fallback', + } + } + + /** Append bridge to WS URL — browsers cannot set custom WS headers; Desktop has no cookies. */ + function withBridgeQuery(url) { + const b = readBridge() + if (!b) return url + try { + const parsed = new URL(String(url), typeof location !== 'undefined' ? location.href : 'http://localhost/') + if (parsed.searchParams.has('udsBridgeToken')) return parsed.toString() + parsed.searchParams.set('udsBridgeEmpNo', b.empNo) + parsed.searchParams.set('udsBridgeToken', b.token) + parsed.searchParams.set('udsBridgeKind', b.kind || 'fallback') + return parsed.toString() + } catch { + return url + } + } + + // Install once at module load so login→reconnect WS already carries bridge. + if (typeof window !== 'undefined' && !window.__udsAuthBridgeTransportPatched) { + window.__udsAuthBridgeTransportPatched = true + try { + const OrigWS = window.WebSocket + if (typeof OrigWS === 'function') { + function UdsAuthWebSocket(url, protocols) { + const next = withBridgeQuery(url) + if (protocols === undefined) return new OrigWS(next) + return new OrigWS(next, protocols) + } + UdsAuthWebSocket.prototype = OrigWS.prototype + Object.assign(UdsAuthWebSocket, OrigWS) + window.WebSocket = UdsAuthWebSocket + } + } catch { /* ignore */ } + try { + const origFetch = window.fetch.bind(window) + window.fetch = async function udsAuthBridgeFetch(input, init) { + const bHeaders = bridgeAuthHeaders() + if (!bHeaders['X-UDS-Bridge-Token']) { + return origFetch(input, init) + } + const opts = init ? { ...init } : {} + const prev = opts.headers + if (prev && typeof prev.forEach === 'function') { + const h = { ...bHeaders } + prev.forEach((v, k) => { h[k] = v }) + opts.headers = h + } else { + opts.headers = { ...bHeaders, ...(prev || {}) } + } + if (opts.credentials == null) opts.credentials = 'include' + return origFetch(input, opts) + } + } catch { /* ignore */ } + } + function getEmpNo() { - return getCookie('PORTALSSOUser') || getCookie('ZTEDPGSSOUser') || getCookie('UDS_FALLBACK_USER') || getCookie('UDS_FALLBACK_UI') || null + return getCookie('PORTALSSOUser') || getCookie('ZTEDPGSSOUser') || getCookie('UDS_FALLBACK_USER') || getCookie('UDS_FALLBACK_UI') + || readBridge()?.empNo || null } /** Prefer /api/me-driven attr: fallback login cookie is HttpOnly and invisible to document.cookie. */ @@ -660,8 +778,17 @@ function reloadAfterLogin() { return getCookie('PORTALSSOCookie') || getCookie('ZTEDPGSSOCookie') || null } - async function fetchJson(url, options) { - const res = await fetch(url, options) + async function fetchJson(url, options = {}) { + const baseHeaders = bridgeAuthHeaders() + const optHeaders = options.headers || {} + const headers = (optHeaders && typeof optHeaders.forEach === 'function') + ? (() => { + const h = { ...baseHeaders } + optHeaders.forEach((v, k) => { h[k] = v }) + return h + })() + : { ...baseHeaders, ...optHeaders } + const res = await fetch(url, { credentials: 'include', ...options, headers }) const data = await res.json().catch(() => ({})) if (!res.ok) { const err = new Error(data.message || data.error || res.statusText || t('err.request_failed')) @@ -1253,6 +1380,7 @@ function reloadAfterLogin() { const empNo = other.account || other.empNo || '' const token = other.token || other.authValue || '' if (empNo && token) { + // Cookie path (http/web). Desktop dsh-app:// ignores these — bridge/bind below. setCookie('PORTALSSOUser', empNo, 7) setCookie('PORTALSSOCookie', token, 7) try { @@ -1274,6 +1402,17 @@ function reloadAfterLogin() { setQrStatus(t('ui.userInfoFailed') + ': ' + (err.message || err)) return } + try { + const bound = await fetchJson('/uds-auth/api/bridge/bind', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ empNo, token }), + }) + adoptAuthSession(bound) + } catch (err) { + console.warn('[uds-auth] bridge/bind after QR failed', err) + // http hosts may still have cookies; Desktop will stay logged out without bridge. + } setQrStatus(t('ui.loginSuccess')) await refreshUser() setOpen(false) @@ -1308,11 +1447,12 @@ function reloadAfterLogin() { setFbBusy(true) setFbErr('') try { - await fetchJson('/uds-auth/api/fallback/login', { + const data = await fetchJson('/uds-auth/api/fallback/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: fbUser.trim(), password: fbPass }), }) + adoptAuthSession(data) setFbPass('') await refreshUser() setOpen(false) @@ -1328,11 +1468,12 @@ function reloadAfterLogin() { setFbBusy(true) setFbErr('') try { - await fetchJson('/uds-auth/api/local-admin/unlock', { + const data = await fetchJson('/uds-auth/api/local-admin/unlock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: localKey }), }) + adoptAuthSession(data) setLocalKey('') await refreshUser() setOpen(false) @@ -1564,6 +1705,7 @@ function reloadAfterLogin() { onClick: async () => { try { await fetchJson('/uds-auth/api/logout', { method: 'POST' }) } catch { /* ignore */ } clearAuthCookies() + clearBridge() setUser(null) setOpen(false) document.documentElement.setAttribute('data-uds-logged-in', '0') @@ -1643,6 +1785,19 @@ function reloadAfterLogin() { window.__udsAuthReconnect = () => { try { cctx.connection.reconnect() } catch { window.location.reload() } } + // Desktop ownsHost: mux WS often connects before this client patches + // WebSocket. If a bridge already exists, reconnect once so upgrade + // carries ?udsBridge* (history/follow ACL depends on it). + if ( + readBridge() + && window.__DSH_TRANSPORT__?.ownsHost + && !window.__udsAuthBridgeMuxPrimed + ) { + window.__udsAuthBridgeMuxPrimed = true + setTimeout(() => { + try { cctx.connection.reconnect() } catch { /* ignore */ } + }, 250) + } }) } catch { /* connection may be unavailable */ } diff --git a/uds-auth/lib/dsh-acl.js b/uds-auth/lib/dsh-acl.js index 25dd2e2e..73e15a30 100644 --- a/uds-auth/lib/dsh-acl.js +++ b/uds-auth/lib/dsh-acl.js @@ -3,8 +3,10 @@ */ import { createRequire } from 'node:module' import { resolve as resolvePath, sep as pathSep } from 'node:path' -import { withUserContext, getUserContext, runWithUserContext } from './context.js' +import { withUserContext, getUserContext, runWithUserContext, enterUserContext } from './context.js' import { resolveLocale, t } from './i18n.js' +import { readBridgeFromRequest } from './session-bridge.js' +import { rememberBrowserIdentity, peekBrowserIdentity } from './identity-cache.js' const require = createRequire(import.meta.url) @@ -44,6 +46,38 @@ function parseCookie(header, name) { /** @type {WeakMap} */ const upgradeSocketIdentity = new WeakMap() +function empNoOfIdentity(identity) { + return identity?.empNo || identity?.userContext?.empNo || null +} + +function fallbackBrowserIdentity() { + return peekBrowserIdentity() +} + +/** Re-resolve when prior bind left null/empty (do not treat null as final). */ +function resolveUpgradeIdentity(req, socket, resolveIdentitySync) { + let identity + try { + if (req && Object.prototype.hasOwnProperty.call(req, '__udsAuthIdentity')) { + identity = req.__udsAuthIdentity + } else if (socket) { + identity = upgradeSocketIdentity.get(socket) + } + } catch { + identity = undefined + } + if (!empNoOfIdentity(identity) && typeof resolveIdentitySync === 'function') { + try { + identity = resolveIdentitySync(req) + } catch { + identity = identity ?? null + } + try { if (req) req.__udsAuthIdentity = identity } catch { /* ignore */ } + if (socket) upgradeSocketIdentity.set(socket, identity) + } + return identity ?? null +} + function loadWsModules(requireFn) { const found = [] const seen = new Set() @@ -96,16 +130,21 @@ function bindWebSocketListenersToIdentity(ws, identity) { if (!ws || ws.__udsAuthBound) return ws.__udsAuthBound = true try { ws.__udsAuthIdentity = identity || null } catch { /* ignore */ } + if (empNoOfIdentity(identity)) rememberBrowserIdentity(identity) // Gateway RemoteStreamMuxConnection registers sync `message` listeners that - // kick off async pump()/session.follow after the listener returns. als.run() - // would exit too early and drop empNo → "登录后才能访问会话". enterWith keeps - // the upgrade-time identity for the deferred stream work on this connection. + // kick off async pump()/session.follow after the listener returns. + // als.run() exits too early → empNo dropped → "登录后才能访问会话". + // enterWith keeps upgrade-time identity for deferred stream / history work. const wrap = (listener) => { if (typeof listener !== 'function') return listener return function udsAuthBoundListener(...args) { - // als.run preserves store across orphaned async pump()/session.follow started - // inside the sync Gateway message listener (Node async_hooks). - return runWithUserContext(identity || null, () => listener.apply(this, args)) + // Prefer live socket identity; fall back to last HTTP/WS bridged login. + const live = empNoOfIdentity(identity) ? identity : fallbackBrowserIdentity() + // enterWith only — do NOT wrap in als.run(): run() restores the previous + // store when the sync listener returns, which drops empNo before + // pump()/session.follow (history) continues on the same connection. + enterUserContext(live || null) + return listener.apply(this, args) } } for (const method of ['on', 'once', 'addListener', 'prependListener', 'prependOnceListener']) { @@ -126,14 +165,7 @@ function patchOneWebSocketServer(WebSocketServer, resolveIdentitySync) { function udsAuthHandleUpgrade(req, socket, head, cb) { let identity try { - identity = (req && Object.prototype.hasOwnProperty.call(req, '__udsAuthIdentity')) - ? req.__udsAuthIdentity - : (socket ? upgradeSocketIdentity.get(socket) : undefined) - if (identity === undefined && typeof resolveIdentitySync === 'function') { - identity = resolveIdentitySync(req) - try { if (req) req.__udsAuthIdentity = identity } catch { /* ignore */ } - if (socket) upgradeSocketIdentity.set(socket, identity) - } + identity = resolveUpgradeIdentity(req, socket, resolveIdentitySync) } catch { identity = null } @@ -164,20 +196,45 @@ function patchWebSocketServerForUdsIdentity(resolveIdentitySync) { /** * Sync ACL identity from cookies + roles (no sessionStore). Used on WS upgrade/messages. */ -export function resolveIdentityFromRequestSync(req, deps) { +function identityFromCookieOrBridge(req, deps) { const cookie = req?.headers?.cookie || '' - const empNo = parseCookie(cookie, 'PORTALSSOUser') + let empNo = parseCookie(cookie, 'PORTALSSOUser') || parseCookie(cookie, 'ZTEDPGSSOUser') || parseCookie(cookie, 'UDS_FALLBACK_USER') || parseCookie(cookie, 'UDS_FALLBACK_UI') - if (!empNo) return null - - const token = parseCookie(cookie, 'PORTALSSOCookie') + let token = parseCookie(cookie, 'PORTALSSOCookie') || parseCookie(cookie, 'ZTEDPGSSOCookie') - const isFallback = empNo === 'administrator' + let via = empNo ? 'cookie' : null + let isFallback = empNo === 'administrator' || !!parseCookie(cookie, 'UDS_FALLBACK_USER') || !!parseCookie(cookie, 'UDS_FALLBACK_UI') + // Desktop dsh-app://: cookies are dropped — accept verified bridge + // (HTTP headers or WS upgrade query params). + if (!empNo && deps?.sessionBridge) { + try { + const hdr = readBridgeFromRequest(req) + if (hdr) { + const ok = deps.sessionBridge.verify(hdr.empNo, hdr.token) + if (ok) { + empNo = ok.empNo + token = ok.ssoToken || token + via = 'bridge' + isFallback = ok.kind !== 'uds' || empNo === 'administrator' + } + } + } catch { /* ignore */ } + } + + if (!empNo) return null + return { empNo: String(empNo), token, via, isFallback } +} + +export function resolveIdentityFromRequestSync(req, deps) { + const extracted = identityFromCookieOrBridge(req, deps) + if (!extracted) return null + const { empNo, token, via, isFallback } = extracted + // Bare portal empNo without token is NOT enough — otherwise logout/未登录 // still leaks workspace names via leftover SSO cookies on the WebSocket. if (!isFallback && !token) return null @@ -192,7 +249,9 @@ export function resolveIdentityFromRequestSync(req, deps) { empNo: String(empNo), userId: String(empNo), isAuthenticated: true, - authMode: isFallback ? 'fallback-cookie' : 'cookie-sync', + authMode: isFallback + ? (via === 'bridge' ? 'fallback-bridge' : 'fallback-cookie') + : (via === 'bridge' ? 'bridge-sync' : 'cookie-sync'), token: token || undefined, }, kind: isFallback ? 'fallback' : 'uds', @@ -200,12 +259,9 @@ export function resolveIdentityFromRequestSync(req, deps) { } export async function resolveIdentityFromRequest(req, deps) { - const cookie = req?.headers?.cookie || '' - const empNo = parseCookie(cookie, 'PORTALSSOUser') - || parseCookie(cookie, 'ZTEDPGSSOUser') - || parseCookie(cookie, 'UDS_FALLBACK_USER') - || parseCookie(cookie, 'UDS_FALLBACK_UI') - if (!empNo) return null + const extracted = identityFromCookieOrBridge(req, deps) + if (!extracted) return null + const { empNo, token, via, isFallback } = extracted const { sessionStore, rolesStore } = deps let userContext = null @@ -215,20 +271,16 @@ export async function resolveIdentityFromRequest(req, deps) { userContext = null } - const token = parseCookie(cookie, 'PORTALSSOCookie') - || parseCookie(cookie, 'ZTEDPGSSOCookie') - const isFallback = empNo === 'administrator' - || !!parseCookie(cookie, 'UDS_FALLBACK_USER') - || !!parseCookie(cookie, 'UDS_FALLBACK_UI') - if (!userContext) { - // Require session, token, or fallback cookie — never empNo alone. + // Require session, token, or fallback cookie/bridge — never empNo alone. if (!token && !isFallback) return null userContext = { empNo: String(empNo), userId: String(empNo), isAuthenticated: true, - authMode: token ? 'cookie-acl' : 'fallback-cookie', + authMode: token + ? (via === 'bridge' ? 'bridge-acl' : 'cookie-acl') + : (via === 'bridge' ? 'fallback-bridge' : 'fallback-cookie'), token: token || undefined, lastActiveAt: new Date().toISOString(), } @@ -333,8 +385,13 @@ export function patchWebServerWithIdentity(server, resolveIdentity, resolveIdent } catch { identity = syncIdentity } + // If async path missed bridge query, force a fresh sync resolve. + if (!empNoOfIdentity(identity) && typeof resolveIdentitySync === 'function') { + try { identity = resolveIdentitySync(req) || identity } catch { /* keep */ } + } try { req.__udsAuthIdentity = identity } catch { /* ignore */ } if (socket) upgradeSocketIdentity.set(socket, identity) + if (empNoOfIdentity(identity)) rememberBrowserIdentity(identity) return withUserContext(identity, () => prev(req, socket, head)) } @@ -848,10 +905,22 @@ export function installDshAcl(ctx, { // Host-internal: do not burn 800ms waiting for a browser cookie that will never appear. if (identity === undefined) return identity if (empOf(identity)) return identity + // Desktop mux: follow/history often sees ALS=null; use last bridged login + // (HTTP /api/me or WS upgrade — see identity-cache.js). + const cached = fallbackBrowserIdentity() + if (cached && empOf(cached)) { + enterUserContext(cached) + return cached + } const deadline = Date.now() + ms while (!empOf(identity) && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 40)) identity = getUserContext() + const again = fallbackBrowserIdentity() + if (!empOf(identity) && again && empOf(again)) { + enterUserContext(again) + return again + } } return identity } @@ -862,15 +931,18 @@ export function installDshAcl(ctx, { // follow is an async generator: assert inside so we can await identity. if (methodName === 'follow') { sc.follow = async function* (request, signal) { - await waitForIdentity() + const identity = await waitForIdentity() assertCanAccess(request) + // Long-lived generator: pin ALS so history frames keep empNo after awaits. + if (identity !== undefined) enterUserContext(identity) yield* orig(request, signal) } return } sc[methodName] = async (request, signal) => { - await waitForIdentity() + const identity = await waitForIdentity() assertCanAccess(request) + if (identity !== undefined) enterUserContext(identity) return orig(request, signal) } } diff --git a/uds-auth/lib/identity-cache.js b/uds-auth/lib/identity-cache.js new file mode 100644 index 00000000..c4d1488a --- /dev/null +++ b/uds-auth/lib/identity-cache.js @@ -0,0 +1,22 @@ +/** + * Last verified browser identity (bridge or cookie). + * Desktop mux follow/history often runs with empty ALS; fall back here after + * a successful /uds-auth HTTP auth or WS upgrade bind. + */ + +/** @type {object|null} */ +let lastBrowserIdentity = null + +export function rememberBrowserIdentity(identity) { + const emp = identity?.empNo || identity?.userContext?.empNo + if (!emp) return + lastBrowserIdentity = identity +} + +export function clearBrowserIdentity() { + lastBrowserIdentity = null +} + +export function peekBrowserIdentity() { + return lastBrowserIdentity +} diff --git a/uds-auth/lib/index.js b/uds-auth/lib/index.js index 34e89f6c..37d9d42c 100644 --- a/uds-auth/lib/index.js +++ b/uds-auth/lib/index.js @@ -20,6 +20,7 @@ import { buildLocalAdminUserContext, LOCAL_ADMIN_BOX_ENV, } from './local-admin.js' +import { SessionBridgeStore } from './session-bridge.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const require = createRequire(import.meta.url) @@ -149,6 +150,25 @@ let _pluginCtx = null let _logger = console let _skillCredentials = null let _agentAuthHandlers = null +/** @type {SessionBridgeStore | null} */ +let _sessionBridge = null + +/** Mint cookie-less bridge token (Desktop dsh-app://); cookies still set for http. */ +function mintBridge(empNo, kind = 'fallback', ssoToken) { + if (!_sessionBridge) return null + try { + const minted = _sessionBridge.mint({ empNo, kind, ssoToken }) + 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 + } +} function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) { if (/^https?:\/\//.test(uacQrVerifyPath)) return uacQrVerifyPath @@ -522,12 +542,86 @@ async function handleFallbackLogin(req, res) { await _sessionStore.setex(empNo, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext) await ensureUserWorkspace(empNo) + // Cookie path (http / web) + bridge path (Desktop dsh-app:// where cookies are dropped). setFallbackAdminCookies(req, res) + const bridge = mintBridge(empNo, 'fallback') sendOkMsg(res, req, 'fallback_login', null, userContext, { 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 = '' + for await (const chunk of req) body += chunk + 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 + await _sessionStore.setex(id, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext) + await ensureUserWorkspace(id) + + // Cookie compat for http hosts + 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', [ + cookieParts(INTERNAL.cookieEmpNo, id, false), + cookieParts(INTERNAL.cookieAuthValue, token, true), + ]) + + const bridge = mintBridge(id, 'uds', token) + const role = _rolesStore?.getRole?.(id) || 'user' + sendOkMsg(res, req, 'fallback_login', null, userContext, { + success: true, + empNo: id, + role, + bridge, }) } @@ -579,11 +673,13 @@ async function handleLocalAdminUnlock(req, res) { } appendLocalAdminCookie(fakeRes, req) setFallbackAdminCookies(req, res, fakeRes._cookies) + const bridge = mintBridge(empNo, 'fallback') sendOkMsg(res, req, 'local_admin_unlock', null, userContext, { success: true, empNo, role: 'fallback_admin', + bridge, }) } @@ -741,6 +837,11 @@ function handleRequest(req, res) { 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) { @@ -857,8 +958,41 @@ function installSettingsSection(ctx, entry, hooks) { return } ctx.inject(['settings'], (settingsCtx) => { - ctx.logger?.info?.('[uds-auth] registering settings panel (new API)') - settingsCtx.settings.installSection(ctx, UDS_AUTH_SETTINGS_NAMESPACE, ConfigSchema, entry, hooksArg) + 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)') }) } @@ -1014,6 +1148,13 @@ async function initServices(ctx, config) { await _rolesStore.init() logLocalAdminStatus(ctx.logger || console) + _sessionBridge = new SessionBridgeStore({ + file: resolve(__dirname, '..', '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(__dirname, '..', 'session-owners.json') }) await _sessionAcl.init() @@ -1034,7 +1175,10 @@ async function initServices(ctx, config) { authValueHeader: INTERNAL.authValueHeader, }, session: INTERNAL.session, - }, _sessionStore, _rolesStore, { onSkillCredentials: rememberSkillCreds }) + }, _sessionStore, _rolesStore, { + onSkillCredentials: rememberSkillCreds, + sessionBridge: _sessionBridge, + }) _apiHandlers = createApiHandlers( { session: INTERNAL.session }, @@ -1043,6 +1187,7 @@ async function initServices(ctx, config) { { skillCredentials: _skillCredentials, retainSkillCredentialsOnLogout: () => _currentConfig?.retainSkillCredentialsOnLogout !== false, + sessionBridge: _sessionBridge, }, ) @@ -1080,6 +1225,7 @@ async function initServices(ctx, config) { const identityDeps = { sessionStore: _sessionStore, rolesStore: _rolesStore, + sessionBridge: _sessionBridge, } const resolveIdentity = (req) => resolveIdentityFromRequest(req, identityDeps) const resolveIdentitySync = (req) => resolveIdentityFromRequestSync(req, identityDeps) diff --git a/uds-auth/lib/middleware/auth-middleware.js b/uds-auth/lib/middleware/auth-middleware.js index 9a7969b9..225b0d85 100644 --- a/uds-auth/lib/middleware/auth-middleware.js +++ b/uds-auth/lib/middleware/auth-middleware.js @@ -2,6 +2,8 @@ import { UdsClient } from '../uds/client.js' import { UdsValidator } from '../uds/validator.js' import { ROLES } from '../roles.js' import { searchUserByEmpNoToken } from '../uds/user-search.js' +import { readBridgeFromRequest } from '../session-bridge.js' +import { rememberBrowserIdentity } from '../identity-cache.js' /** * auth-middleware @@ -19,6 +21,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { const onSkillCredentials = typeof hooks.onSkillCredentials === 'function' ? hooks.onSkillCredentials : null + const sessionBridge = hooks.sessionBridge || null const udsClient = new UdsClient(config.udsAuth) const validatorConfig = { ...config.udsAuth, @@ -34,9 +37,29 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { if (udsMatch) return { empNo: decodeURIComponent(udsMatch[1].trim()), kind: 'uds' } const fbMatch = cookieHeader?.match(/UDS_FALLBACK_USER=([^;]+)/) if (fbMatch) return { empNo: decodeURIComponent(fbMatch[1].trim()), kind: 'fallback' } + // Non-HttpOnly UI cookie (still valid when Set-Cookie works). + const fbUi = cookieHeader?.match(/UDS_FALLBACK_UI=([^;]+)/) + if (fbUi) return { empNo: decodeURIComponent(fbUi[1].trim()), kind: 'fallback' } return null } + /** Cookie first (http/web), then Desktop bridge headers. */ + function extractIdentity(req) { + const fromCookie = extractEmpNo(req?.headers?.cookie || '') + if (fromCookie) return { ...fromCookie, via: 'cookie' } + if (!sessionBridge) return null + const hdr = readBridgeFromRequest(req) + if (!hdr) return null + const ok = sessionBridge.verify(hdr.empNo, hdr.token) + if (!ok) return null + return { + empNo: ok.empNo, + kind: ok.kind === 'uds' ? 'uds' : 'fallback', + via: 'bridge', + ssoToken: ok.ssoToken, + } + } + function applyProfile(userContext, profile, credentials) { userContext.userId = profile.empNo userContext.empNo = profile.empNo @@ -109,12 +132,19 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { ctx.empNo = empNo ctx.role = role ctx.permissions = rolesStore.resolvePermissions(empNo, role) + // Stamp for Desktop session.follow when mux ALS is empty. + rememberBrowserIdentity({ + empNo, + role, + permissions: ctx.permissions, + userContext, + kind: kind === 'uds' ? 'uds' : 'fallback', + }) } async function authMiddleware(ctx, next) { const { req } = ctx - const cookieHeader = req.headers.cookie || '' - const extracted = extractEmpNo(cookieHeader) + const extracted = extractIdentity(req) if (!extracted) return next() @@ -123,6 +153,9 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { // 旧 trust 会话 / 无姓名部门:强制用 token 重查用户信息 if (userContext && extracted.kind === 'uds' && needsProfileUpgrade(userContext)) { const credentials = udsValidator.extractCredentials(req) + || (extracted.ssoToken + ? { empNo: extracted.empNo, token: extracted.ssoToken, lang: 'zh-CN' } + : null) if (credentials && credentials.empNo === extracted.empNo && udsValidator.validateCredentials(credentials)) { const profile = await verifyEmpNoAndToken(credentials.empNo, credentials.token) if (profile) { @@ -149,7 +182,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { return next() } - // Fallback cookie survives process restart; memory session does not — rebuild. + // Fallback cookie/bridge survives process restart; memory session does not — rebuild. if (extracted.kind === 'fallback') { const empNo = extracted.empNo || 'administrator' userContext = { @@ -159,7 +192,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { displayName: 'Fallback Administrator', isAuthenticated: true, role: ROLES.FALLBACK_ADMIN, - authMode: 'fallback-cookie', + authMode: extracted.via === 'bridge' ? 'fallback-bridge' : 'fallback-cookie', authenticatedAt: new Date().toISOString(), lastActiveAt: new Date().toISOString(), sessionCreatedAt: new Date().toISOString(), @@ -169,6 +202,9 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { } const credentials = udsValidator.extractCredentials(req) + || (extracted.ssoToken + ? { empNo: extracted.empNo, token: extracted.ssoToken, lang: 'zh-CN' } + : null) if (!credentials || !udsValidator.validateCredentials(credentials)) { return next() } diff --git a/uds-auth/lib/session-bridge.js b/uds-auth/lib/session-bridge.js new file mode 100644 index 00000000..bdbaae1c --- /dev/null +++ b/uds-auth/lib/session-bridge.js @@ -0,0 +1,197 @@ +/** + * Cookie-less session bridge for Desktop (dsh-app:// drops Set-Cookie / document.cookie). + * + * Login handlers mint a random token; the browser stores { empNo, token, kind } in + * localStorage and sends X-UDS-Bridge-* headers on subsequent /uds-auth requests. + */ +import { randomBytes, timingSafeEqual, createHash } from 'node:crypto' +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { dirname } from 'node:path' + +export const BRIDGE_EMPNO_HEADER = 'x-uds-bridge-empno' +export const BRIDGE_TOKEN_HEADER = 'x-uds-bridge-token' +export const BRIDGE_KIND_HEADER = 'x-uds-bridge-kind' + +const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +function safeEqualStr(a, b) { + if (a == null || b == null) return false + const ha = createHash('sha256').update(String(a)).digest() + const hb = createHash('sha256').update(String(b)).digest() + return timingSafeEqual(ha, hb) +} + +export class SessionBridgeStore { + /** + * @param {{ file?: string, ttlMs?: number }} [opts] + */ + constructor(opts = {}) { + this._file = opts.file || null + this._ttlMs = opts.ttlMs || DEFAULT_TTL_MS + /** @type {Map} */ + this._byToken = new Map() + this._saveTimer = null + } + + async init() { + if (!this._file) return + try { + const raw = await readFile(this._file, 'utf-8') + const data = JSON.parse(raw) + const now = Date.now() + for (const [token, row] of Object.entries(data.tokens || {})) { + if (!row || !row.empNo || !row.exp || row.exp <= now) continue + this._byToken.set(token, { + empNo: String(row.empNo), + kind: String(row.kind || 'fallback'), + exp: Number(row.exp), + ssoToken: row.ssoToken ? String(row.ssoToken) : undefined, + }) + } + } catch (err) { + if (err?.code !== 'ENOENT') { + console.warn('[uds-auth:SessionBridge] load failed:', err.message) + } + } + } + + _scheduleSave() { + if (!this._file || this._saveTimer) return + this._saveTimer = setTimeout(() => { + this._saveTimer = null + void this._persist() + }, 200) + } + + async _persist() { + if (!this._file) return + const now = Date.now() + const tokens = {} + for (const [token, row] of this._byToken) { + if (row.exp <= now) { + this._byToken.delete(token) + continue + } + tokens[token] = { + empNo: row.empNo, + kind: row.kind, + exp: row.exp, + ...(row.ssoToken ? { ssoToken: row.ssoToken } : {}), + } + } + try { + await mkdir(dirname(this._file), { recursive: true }) + await writeFile(this._file, JSON.stringify({ tokens }, null, 2), 'utf-8') + } catch (err) { + console.warn('[uds-auth:SessionBridge] save failed:', err.message) + } + } + + /** + * @param {{ empNo: string, kind?: string, ssoToken?: string, ttlMs?: number }} row + * @returns {{ empNo: string, kind: string, token: string, exp: number }} + */ + mint(row) { + const empNo = String(row.empNo || '').trim() + if (!empNo) throw new Error('bridge_empNo_required') + const kind = String(row.kind || 'fallback') + const token = randomBytes(32).toString('hex') + const exp = Date.now() + (row.ttlMs || this._ttlMs) + this._byToken.set(token, { + empNo, + kind, + exp, + ssoToken: row.ssoToken ? String(row.ssoToken) : undefined, + }) + this._scheduleSave() + return { empNo, kind, token, exp } + } + + /** + * @param {string} empNo + * @param {string} token + * @returns {{ empNo: string, kind: string, ssoToken?: string } | null} + */ + verify(empNo, token) { + const t = String(token || '').trim() + const e = String(empNo || '').trim() + if (!t || !e) return null + const row = this._byToken.get(t) + if (!row) return null + if (row.exp <= Date.now()) { + this._byToken.delete(t) + this._scheduleSave() + return null + } + if (!safeEqualStr(row.empNo, e)) return null + return { empNo: row.empNo, kind: row.kind, ssoToken: row.ssoToken } + } + + /** Clear all bridge tokens for an empNo (logout). */ + revokeEmpNo(empNo) { + const e = String(empNo || '').trim() + if (!e) return + let changed = false + for (const [token, row] of this._byToken) { + if (row.empNo === e) { + this._byToken.delete(token) + changed = true + } + } + if (changed) this._scheduleSave() + } + + revokeToken(token) { + const t = String(token || '').trim() + if (!t) return + if (this._byToken.delete(t)) this._scheduleSave() + } +} + +/** + * Read bridge credentials from request headers. + * @param {any} req + * @returns {{ empNo: string, token: string, kind: string } | null} + */ +export function readBridgeHeaders(req) { + const headers = req?.headers || {} + const empNo = String(headers[BRIDGE_EMPNO_HEADER] || '').trim() + const token = String(headers[BRIDGE_TOKEN_HEADER] || '').trim() + const kind = String(headers[BRIDGE_KIND_HEADER] || 'fallback').trim() || 'fallback' + if (!empNo || !token) return null + return { empNo, token, kind } +} + +/** + * Headers first, then WebSocket upgrade query + * (`?udsBridgeEmpNo=&udsBridgeToken=&udsBridgeKind=`). + * Browser WS cannot set custom headers; Desktop also drops cookies. + * @param {any} req + * @returns {{ empNo: string, token: string, kind: string } | null} + */ +export function readBridgeFromRequest(req) { + const fromHdr = readBridgeHeaders(req) + if (fromHdr) return fromHdr + try { + const url = new URL(req?.url || '/', 'http://uds-auth.local') + const empNo = String( + url.searchParams.get('udsBridgeEmpNo') + || url.searchParams.get('x-uds-bridge-empno') + || '', + ).trim() + const token = String( + url.searchParams.get('udsBridgeToken') + || url.searchParams.get('x-uds-bridge-token') + || '', + ).trim() + const kind = String( + url.searchParams.get('udsBridgeKind') + || url.searchParams.get('x-uds-bridge-kind') + || 'fallback', + ).trim() || 'fallback' + if (!empNo || !token) return null + return { empNo, token, kind } + } catch { + return null + } +} diff --git a/uds-auth/package.json b/uds-auth/package.json index 32a422bb..1d3f53a7 100644 --- a/uds-auth/package.json +++ b/uds-auth/package.json @@ -1,6 +1,6 @@ { "name": "uds-auth", - "version": "0.2.13", + "version": "0.2.19", "description": "UDS authentication plugin for DeepSeek Harness with multi-tenant session isolation", "type": "module", "main": "lib/index.js",