oclaw/uds-auth/lib/task-capability.js
oliver df97c5e042
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run
fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
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>
2026-10-12 02:11:09 +08:00

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