UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

304 lines (303 loc) 10.7 kB
import { n as normalizeDeviceAuthScopes } from "./device-auth-C-STNejO.js"; import { r as roleScopesAllow, t as resolveMissingRequestedScope } from "./operator-scope-compat-iV7_Lmth.js"; import { _ as verifyPairingToken, g as generatePairingToken, w as persistDevicePairingStoreState } from "./device-bootstrap-DRFMRCcr.js"; import { A as withDevicePairingLock, E as normalizeDevicePairingRole, S as loadDevicePairingState, T as normalizeDevicePairingId, g as resolveNodePairingGeneration, s as listApprovedPairedDeviceRoles, t as clearNodePairingGenerationState, x as cloneDevicePairingTokens } from "./device-pairing-DFIQp3ZY.js"; //#region src/infra/device-pairing-tokens.ts const OPERATOR_SCOPE_PREFIX = "operator."; const SHARED_GATEWAY_AUTH_ISSUER_KIND = "shared-gateway-auth"; const BROWSER_DEVICE_CLIENT_IDS = /* @__PURE__ */ new Set(["openclaw-control-ui", "webchat-ui"]); const BROWSER_DEVICE_CLIENT_MODE = "webchat"; function getPairedDeviceFromState(state, deviceId) { return state.pairedByDeviceId[normalizeDevicePairingId(deviceId)] ?? null; } function isBrowserRelatedPairedDevice(device) { if (device.clientMode?.trim().toLowerCase() === BROWSER_DEVICE_CLIENT_MODE) return true; const clientId = device.clientId?.trim().toLowerCase(); return clientId ? BROWSER_DEVICE_CLIENT_IDS.has(clientId) : false; } function deviceTokenIssuerMatches(entry, issuer) { if (!issuer) return !entry.issuer; return entry.issuer?.kind === issuer.kind && entry.issuer.generation === issuer.generation; } /** Build one freshly generated role token while preserving requested lifecycle fields. */ function createDeviceAuthToken(params) { return { token: generatePairingToken(), role: params.role, scopes: params.scopes, issuer: params.issuer ?? (params.preserveExistingIssuer ? params.existing?.issuer : void 0), createdAtMs: params.existing?.createdAtMs ?? params.now, rotatedAtMs: params.rotatedAtMs, revokedAtMs: void 0, lastUsedAtMs: params.existing?.lastUsedAtMs }; } /** Select scopes owned by one device-token role. */ function resolveRoleTokenScopes(role, scopes) { const normalized = normalizeDeviceAuthScopes(scopes); if (role === "operator") return normalized.filter((scope) => scope.startsWith(OPERATOR_SCOPE_PREFIX)); return normalized.filter((scope) => !scope.startsWith(OPERATOR_SCOPE_PREFIX)); } function resolveApprovedDeviceScopeBaseline(device) { const baseline = device.approvedScopes ?? device.scopes; if (!Array.isArray(baseline)) return null; return normalizeDeviceAuthScopes(baseline); } function scopesWithinApprovedDeviceBaseline(params) { if (!params.approvedScopes) return false; return roleScopesAllow({ role: params.role, requestedScopes: params.scopes, allowedScopes: params.approvedScopes }); } /** Summarize token metadata without exposing bearer token strings. */ function summarizeDeviceTokens(tokens) { if (!tokens) return; const summaries = Object.values(tokens).map((token) => ({ role: token.role, scopes: token.scopes, createdAtMs: token.createdAtMs, rotatedAtMs: token.rotatedAtMs, revokedAtMs: token.revokedAtMs, lastUsedAtMs: token.lastUsedAtMs })).toSorted((a, b) => a.role.localeCompare(b.role)); return summaries.length > 0 ? summaries : void 0; } /** Verify a device role token, scope it to the approval baseline, and mark last use. */ async function verifyDeviceToken(params) { return await withDevicePairingLock(async () => { const state = await loadDevicePairingState(params.baseDir); const device = getPairedDeviceFromState(state, params.deviceId); if (!device) return { ok: false, reason: "device-not-paired" }; const role = normalizeDevicePairingRole(params.role); if (!role) return { ok: false, reason: "role-missing" }; const entry = device.tokens?.[role]; if (!entry) return { ok: false, reason: "token-missing" }; if (entry.revokedAtMs) return { ok: false, reason: "token-revoked" }; if (!verifyPairingToken(params.token, entry.token)) return { ok: false, reason: "token-mismatch" }; if (entry.issuer?.kind === SHARED_GATEWAY_AUTH_ISSUER_KIND && entry.issuer.generation !== params.requiredSharedGatewaySessionGeneration) return { ok: false, reason: "issuer-generation-stale" }; if (!entry.issuer && params.requiredSharedGatewaySessionGeneration !== void 0 && isBrowserRelatedPairedDevice(device)) return { ok: false, reason: "legacy-browser-token" }; const approvedScopes = resolveApprovedDeviceScopeBaseline(device); if (!scopesWithinApprovedDeviceBaseline({ role, scopes: entry.scopes, approvedScopes })) return { ok: false, reason: "scope-mismatch" }; const requestedScopes = normalizeDeviceAuthScopes(params.scopes); if (!roleScopesAllow({ role, requestedScopes, allowedScopes: entry.scopes })) return { ok: false, reason: "scope-mismatch" }; const now = Date.now(); entry.lastUsedAtMs = now; device.tokens ??= {}; device.tokens[role] = entry; device.lastSeenAtMs = now; device.lastSeenReason = "device-token-auth"; state.pairedByDeviceId[device.deviceId] = device; persistDevicePairingStoreState(state, params.baseDir, "paired"); return entry.issuer ? { ok: true, issuer: entry.issuer } : { ok: true }; }); } /** Return a reusable token for a role or issue one within the approved scope baseline. */ async function ensureDeviceToken(params) { return await withDevicePairingLock(async () => { const state = await loadDevicePairingState(params.baseDir); if (params.isIssuanceCurrent?.() === false) return null; const requestedScopes = normalizeDeviceAuthScopes(params.scopes); const context = resolveDeviceTokenUpdateContext({ state, deviceId: params.deviceId, role: params.role }); if (!context) return null; const { device, role, tokens, existing } = context; const previousNodeGeneration = resolveNodePairingGeneration(device); const approvedScopes = resolveApprovedDeviceScopeBaseline(device); if (!scopesWithinApprovedDeviceBaseline({ role, scopes: requestedScopes, approvedScopes })) return null; if (existing && !existing.revokedAtMs) { const existingWithinApproved = scopesWithinApprovedDeviceBaseline({ role, scopes: existing.scopes, approvedScopes }); const issuerAllowsReuse = deviceTokenIssuerMatches(existing, params.issuer); if (existingWithinApproved && issuerAllowsReuse && roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes })) return existing; } const now = Date.now(); const next = createDeviceAuthToken({ role, scopes: requestedScopes, issuer: params.issuer, existing, now, rotatedAtMs: existing ? now : void 0 }); tokens[role] = next; device.tokens = tokens; clearNodePairingGenerationState(device, previousNodeGeneration); state.pairedByDeviceId[device.deviceId] = device; persistDevicePairingStoreState(state, params.baseDir, "paired"); return next; }); } function resolveDeviceTokenUpdateContext(params) { const device = getPairedDeviceFromState(params.state, params.deviceId); if (!device) return null; const role = normalizeDevicePairingRole(params.role); if (!role) return null; if (!listApprovedPairedDeviceRoles(device).includes(role)) return null; const tokens = cloneDevicePairingTokens(device); return { device, role, tokens, existing: tokens[role] }; } /** Rotate a role token inside the device's approved scope baseline. */ async function rotateDeviceToken(params) { return await withDevicePairingLock(async () => { const state = await loadDevicePairingState(params.baseDir); const context = resolveDeviceTokenUpdateContext({ state, deviceId: params.deviceId, role: params.role }); if (!context) return { ok: false, reason: "unknown-device-or-role" }; const { device, role, tokens, existing } = context; const previousNodeGeneration = resolveNodePairingGeneration(device); const requestedScopes = normalizeDeviceAuthScopes(params.scopes ?? existing?.scopes ?? device.scopes); const approvedScopes = resolveApprovedDeviceScopeBaseline(device); if (!approvedScopes) return { ok: false, reason: "missing-approved-scope-baseline" }; if (!scopesWithinApprovedDeviceBaseline({ role, scopes: requestedScopes, approvedScopes })) return { ok: false, reason: "scope-outside-approved-baseline" }; if (params.callerScopes) { const missingScope = resolveMissingRequestedScope({ role, requestedScopes, allowedScopes: params.callerScopes }); if (missingScope) return { ok: false, reason: "caller-missing-scope", scope: missingScope }; } const now = Date.now(); const next = createDeviceAuthToken({ role, scopes: requestedScopes, existing, preserveExistingIssuer: true, now, rotatedAtMs: now }); tokens[role] = next; device.tokens = tokens; clearNodePairingGenerationState(device, previousNodeGeneration); state.pairedByDeviceId[device.deviceId] = device; persistDevicePairingStoreState(state, params.baseDir, "paired"); return { ok: true, entry: next }; }); } /** Revoke one active role token after optional caller-scope authorization. */ async function revokeDeviceToken(params) { return await withDevicePairingLock(async () => { const state = await loadDevicePairingState(params.baseDir); const context = resolveDeviceTokenUpdateContext({ state, deviceId: params.deviceId, role: params.role }); if (!context || !context.existing) return { ok: false, reason: "unknown-device-or-role" }; const { device, role, tokens, existing } = context; const previousNodeGeneration = resolveNodePairingGeneration(device); const targetScopes = normalizeDeviceAuthScopes(Array.isArray(existing.scopes) ? existing.scopes : device.scopes); if (params.callerScopes) { const missingScope = resolveMissingRequestedScope({ role, requestedScopes: targetScopes, allowedScopes: params.callerScopes }); if (missingScope) return { ok: false, reason: "caller-missing-scope", scope: missingScope }; } const entry = { ...existing, revokedAtMs: Date.now() }; tokens[role] = entry; device.tokens = tokens; clearNodePairingGenerationState(device, previousNodeGeneration); state.pairedByDeviceId[device.deviceId] = device; persistDevicePairingStoreState(state, params.baseDir, "paired"); return { ok: true, entry }; }); } //#endregion export { rotateDeviceToken as a, revokeDeviceToken as i, ensureDeviceToken as n, summarizeDeviceTokens as o, resolveRoleTokenScopes as r, verifyDeviceToken as s, createDeviceAuthToken as t };