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>
145 lines
4.1 KiB
JavaScript
145 lines
4.1 KiB
JavaScript
/**
|
|
* R06: short-lived task capabilities for skill/cron (not browser session bearers).
|
|
* Bound to empNo + optional dshSessionId + audience + scope + expiry; revocable.
|
|
*/
|
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
|
|
function hashToken(token) {
|
|
return createHash('sha256').update(String(token)).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
|
|
}
|
|
}
|
|
|
|
export class TaskCapabilityStore {
|
|
/**
|
|
* @param {{ defaultTtlSeconds?: number, maxTtlSeconds?: number }} [opts]
|
|
*/
|
|
constructor(opts = {}) {
|
|
this._defaultTtl = Math.max(60, Number(opts.defaultTtlSeconds) || 3600)
|
|
this._maxTtl = Math.max(this._defaultTtl, Number(opts.maxTtlSeconds) || 24 * 3600)
|
|
/** @type {Map<string, object>} id → record */
|
|
this._byId = new Map()
|
|
/** @type {Map<string, string>} tokenHash → id */
|
|
this._byHash = new Map()
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* empNo: string,
|
|
* dshSessionId?: string|null,
|
|
* audience?: string,
|
|
* scopes?: string[],
|
|
* ttlSeconds?: number,
|
|
* mintedBy?: string,
|
|
* }} input
|
|
*/
|
|
mint(input) {
|
|
for (const [id, row] of this._byId) {
|
|
if (row.expiresAt <= Date.now()) this.revoke(id)
|
|
}
|
|
if (this._byId.size >= 5000) throw new Error('capability_capacity')
|
|
const empNo = String(input?.empNo || '').trim()
|
|
if (!empNo) throw new Error('capability_empNo_required')
|
|
const ttl = Math.min(
|
|
this._maxTtl,
|
|
Math.max(30, Number(input.ttlSeconds) || this._defaultTtl),
|
|
)
|
|
const id = randomBytes(12).toString('hex')
|
|
const token = randomBytes(32).toString('hex')
|
|
const tokenHash = hashToken(token)
|
|
const now = Date.now()
|
|
const record = {
|
|
id,
|
|
tokenHash,
|
|
empNo,
|
|
dshSessionId: input.dshSessionId ? String(input.dshSessionId) : null,
|
|
audience: String(input.audience || 'uds-auth-agent'),
|
|
scopes: Array.isArray(input.scopes) ? input.scopes.map(String) : ['outbound', 'credentials'],
|
|
mintedBy: input.mintedBy || null,
|
|
createdAt: now,
|
|
expiresAt: now + ttl * 1000,
|
|
revoked: false,
|
|
}
|
|
this._byId.set(id, record)
|
|
this._byHash.set(tokenHash, id)
|
|
return {
|
|
id,
|
|
token,
|
|
empNo: record.empNo,
|
|
dshSessionId: record.dshSessionId,
|
|
audience: record.audience,
|
|
scopes: record.scopes,
|
|
expiresAt: record.expiresAt,
|
|
expiresIn: ttl,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} token
|
|
* @param {{ audience?: string, scope?: string, dshSessionId?: string|null }} [opts]
|
|
*/
|
|
verify(token, opts = {}) {
|
|
const t = String(token || '').trim()
|
|
if (!t || t.length < 16) return null
|
|
const id = this._byHash.get(hashToken(t))
|
|
if (!id) return null
|
|
const row = this._byId.get(id)
|
|
if (!row || row.revoked) return null
|
|
if (row.expiresAt && Date.now() > row.expiresAt) {
|
|
this.revoke(id)
|
|
return null
|
|
}
|
|
if (!safeEqualHex(row.tokenHash, hashToken(t))) return null
|
|
if (opts.audience && row.audience !== opts.audience) return null
|
|
if (opts.scope && !(row.scopes || []).includes(opts.scope)) return null
|
|
if (opts.dshSessionId != null && row.dshSessionId
|
|
&& String(opts.dshSessionId) !== String(row.dshSessionId)) {
|
|
return null
|
|
}
|
|
return {
|
|
id: row.id,
|
|
empNo: row.empNo,
|
|
dshSessionId: row.dshSessionId,
|
|
audience: row.audience,
|
|
scopes: [...(row.scopes || [])],
|
|
expiresAt: row.expiresAt,
|
|
}
|
|
}
|
|
|
|
revoke(id) {
|
|
const row = this._byId.get(String(id || ''))
|
|
if (!row) return false
|
|
row.revoked = true
|
|
this._byHash.delete(row.tokenHash)
|
|
this._byId.delete(row.id)
|
|
return true
|
|
}
|
|
|
|
revokeEmpNo(empNo) {
|
|
const e = String(empNo || '').trim()
|
|
let n = 0
|
|
for (const [id, row] of [...this._byId]) {
|
|
if (row.empNo === e) {
|
|
this.revoke(id)
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
extractFromRequest(req) {
|
|
const h = req?.headers || {}
|
|
return (
|
|
h['x-uds-task-capability']
|
|
|| h['X-UDS-Task-Capability']
|
|
|| null
|
|
)
|
|
}
|
|
}
|