mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 20:35:56 +08:00
Bridge uds-auth session identity for Desktop dsh-app://.
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:
parent
92a6827aef
commit
5b5c0a1418
9 changed files with 686 additions and 51 deletions
4
uds-auth/.gitignore
vendored
4
uds-auth/.gitignore
vendored
|
|
@ -15,3 +15,7 @@ skill-credentials.json
|
||||||
scripts/find-dsh-process.ps1
|
scripts/find-dsh-process.ps1
|
||||||
scripts/inspect-session-sample.cjs
|
scripts/inspect-session-sample.cjs
|
||||||
scripts/merge-sessions-into-workspace.ps1
|
scripts/merge-sessions-into-workspace.ps1
|
||||||
|
|
||||||
|
session-bridge.json
|
||||||
|
roles.json
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ROLES, ROLE_LABELS } from './roles.js'
|
import { ROLES, ROLE_LABELS } from './roles.js'
|
||||||
import { requirePermission } from './middleware/auth-middleware.js'
|
import { requirePermission } from './middleware/auth-middleware.js'
|
||||||
import { apiError, apiOk, resolveLocale, roleLabel } from './i18n.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.
|
* 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) {
|
async function logout(ctx) {
|
||||||
const empNo = ctx.empNo
|
const empNo = ctx.empNo
|
||||||
if (empNo) await sessionStore.delete(empNo)
|
if (empNo) await sessionStore.delete(empNo)
|
||||||
|
try { extra.sessionBridge?.revokeEmpNo?.(empNo) } catch { /* ignore */ }
|
||||||
|
try { clearBrowserIdentity() } catch { /* ignore */ }
|
||||||
const retain = extra.retainSkillCredentialsOnLogout
|
const retain = extra.retainSkillCredentialsOnLogout
|
||||||
? extra.retainSkillCredentialsOnLogout() !== false
|
? extra.retainSkillCredentialsOnLogout() !== false
|
||||||
: true
|
: true
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
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. */
|
/** 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
|
return getCookie('PORTALSSOCookie') || getCookie('ZTEDPGSSOCookie') || null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url, options) {
|
async function fetchJson(url, options = {}) {
|
||||||
const res = await fetch(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(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error(data.message || data.error || res.statusText || t('err.request_failed'))
|
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 empNo = other.account || other.empNo || ''
|
||||||
const token = other.token || other.authValue || ''
|
const token = other.token || other.authValue || ''
|
||||||
if (empNo && token) {
|
if (empNo && token) {
|
||||||
|
// Cookie path (http/web). Desktop dsh-app:// ignores these — bridge/bind below.
|
||||||
setCookie('PORTALSSOUser', empNo, 7)
|
setCookie('PORTALSSOUser', empNo, 7)
|
||||||
setCookie('PORTALSSOCookie', token, 7)
|
setCookie('PORTALSSOCookie', token, 7)
|
||||||
try {
|
try {
|
||||||
|
|
@ -1274,6 +1402,17 @@ function reloadAfterLogin() {
|
||||||
setQrStatus(t('ui.userInfoFailed') + ': ' + (err.message || err))
|
setQrStatus(t('ui.userInfoFailed') + ': ' + (err.message || err))
|
||||||
return
|
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'))
|
setQrStatus(t('ui.loginSuccess'))
|
||||||
await refreshUser()
|
await refreshUser()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
|
@ -1308,11 +1447,12 @@ function reloadAfterLogin() {
|
||||||
setFbBusy(true)
|
setFbBusy(true)
|
||||||
setFbErr('')
|
setFbErr('')
|
||||||
try {
|
try {
|
||||||
await fetchJson('/uds-auth/api/fallback/login', {
|
const data = await fetchJson('/uds-auth/api/fallback/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: fbUser.trim(), password: fbPass }),
|
body: JSON.stringify({ username: fbUser.trim(), password: fbPass }),
|
||||||
})
|
})
|
||||||
|
adoptAuthSession(data)
|
||||||
setFbPass('')
|
setFbPass('')
|
||||||
await refreshUser()
|
await refreshUser()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
|
@ -1328,11 +1468,12 @@ function reloadAfterLogin() {
|
||||||
setFbBusy(true)
|
setFbBusy(true)
|
||||||
setFbErr('')
|
setFbErr('')
|
||||||
try {
|
try {
|
||||||
await fetchJson('/uds-auth/api/local-admin/unlock', {
|
const data = await fetchJson('/uds-auth/api/local-admin/unlock', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key: localKey }),
|
body: JSON.stringify({ key: localKey }),
|
||||||
})
|
})
|
||||||
|
adoptAuthSession(data)
|
||||||
setLocalKey('')
|
setLocalKey('')
|
||||||
await refreshUser()
|
await refreshUser()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
|
@ -1564,6 +1705,7 @@ function reloadAfterLogin() {
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
try { await fetchJson('/uds-auth/api/logout', { method: 'POST' }) } catch { /* ignore */ }
|
try { await fetchJson('/uds-auth/api/logout', { method: 'POST' }) } catch { /* ignore */ }
|
||||||
clearAuthCookies()
|
clearAuthCookies()
|
||||||
|
clearBridge()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
document.documentElement.setAttribute('data-uds-logged-in', '0')
|
document.documentElement.setAttribute('data-uds-logged-in', '0')
|
||||||
|
|
@ -1643,6 +1785,19 @@ function reloadAfterLogin() {
|
||||||
window.__udsAuthReconnect = () => {
|
window.__udsAuthReconnect = () => {
|
||||||
try { cctx.connection.reconnect() } catch { window.location.reload() }
|
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 */ }
|
} catch { /* connection may be unavailable */ }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
*/
|
*/
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { resolve as resolvePath, sep as pathSep } from 'node:path'
|
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 { resolveLocale, t } from './i18n.js'
|
||||||
|
import { readBridgeFromRequest } from './session-bridge.js'
|
||||||
|
import { rememberBrowserIdentity, peekBrowserIdentity } from './identity-cache.js'
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
|
@ -44,6 +46,38 @@ function parseCookie(header, name) {
|
||||||
/** @type {WeakMap<object, object|null|undefined>} */
|
/** @type {WeakMap<object, object|null|undefined>} */
|
||||||
const upgradeSocketIdentity = new 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) {
|
function loadWsModules(requireFn) {
|
||||||
const found = []
|
const found = []
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
|
|
@ -96,16 +130,21 @@ function bindWebSocketListenersToIdentity(ws, identity) {
|
||||||
if (!ws || ws.__udsAuthBound) return
|
if (!ws || ws.__udsAuthBound) return
|
||||||
ws.__udsAuthBound = true
|
ws.__udsAuthBound = true
|
||||||
try { ws.__udsAuthIdentity = identity || null } catch { /* ignore */ }
|
try { ws.__udsAuthIdentity = identity || null } catch { /* ignore */ }
|
||||||
|
if (empNoOfIdentity(identity)) rememberBrowserIdentity(identity)
|
||||||
// Gateway RemoteStreamMuxConnection registers sync `message` listeners that
|
// Gateway RemoteStreamMuxConnection registers sync `message` listeners that
|
||||||
// kick off async pump()/session.follow after the listener returns. als.run()
|
// kick off async pump()/session.follow after the listener returns.
|
||||||
// would exit too early and drop empNo → "登录后才能访问会话". enterWith keeps
|
// als.run() exits too early → empNo dropped → "登录后才能访问会话".
|
||||||
// the upgrade-time identity for the deferred stream work on this connection.
|
// enterWith keeps upgrade-time identity for deferred stream / history work.
|
||||||
const wrap = (listener) => {
|
const wrap = (listener) => {
|
||||||
if (typeof listener !== 'function') return listener
|
if (typeof listener !== 'function') return listener
|
||||||
return function udsAuthBoundListener(...args) {
|
return function udsAuthBoundListener(...args) {
|
||||||
// als.run preserves store across orphaned async pump()/session.follow started
|
// Prefer live socket identity; fall back to last HTTP/WS bridged login.
|
||||||
// inside the sync Gateway message listener (Node async_hooks).
|
const live = empNoOfIdentity(identity) ? identity : fallbackBrowserIdentity()
|
||||||
return runWithUserContext(identity || null, () => listener.apply(this, args))
|
// 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']) {
|
for (const method of ['on', 'once', 'addListener', 'prependListener', 'prependOnceListener']) {
|
||||||
|
|
@ -126,14 +165,7 @@ function patchOneWebSocketServer(WebSocketServer, resolveIdentitySync) {
|
||||||
function udsAuthHandleUpgrade(req, socket, head, cb) {
|
function udsAuthHandleUpgrade(req, socket, head, cb) {
|
||||||
let identity
|
let identity
|
||||||
try {
|
try {
|
||||||
identity = (req && Object.prototype.hasOwnProperty.call(req, '__udsAuthIdentity'))
|
identity = resolveUpgradeIdentity(req, socket, resolveIdentitySync)
|
||||||
? 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)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
identity = null
|
identity = null
|
||||||
}
|
}
|
||||||
|
|
@ -164,20 +196,45 @@ function patchWebSocketServerForUdsIdentity(resolveIdentitySync) {
|
||||||
/**
|
/**
|
||||||
* Sync ACL identity from cookies + roles (no sessionStore). Used on WS upgrade/messages.
|
* 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 cookie = req?.headers?.cookie || ''
|
||||||
const empNo = parseCookie(cookie, 'PORTALSSOUser')
|
let empNo = parseCookie(cookie, 'PORTALSSOUser')
|
||||||
|| parseCookie(cookie, 'ZTEDPGSSOUser')
|
|| parseCookie(cookie, 'ZTEDPGSSOUser')
|
||||||
|| parseCookie(cookie, 'UDS_FALLBACK_USER')
|
|| parseCookie(cookie, 'UDS_FALLBACK_USER')
|
||||||
|| parseCookie(cookie, 'UDS_FALLBACK_UI')
|
|| parseCookie(cookie, 'UDS_FALLBACK_UI')
|
||||||
if (!empNo) return null
|
let token = parseCookie(cookie, 'PORTALSSOCookie')
|
||||||
|
|
||||||
const token = parseCookie(cookie, 'PORTALSSOCookie')
|
|
||||||
|| parseCookie(cookie, 'ZTEDPGSSOCookie')
|
|| 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_USER')
|
||||||
|| !!parseCookie(cookie, 'UDS_FALLBACK_UI')
|
|| !!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/未登录
|
// Bare portal empNo without token is NOT enough — otherwise logout/未登录
|
||||||
// still leaks workspace names via leftover SSO cookies on the WebSocket.
|
// still leaks workspace names via leftover SSO cookies on the WebSocket.
|
||||||
if (!isFallback && !token) return null
|
if (!isFallback && !token) return null
|
||||||
|
|
@ -192,7 +249,9 @@ export function resolveIdentityFromRequestSync(req, deps) {
|
||||||
empNo: String(empNo),
|
empNo: String(empNo),
|
||||||
userId: String(empNo),
|
userId: String(empNo),
|
||||||
isAuthenticated: true,
|
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,
|
token: token || undefined,
|
||||||
},
|
},
|
||||||
kind: isFallback ? 'fallback' : 'uds',
|
kind: isFallback ? 'fallback' : 'uds',
|
||||||
|
|
@ -200,12 +259,9 @@ export function resolveIdentityFromRequestSync(req, deps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveIdentityFromRequest(req, deps) {
|
export async function resolveIdentityFromRequest(req, deps) {
|
||||||
const cookie = req?.headers?.cookie || ''
|
const extracted = identityFromCookieOrBridge(req, deps)
|
||||||
const empNo = parseCookie(cookie, 'PORTALSSOUser')
|
if (!extracted) return null
|
||||||
|| parseCookie(cookie, 'ZTEDPGSSOUser')
|
const { empNo, token, via, isFallback } = extracted
|
||||||
|| parseCookie(cookie, 'UDS_FALLBACK_USER')
|
|
||||||
|| parseCookie(cookie, 'UDS_FALLBACK_UI')
|
|
||||||
if (!empNo) return null
|
|
||||||
|
|
||||||
const { sessionStore, rolesStore } = deps
|
const { sessionStore, rolesStore } = deps
|
||||||
let userContext = null
|
let userContext = null
|
||||||
|
|
@ -215,20 +271,16 @@ export async function resolveIdentityFromRequest(req, deps) {
|
||||||
userContext = null
|
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) {
|
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
|
if (!token && !isFallback) return null
|
||||||
userContext = {
|
userContext = {
|
||||||
empNo: String(empNo),
|
empNo: String(empNo),
|
||||||
userId: String(empNo),
|
userId: String(empNo),
|
||||||
isAuthenticated: true,
|
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,
|
token: token || undefined,
|
||||||
lastActiveAt: new Date().toISOString(),
|
lastActiveAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
@ -333,8 +385,13 @@ export function patchWebServerWithIdentity(server, resolveIdentity, resolveIdent
|
||||||
} catch {
|
} catch {
|
||||||
identity = syncIdentity
|
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 */ }
|
try { req.__udsAuthIdentity = identity } catch { /* ignore */ }
|
||||||
if (socket) upgradeSocketIdentity.set(socket, identity)
|
if (socket) upgradeSocketIdentity.set(socket, identity)
|
||||||
|
if (empNoOfIdentity(identity)) rememberBrowserIdentity(identity)
|
||||||
return withUserContext(identity, () => prev(req, socket, head))
|
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.
|
// Host-internal: do not burn 800ms waiting for a browser cookie that will never appear.
|
||||||
if (identity === undefined) return identity
|
if (identity === undefined) return identity
|
||||||
if (empOf(identity)) 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
|
const deadline = Date.now() + ms
|
||||||
while (!empOf(identity) && Date.now() < deadline) {
|
while (!empOf(identity) && Date.now() < deadline) {
|
||||||
await new Promise((r) => setTimeout(r, 40))
|
await new Promise((r) => setTimeout(r, 40))
|
||||||
identity = getUserContext()
|
identity = getUserContext()
|
||||||
|
const again = fallbackBrowserIdentity()
|
||||||
|
if (!empOf(identity) && again && empOf(again)) {
|
||||||
|
enterUserContext(again)
|
||||||
|
return again
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return identity
|
return identity
|
||||||
}
|
}
|
||||||
|
|
@ -862,15 +931,18 @@ export function installDshAcl(ctx, {
|
||||||
// follow is an async generator: assert inside so we can await identity.
|
// follow is an async generator: assert inside so we can await identity.
|
||||||
if (methodName === 'follow') {
|
if (methodName === 'follow') {
|
||||||
sc.follow = async function* (request, signal) {
|
sc.follow = async function* (request, signal) {
|
||||||
await waitForIdentity()
|
const identity = await waitForIdentity()
|
||||||
assertCanAccess(request)
|
assertCanAccess(request)
|
||||||
|
// Long-lived generator: pin ALS so history frames keep empNo after awaits.
|
||||||
|
if (identity !== undefined) enterUserContext(identity)
|
||||||
yield* orig(request, signal)
|
yield* orig(request, signal)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sc[methodName] = async (request, signal) => {
|
sc[methodName] = async (request, signal) => {
|
||||||
await waitForIdentity()
|
const identity = await waitForIdentity()
|
||||||
assertCanAccess(request)
|
assertCanAccess(request)
|
||||||
|
if (identity !== undefined) enterUserContext(identity)
|
||||||
return orig(request, signal)
|
return orig(request, signal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
uds-auth/lib/identity-cache.js
Normal file
22
uds-auth/lib/identity-cache.js
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
buildLocalAdminUserContext,
|
buildLocalAdminUserContext,
|
||||||
LOCAL_ADMIN_BOX_ENV,
|
LOCAL_ADMIN_BOX_ENV,
|
||||||
} from './local-admin.js'
|
} from './local-admin.js'
|
||||||
|
import { SessionBridgeStore } from './session-bridge.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
@ -149,6 +150,25 @@ let _pluginCtx = null
|
||||||
let _logger = console
|
let _logger = console
|
||||||
let _skillCredentials = null
|
let _skillCredentials = null
|
||||||
let _agentAuthHandlers = 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) {
|
function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) {
|
||||||
if (/^https?:\/\//.test(uacQrVerifyPath)) return 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 _sessionStore.setex(empNo, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext)
|
||||||
await ensureUserWorkspace(empNo)
|
await ensureUserWorkspace(empNo)
|
||||||
|
|
||||||
|
// Cookie path (http / web) + bridge path (Desktop dsh-app:// where cookies are dropped).
|
||||||
setFallbackAdminCookies(req, res)
|
setFallbackAdminCookies(req, res)
|
||||||
|
const bridge = mintBridge(empNo, 'fallback')
|
||||||
|
|
||||||
sendOkMsg(res, req, 'fallback_login', null, userContext, {
|
sendOkMsg(res, req, 'fallback_login', null, userContext, {
|
||||||
success: true,
|
success: true,
|
||||||
empNo,
|
empNo,
|
||||||
role: 'fallback_admin',
|
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)
|
appendLocalAdminCookie(fakeRes, req)
|
||||||
setFallbackAdminCookies(req, res, fakeRes._cookies)
|
setFallbackAdminCookies(req, res, fakeRes._cookies)
|
||||||
|
const bridge = mintBridge(empNo, 'fallback')
|
||||||
|
|
||||||
sendOkMsg(res, req, 'local_admin_unlock', null, userContext, {
|
sendOkMsg(res, req, 'local_admin_unlock', null, userContext, {
|
||||||
success: true,
|
success: true,
|
||||||
empNo,
|
empNo,
|
||||||
role: 'fallback_admin',
|
role: 'fallback_admin',
|
||||||
|
bridge,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -741,6 +837,11 @@ function handleRequest(req, res) {
|
||||||
await handleLocalAdminUnlock(req, res)
|
await handleLocalAdminUnlock(req, res)
|
||||||
return
|
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) {
|
if (!ctx2.empNo) {
|
||||||
|
|
@ -857,8 +958,41 @@ function installSettingsSection(ctx, entry, hooks) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.inject(['settings'], (settingsCtx) => {
|
ctx.inject(['settings'], (settingsCtx) => {
|
||||||
ctx.logger?.info?.('[uds-auth] registering settings panel (new API)')
|
const settings = settingsCtx.settings
|
||||||
settingsCtx.settings.installSection(ctx, UDS_AUTH_SETTINGS_NAMESPACE, ConfigSchema, entry, hooksArg)
|
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()
|
await _rolesStore.init()
|
||||||
logLocalAdminStatus(ctx.logger || console)
|
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') })
|
_sessionAcl = new SessionAclStore({ ownersFile: resolve(__dirname, '..', 'session-owners.json') })
|
||||||
await _sessionAcl.init()
|
await _sessionAcl.init()
|
||||||
|
|
||||||
|
|
@ -1034,7 +1175,10 @@ async function initServices(ctx, config) {
|
||||||
authValueHeader: INTERNAL.authValueHeader,
|
authValueHeader: INTERNAL.authValueHeader,
|
||||||
},
|
},
|
||||||
session: INTERNAL.session,
|
session: INTERNAL.session,
|
||||||
}, _sessionStore, _rolesStore, { onSkillCredentials: rememberSkillCreds })
|
}, _sessionStore, _rolesStore, {
|
||||||
|
onSkillCredentials: rememberSkillCreds,
|
||||||
|
sessionBridge: _sessionBridge,
|
||||||
|
})
|
||||||
|
|
||||||
_apiHandlers = createApiHandlers(
|
_apiHandlers = createApiHandlers(
|
||||||
{ session: INTERNAL.session },
|
{ session: INTERNAL.session },
|
||||||
|
|
@ -1043,6 +1187,7 @@ async function initServices(ctx, config) {
|
||||||
{
|
{
|
||||||
skillCredentials: _skillCredentials,
|
skillCredentials: _skillCredentials,
|
||||||
retainSkillCredentialsOnLogout: () => _currentConfig?.retainSkillCredentialsOnLogout !== false,
|
retainSkillCredentialsOnLogout: () => _currentConfig?.retainSkillCredentialsOnLogout !== false,
|
||||||
|
sessionBridge: _sessionBridge,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1080,6 +1225,7 @@ async function initServices(ctx, config) {
|
||||||
const identityDeps = {
|
const identityDeps = {
|
||||||
sessionStore: _sessionStore,
|
sessionStore: _sessionStore,
|
||||||
rolesStore: _rolesStore,
|
rolesStore: _rolesStore,
|
||||||
|
sessionBridge: _sessionBridge,
|
||||||
}
|
}
|
||||||
const resolveIdentity = (req) => resolveIdentityFromRequest(req, identityDeps)
|
const resolveIdentity = (req) => resolveIdentityFromRequest(req, identityDeps)
|
||||||
const resolveIdentitySync = (req) => resolveIdentityFromRequestSync(req, identityDeps)
|
const resolveIdentitySync = (req) => resolveIdentityFromRequestSync(req, identityDeps)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { UdsClient } from '../uds/client.js'
|
||||||
import { UdsValidator } from '../uds/validator.js'
|
import { UdsValidator } from '../uds/validator.js'
|
||||||
import { ROLES } from '../roles.js'
|
import { ROLES } from '../roles.js'
|
||||||
import { searchUserByEmpNoToken } from '../uds/user-search.js'
|
import { searchUserByEmpNoToken } from '../uds/user-search.js'
|
||||||
|
import { readBridgeFromRequest } from '../session-bridge.js'
|
||||||
|
import { rememberBrowserIdentity } from '../identity-cache.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* auth-middleware
|
* auth-middleware
|
||||||
|
|
@ -19,6 +21,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
|
||||||
const onSkillCredentials = typeof hooks.onSkillCredentials === 'function'
|
const onSkillCredentials = typeof hooks.onSkillCredentials === 'function'
|
||||||
? hooks.onSkillCredentials
|
? hooks.onSkillCredentials
|
||||||
: null
|
: null
|
||||||
|
const sessionBridge = hooks.sessionBridge || null
|
||||||
const udsClient = new UdsClient(config.udsAuth)
|
const udsClient = new UdsClient(config.udsAuth)
|
||||||
const validatorConfig = {
|
const validatorConfig = {
|
||||||
...config.udsAuth,
|
...config.udsAuth,
|
||||||
|
|
@ -34,9 +37,29 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
|
||||||
if (udsMatch) return { empNo: decodeURIComponent(udsMatch[1].trim()), kind: 'uds' }
|
if (udsMatch) return { empNo: decodeURIComponent(udsMatch[1].trim()), kind: 'uds' }
|
||||||
const fbMatch = cookieHeader?.match(/UDS_FALLBACK_USER=([^;]+)/)
|
const fbMatch = cookieHeader?.match(/UDS_FALLBACK_USER=([^;]+)/)
|
||||||
if (fbMatch) return { empNo: decodeURIComponent(fbMatch[1].trim()), kind: 'fallback' }
|
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
|
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) {
|
function applyProfile(userContext, profile, credentials) {
|
||||||
userContext.userId = profile.empNo
|
userContext.userId = profile.empNo
|
||||||
userContext.empNo = profile.empNo
|
userContext.empNo = profile.empNo
|
||||||
|
|
@ -109,12 +132,19 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
|
||||||
ctx.empNo = empNo
|
ctx.empNo = empNo
|
||||||
ctx.role = role
|
ctx.role = role
|
||||||
ctx.permissions = rolesStore.resolvePermissions(empNo, 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) {
|
async function authMiddleware(ctx, next) {
|
||||||
const { req } = ctx
|
const { req } = ctx
|
||||||
const cookieHeader = req.headers.cookie || ''
|
const extracted = extractIdentity(req)
|
||||||
const extracted = extractEmpNo(cookieHeader)
|
|
||||||
|
|
||||||
if (!extracted) return next()
|
if (!extracted) return next()
|
||||||
|
|
||||||
|
|
@ -123,6 +153,9 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
|
||||||
// 旧 trust 会话 / 无姓名部门:强制用 token 重查用户信息
|
// 旧 trust 会话 / 无姓名部门:强制用 token 重查用户信息
|
||||||
if (userContext && extracted.kind === 'uds' && needsProfileUpgrade(userContext)) {
|
if (userContext && extracted.kind === 'uds' && needsProfileUpgrade(userContext)) {
|
||||||
const credentials = udsValidator.extractCredentials(req)
|
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)) {
|
if (credentials && credentials.empNo === extracted.empNo && udsValidator.validateCredentials(credentials)) {
|
||||||
const profile = await verifyEmpNoAndToken(credentials.empNo, credentials.token)
|
const profile = await verifyEmpNoAndToken(credentials.empNo, credentials.token)
|
||||||
if (profile) {
|
if (profile) {
|
||||||
|
|
@ -149,7 +182,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
|
||||||
return next()
|
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') {
|
if (extracted.kind === 'fallback') {
|
||||||
const empNo = extracted.empNo || 'administrator'
|
const empNo = extracted.empNo || 'administrator'
|
||||||
userContext = {
|
userContext = {
|
||||||
|
|
@ -159,7 +192,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
|
||||||
displayName: 'Fallback Administrator',
|
displayName: 'Fallback Administrator',
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
role: ROLES.FALLBACK_ADMIN,
|
role: ROLES.FALLBACK_ADMIN,
|
||||||
authMode: 'fallback-cookie',
|
authMode: extracted.via === 'bridge' ? 'fallback-bridge' : 'fallback-cookie',
|
||||||
authenticatedAt: new Date().toISOString(),
|
authenticatedAt: new Date().toISOString(),
|
||||||
lastActiveAt: new Date().toISOString(),
|
lastActiveAt: new Date().toISOString(),
|
||||||
sessionCreatedAt: new Date().toISOString(),
|
sessionCreatedAt: new Date().toISOString(),
|
||||||
|
|
@ -169,6 +202,9 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = udsValidator.extractCredentials(req)
|
const credentials = udsValidator.extractCredentials(req)
|
||||||
|
|| (extracted.ssoToken
|
||||||
|
? { empNo: extracted.empNo, token: extracted.ssoToken, lang: 'zh-CN' }
|
||||||
|
: null)
|
||||||
if (!credentials || !udsValidator.validateCredentials(credentials)) {
|
if (!credentials || !udsValidator.validateCredentials(credentials)) {
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
197
uds-auth/lib/session-bridge.js
Normal file
197
uds-auth/lib/session-bridge.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "uds-auth",
|
"name": "uds-auth",
|
||||||
"version": "0.2.13",
|
"version": "0.2.19",
|
||||||
"description": "UDS authentication plugin for DeepSeek Harness with multi-tenant session isolation",
|
"description": "UDS authentication plugin for DeepSeek Harness with multi-tenant session isolation",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue