mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 15:23:27 +08:00
Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw "sign-in failed / add API key" on DSH Desktop because the remote.mux opens anonymously at boot and every early call was rejected terminally. - gateway: anonymous workspace/follow reaches dsh-acl's empty baseline; anonymous streams are parked until the carrier aborts instead of failing; anonymous workspace/initializeDefault answers "nothing created" - request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket or bridge (sync + async variants used by all callers) - dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup instead of 403, so the Desktop welcome read no longer fails - desktop-bootstrap: key/account projection is best-effort per reference - client: emit connection/reset after login so boot-time caches (settings describe mirror -> Settings > Models) re-read under the real principal - sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min); bridge expiry slides with its session; MemoryStore persisted to sessions.json (bearer hash only, userData AES-256-GCM sealed) - README: session lifetime/persistence and DSH compatibility notes Also includes previously uncommitted uds-auth 0.3.x work in this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
440 lines
14 KiB
JavaScript
440 lines
14 KiB
JavaScript
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
import { readFile } from 'node:fs/promises'
|
|
import { SessionStore, SESSION_ABSOLUTE_MAX_SECONDS } from './store.js'
|
|
import { atomicWriteJson } from '../utils/atomic-write.js'
|
|
import { loadOrCreateSecretKey, sealSecret, openSecret } from '../utils/secret-box.js'
|
|
|
|
function hashBearer(bearer) {
|
|
return createHash('sha256').update(String(bearer)).digest('hex')
|
|
}
|
|
|
|
function safeEqualHex(a, b) {
|
|
if (!a || !b || a.length !== b.length) return false
|
|
try {
|
|
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'))
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-memory auth session store, optionally persisted so a Host restart does not
|
|
* log everyone out (emergency admins have no SSO re-mint path).
|
|
* Primary key: sessionId. Auth proof: bearer (stored as SHA-256 hash only).
|
|
* empNo is a secondary index for logout-all / listing — not an auth credential.
|
|
*
|
|
* Persistence (opts.file + opts.dataDir): bearer hashes and expiry in clear,
|
|
* userData (may carry the UAC token) sealed with the plugin AES-GCM key.
|
|
*/
|
|
export class MemoryStore extends SessionStore {
|
|
/**
|
|
* @param {{ file?: string, dataDir?: string, logger?: any }} [opts]
|
|
*/
|
|
constructor(opts = {}) {
|
|
super()
|
|
this._file = opts.file || null
|
|
this._dataDir = opts.dataDir || null
|
|
this._logger = opts.logger || console
|
|
this._key = null
|
|
this._saveTimer = null
|
|
/** @type {Map<string, object>} sessionId → record */
|
|
this._sessions = new Map()
|
|
/** @type {Map<string, string>} bearerHash → sessionId */
|
|
this._byBearer = new Map()
|
|
/** @type {Map<string, Set<string>>} empNo → sessionIds */
|
|
this._byEmpNo = new Map()
|
|
/** @type {Map<string, object>} empNo → profile (cache only) */
|
|
this._profiles = new Map()
|
|
/** @type {Map<string, NodeJS.Timeout>} */
|
|
this._timers = new Map()
|
|
}
|
|
|
|
/** Load persisted sessions (if configured). Never throws: bad state is dropped. */
|
|
async init() {
|
|
if (!this._file) return
|
|
let raw = null
|
|
try {
|
|
raw = JSON.parse(await readFile(this._file, 'utf-8'))
|
|
} catch (err) {
|
|
if (err?.code !== 'ENOENT') this._logger.warn?.('[uds-auth:sessions] load failed, starting empty:', err.message)
|
|
}
|
|
const rows = Array.isArray(raw?.sessions) ? raw.sessions : []
|
|
try {
|
|
this._key = await loadOrCreateSecretKey(this._dataDir || '.', { hasEncryptedData: rows.length > 0 })
|
|
} catch (err) {
|
|
this._logger.warn?.('[uds-auth:sessions] secret key unavailable, sessions will not persist:', err.code || err.message)
|
|
this._file = null
|
|
return
|
|
}
|
|
const now = Date.now()
|
|
let restored = 0
|
|
for (const r of rows) {
|
|
if (!r?.sessionId || !r.bearerHash || !r.empNo) continue
|
|
if (!(r.expiresAt > now) || !(r.absoluteExpiresAt > now)) continue
|
|
const plain = openSecret(this._key, r.userData)
|
|
if (plain == null) continue
|
|
let userData
|
|
try { userData = JSON.parse(plain) } catch { continue }
|
|
const record = {
|
|
sessionId: String(r.sessionId),
|
|
bearerHash: String(r.bearerHash),
|
|
empNo: String(r.empNo),
|
|
kind: String(r.kind || 'uds'),
|
|
userData,
|
|
createdAt: Number(r.createdAt) || now,
|
|
expiresAt: Number(r.expiresAt),
|
|
absoluteExpiresAt: Number(r.absoluteExpiresAt),
|
|
}
|
|
this._sessions.set(record.sessionId, record)
|
|
this._byBearer.set(record.bearerHash, record.sessionId)
|
|
if (!this._byEmpNo.has(record.empNo)) this._byEmpNo.set(record.empNo, new Set())
|
|
this._byEmpNo.get(record.empNo).add(record.sessionId)
|
|
this._scheduleExpiry(record.sessionId, Math.ceil((record.expiresAt - now) / 1000))
|
|
if (userData.displayName || userData.username) {
|
|
this._profiles.set(record.empNo, {
|
|
empNo: record.empNo,
|
|
username: userData.username,
|
|
displayName: userData.displayName,
|
|
department: userData.department,
|
|
organization: userData.organization,
|
|
email: userData.email,
|
|
phone: userData.phone,
|
|
})
|
|
}
|
|
restored++
|
|
}
|
|
if (restored !== rows.length) this._scheduleSave()
|
|
if (restored) this._logger.info?.('[uds-auth:sessions] restored %d session(s)', restored)
|
|
}
|
|
|
|
_scheduleSave() {
|
|
if (!this._file || !this._key || this._saveTimer) return
|
|
this._saveTimer = setTimeout(() => {
|
|
this._saveTimer = null
|
|
void this.flush()
|
|
}, 200)
|
|
if (typeof this._saveTimer.unref === 'function') this._saveTimer.unref()
|
|
}
|
|
|
|
/** Write current sessions now (also used on shutdown / tests). */
|
|
async flush() {
|
|
if (!this._file || !this._key) return
|
|
if (this._saveTimer) {
|
|
clearTimeout(this._saveTimer)
|
|
this._saveTimer = null
|
|
}
|
|
const now = Date.now()
|
|
const sessions = []
|
|
for (const row of this._sessions.values()) {
|
|
if (!(row.expiresAt > now) || !(row.absoluteExpiresAt > now)) continue
|
|
sessions.push({
|
|
sessionId: row.sessionId,
|
|
bearerHash: row.bearerHash,
|
|
empNo: row.empNo,
|
|
kind: row.kind,
|
|
createdAt: row.createdAt,
|
|
expiresAt: row.expiresAt,
|
|
absoluteExpiresAt: row.absoluteExpiresAt,
|
|
userData: sealSecret(this._key, JSON.stringify(row.userData || {})),
|
|
})
|
|
}
|
|
try {
|
|
await atomicWriteJson(this._file, { version: 1, sessions }, { mode: 0o600 })
|
|
} catch (err) {
|
|
this._logger.warn?.('[uds-auth:sessions] save failed:', err.message)
|
|
}
|
|
}
|
|
|
|
_purgeExpired(sessionId) {
|
|
const row = this._sessions.get(sessionId)
|
|
if (!row) return null
|
|
if (row.expiresAt && Date.now() > row.expiresAt) {
|
|
this._removeRecord(sessionId, row)
|
|
return null
|
|
}
|
|
return row
|
|
}
|
|
|
|
_removeRecord(sessionId, row) {
|
|
if (this._sessions.has(sessionId)) this._scheduleSave()
|
|
this._sessions.delete(sessionId)
|
|
if (row?.bearerHash) this._byBearer.delete(row.bearerHash)
|
|
if (row?.empNo) {
|
|
const set = this._byEmpNo.get(row.empNo)
|
|
if (set) {
|
|
set.delete(sessionId)
|
|
if (set.size === 0) this._byEmpNo.delete(row.empNo)
|
|
}
|
|
}
|
|
if (this._timers.has(sessionId)) {
|
|
clearTimeout(this._timers.get(sessionId))
|
|
this._timers.delete(sessionId)
|
|
}
|
|
}
|
|
|
|
_scheduleExpiry(sessionId, ttlSeconds) {
|
|
if (this._timers.has(sessionId)) {
|
|
clearTimeout(this._timers.get(sessionId))
|
|
this._timers.delete(sessionId)
|
|
}
|
|
if (!ttlSeconds || ttlSeconds <= 0) return
|
|
const timer = setTimeout(() => {
|
|
const row = this._sessions.get(sessionId)
|
|
if (row) this._removeRecord(sessionId, row)
|
|
}, ttlSeconds * 1000)
|
|
// Do not keep the process alive solely for session TTLs (tests / short-lived hosts).
|
|
if (typeof timer.unref === 'function') timer.unref()
|
|
this._timers.set(sessionId, timer)
|
|
}
|
|
|
|
_publicRecord(row) {
|
|
if (!row) return null
|
|
return {
|
|
sessionId: row.sessionId,
|
|
empNo: row.empNo,
|
|
kind: row.kind,
|
|
userData: { ...row.userData },
|
|
createdAt: row.createdAt,
|
|
expiresAt: row.expiresAt,
|
|
absoluteExpiresAt: row.absoluteExpiresAt,
|
|
}
|
|
}
|
|
|
|
async create(userData, ttlSeconds = 1800) {
|
|
const empNo = String(userData?.empNo || '').trim()
|
|
if (!empNo) throw new Error('session_empNo_required')
|
|
const sessionId = randomBytes(16).toString('hex')
|
|
const bearer = randomBytes(32).toString('hex')
|
|
const bearerHash = hashBearer(bearer)
|
|
const now = Date.now()
|
|
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
|
|
// Idle TTL slides via touch(); absolute lifetime is a fixed 7 days. (Was min(ttl, 7d),
|
|
// which capped every session at its 30-min idle TTL and made sliding a no-op.)
|
|
const absoluteCap = SESSION_ABSOLUTE_MAX_SECONDS
|
|
const record = {
|
|
sessionId,
|
|
bearerHash,
|
|
empNo,
|
|
kind: (() => {
|
|
const mode = String(userData?.authMode || '')
|
|
if (mode.includes('local-admin') || mode === 'sealed_box' || mode === 'local_admin') {
|
|
return 'sealed_box'
|
|
}
|
|
if (mode.includes('fallback') || mode === 'fallback-password' || mode === 'fallback-login') {
|
|
return 'fallback'
|
|
}
|
|
if (empNo === 'administrator') return 'fallback'
|
|
return 'uds'
|
|
})(),
|
|
userData: { ...userData },
|
|
createdAt: now,
|
|
expiresAt: now + ttl * 1000,
|
|
absoluteExpiresAt: now + absoluteCap * 1000,
|
|
}
|
|
this._sessions.set(sessionId, record)
|
|
this._byBearer.set(bearerHash, sessionId)
|
|
if (!this._byEmpNo.has(empNo)) this._byEmpNo.set(empNo, new Set())
|
|
this._byEmpNo.get(empNo).add(sessionId)
|
|
this._scheduleExpiry(sessionId, ttl)
|
|
this._scheduleSave()
|
|
if (userData.displayName || userData.username) {
|
|
this._profiles.set(empNo, {
|
|
empNo,
|
|
username: userData.username,
|
|
displayName: userData.displayName,
|
|
department: userData.department,
|
|
organization: userData.organization,
|
|
email: userData.email,
|
|
phone: userData.phone,
|
|
})
|
|
}
|
|
return { sessionId, bearer, record: this._publicRecord(record) }
|
|
}
|
|
|
|
_lookupBearer(bearer) {
|
|
const t = String(bearer || '').trim()
|
|
if (!t || t.length < 16) return null
|
|
const sessionId = this._byBearer.get(hashBearer(t))
|
|
if (!sessionId) return null
|
|
const row = this._purgeExpired(sessionId)
|
|
if (!row) return null
|
|
if (!safeEqualHex(row.bearerHash, hashBearer(t))) return null
|
|
if (row.absoluteExpiresAt && Date.now() > row.absoluteExpiresAt) {
|
|
this._removeRecord(sessionId, row)
|
|
return null
|
|
}
|
|
return row
|
|
}
|
|
|
|
async getByBearer(bearer) {
|
|
return this._publicRecord(this._lookupBearer(bearer))
|
|
}
|
|
|
|
getByBearerSync(bearer) {
|
|
return this._publicRecord(this._lookupBearer(bearer))
|
|
}
|
|
|
|
async getBySessionId(sessionId) {
|
|
const id = String(sessionId || '').trim()
|
|
if (!id) return null
|
|
return this._publicRecord(this._purgeExpired(id))
|
|
}
|
|
|
|
getBySessionIdSync(sessionId) {
|
|
const id = String(sessionId || '').trim()
|
|
if (!id) return null
|
|
return this._publicRecord(this._purgeExpired(id))
|
|
}
|
|
|
|
async touch(sessionId, ttlSeconds) {
|
|
const row = this._purgeExpired(String(sessionId || ''))
|
|
if (!row) return null
|
|
const ttl = Math.max(60, Number(ttlSeconds) || 1800)
|
|
const absLeft = row.absoluteExpiresAt
|
|
? Math.max(0, Math.floor((row.absoluteExpiresAt - Date.now()) / 1000))
|
|
: ttl
|
|
const nextTtl = Math.min(ttl, absLeft || ttl)
|
|
if (nextTtl <= 0) {
|
|
this._removeRecord(row.sessionId, row)
|
|
return null
|
|
}
|
|
row.expiresAt = Date.now() + nextTtl * 1000
|
|
row.userData = {
|
|
...row.userData,
|
|
lastActiveAt: new Date().toISOString(),
|
|
}
|
|
this._scheduleExpiry(row.sessionId, nextTtl)
|
|
this._scheduleSave()
|
|
return this._publicRecord(row)
|
|
}
|
|
|
|
async revokeSession(sessionId) {
|
|
const row = this._sessions.get(String(sessionId || ''))
|
|
if (!row) return false
|
|
this._removeRecord(row.sessionId, row)
|
|
return true
|
|
}
|
|
|
|
async revokeBearer(bearer) {
|
|
const row = this._lookupBearer(bearer)
|
|
if (!row) return false
|
|
this._removeRecord(row.sessionId, row)
|
|
return true
|
|
}
|
|
|
|
async revokeEmpNo(empNo) {
|
|
const e = String(empNo || '').trim()
|
|
const set = this._byEmpNo.get(e)
|
|
if (!set) return 0
|
|
let n = 0
|
|
for (const sid of [...set]) {
|
|
const row = this._sessions.get(sid)
|
|
if (row) {
|
|
this._removeRecord(sid, row)
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
/**
|
|
* D04: revoke only sessions matching predicate (e.g. password-mode, not sealed-box).
|
|
* @param {string} empNo
|
|
* @param {(row: object) => boolean} predicate
|
|
*/
|
|
async revokeEmpNoMatching(empNo, predicate) {
|
|
const e = String(empNo || '').trim()
|
|
const set = this._byEmpNo.get(e)
|
|
if (!set || typeof predicate !== 'function') return 0
|
|
let n = 0
|
|
for (const sid of [...set]) {
|
|
const row = this._sessions.get(sid)
|
|
if (row && predicate(row)) {
|
|
this._removeRecord(sid, row)
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
async setProfile(empNo, profile) {
|
|
const e = String(empNo || '').trim()
|
|
if (!e || !profile) return
|
|
this._profiles.set(e, { ...profile, empNo: e })
|
|
}
|
|
|
|
async getProfile(empNo) {
|
|
const row = this._profiles.get(String(empNo || '').trim())
|
|
return row ? { ...row } : null
|
|
}
|
|
|
|
async clear() {
|
|
this._scheduleSave()
|
|
for (const t of this._timers.values()) clearTimeout(t)
|
|
this._timers.clear()
|
|
this._sessions.clear()
|
|
this._byBearer.clear()
|
|
this._byEmpNo.clear()
|
|
this._profiles.clear()
|
|
}
|
|
|
|
/**
|
|
* Legacy: return first active session userData for empNo (profile-like).
|
|
* Does NOT prove the caller is authenticated as that empNo.
|
|
*/
|
|
async get(empNo) {
|
|
const e = String(empNo || '').trim()
|
|
const set = this._byEmpNo.get(e)
|
|
if (!set || set.size === 0) return null
|
|
for (const sid of set) {
|
|
const row = this._purgeExpired(sid)
|
|
if (row) return { ...row.userData, _sessionId: row.sessionId }
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Legacy compat: create a new session for empNo (multi-device safe).
|
|
* Returns the public userData; caller should also capture bearer via create().
|
|
*/
|
|
async setex(empNo, seconds, userData) {
|
|
const data = { ...userData, empNo: String(empNo) }
|
|
const { bearer, sessionId, record } = await this.create(data, seconds)
|
|
// Stash last mint so login handlers can read bearer without API break.
|
|
this._lastMint = { empNo: String(empNo), bearer, sessionId }
|
|
return record.userData
|
|
}
|
|
|
|
takeLastMint() {
|
|
const m = this._lastMint || null
|
|
this._lastMint = null
|
|
return m
|
|
}
|
|
|
|
async delete(empNo) {
|
|
await this.revokeEmpNo(empNo)
|
|
}
|
|
|
|
async has(empNo) {
|
|
const e = String(empNo || '').trim()
|
|
const set = this._byEmpNo.get(e)
|
|
if (!set) return false
|
|
for (const sid of set) {
|
|
if (this._purgeExpired(sid)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
async keys() {
|
|
const out = new Set()
|
|
for (const [empNo, set] of this._byEmpNo) {
|
|
for (const sid of set) {
|
|
if (this._purgeExpired(sid)) {
|
|
out.add(empNo)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return [...out]
|
|
}
|
|
}
|