Bridge uds-auth session identity for Desktop dsh-app://.
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run

Cookie-less Desktop needs localStorage bridge headers/WS params plus ALS enterWith and identity cache so login and history stay authenticated.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-09 22:46:37 +08:00
parent 92a6827aef
commit 5b5c0a1418
9 changed files with 686 additions and 51 deletions

4
uds-auth/.gitignore vendored
View file

@ -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

View file

@ -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

View file

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

View file

@ -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<object, object|null|undefined>} */
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)
}
}

View file

@ -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
}

View file

@ -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)

View file

@ -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()
}

View file

@ -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<string, { empNo: string, kind: string, exp: number, ssoToken?: string }>} */
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
}
}

View file

@ -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",