mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 23:30:53 +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/inspect-session-sample.cjs
|
||||
scripts/merge-sessions-into-workspace.ps1
|
||||
|
||||
session-bridge.json
|
||||
roles.json
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
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",
|
||||
"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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue