feat: wire role-scoped device creds

This commit is contained in:
Peter Steinberger
2026-01-20 11:35:08 +00:00
parent dfbf6ac263
commit d8cc7db5e6
17 changed files with 633 additions and 26 deletions

View File

@@ -0,0 +1,107 @@
import Foundation
public struct DeviceAuthEntry: Codable, Sendable {
public let token: String
public let role: String
public let scopes: [String]
public let updatedAtMs: Int
public init(token: String, role: String, scopes: [String], updatedAtMs: Int) {
self.token = token
self.role = role
self.scopes = scopes
self.updatedAtMs = updatedAtMs
}
}
private struct DeviceAuthStoreFile: Codable {
var version: Int
var deviceId: String
var tokens: [String: DeviceAuthEntry]
}
public enum DeviceAuthStore {
private static let fileName = "device-auth.json"
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
let role = normalizeRole(role)
return store.tokens[role]
}
public static func storeToken(
deviceId: String,
role: String,
token: String,
scopes: [String] = []
) -> DeviceAuthEntry {
let normalizedRole = normalizeRole(role)
var next = readStore()
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
let entry = DeviceAuthEntry(
token: token,
role: normalizedRole,
scopes: normalizeScopes(scopes),
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000)
)
if next == nil {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
next?.tokens[normalizedRole] = entry
if let store = next {
writeStore(store)
}
return entry
}
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
let normalizedRole = normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
writeStore(store)
}
private static func normalizeRole(_ role: String) -> String {
role.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func normalizeScopes(_ scopes: [String]) -> [String] {
let trimmed = scopes
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
return Array(Set(trimmed)).sorted()
}
private static func fileURL() -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
}
private static func readStore() -> DeviceAuthStoreFile? {
let url = fileURL()
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
}
guard decoded.version == 1 else { return nil }
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = fileURL()
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
let data = try JSONEncoder().encode(store)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
// best-effort only
}
}
}

View File

@@ -261,6 +261,8 @@ public actor GatewayChannelActor {
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
let clientId = options.clientId
let clientMode = options.clientMode
let role = options.role
let scopes = options.scopes
let reqId = UUID().uuidString
var client: [String: ProtoAnyCodable] = [
@@ -283,8 +285,8 @@ public actor GatewayChannelActor {
"caps": ProtoAnyCodable(options.caps),
"locale": ProtoAnyCodable(primaryLocale),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(options.role),
"scopes": ProtoAnyCodable(options.scopes),
"role": ProtoAnyCodable(role),
"scopes": ProtoAnyCodable(scopes),
]
if !options.commands.isEmpty {
params["commands"] = ProtoAnyCodable(options.commands)
@@ -292,24 +294,27 @@ public actor GatewayChannelActor {
if !options.permissions.isEmpty {
params["permissions"] = ProtoAnyCodable(options.permissions)
}
if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
let identity = DeviceIdentityStore.loadOrCreate()
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
let authToken = storedToken ?? self.token
let canFallbackToShared = storedToken != nil && self.token != nil
if let authToken {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let connectNonce = try await self.waitForConnectChallenge()
let scopes = options.scopes.joined(separator: ",")
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
identity.deviceId,
clientId,
clientMode,
options.role,
scopes,
role,
scopesValue,
String(signedAtMs),
self.token ?? "",
authToken ?? "",
]
if let connectNonce {
payloadParts.append(connectNonce)
@@ -336,11 +341,22 @@ public actor GatewayChannelActor {
params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame)
try await self.task?.send(.data(data))
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(response)
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(response, identity: identity, role: role)
} catch {
if canFallbackToShared {
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
throw error
}
}
private func handleConnectResponse(_ res: ResponseFrame) async throws {
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity,
role: String
) async throws {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
@@ -358,6 +374,17 @@ public actor GatewayChannelActor {
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick)
}
if let auth = ok.auth,
let deviceToken = auth["deviceToken"]?.value as? String {
let authRole = auth["role"]?.value as? String ?? role
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
}
self.lastTick = Date()
self.tickTask?.cancel()
self.tickTask = Task { [weak self] in

View File

@@ -290,7 +290,7 @@ Same `deviceId` across roles → single “Instance” row:
# Execution checklist (ship order)
- [x] **Devicebound auth (PoP):** nonce challenge + signature verify on connect; remove beareronly for nonlocal.
- [ ] **Rolescoped creds:** issue perrole tokens, rotate, revoke, list; UI/CLI surfaced; audit log entries.
- [x] **Rolescoped creds:** issue perrole tokens, rotate, revoke, list; UI/CLI surfaced; audit log entries.
- [ ] **Scope enforcement:** keep paired scopes in sync on rotation; reject/upgrade flows explicit; tests.
- [ ] **Approvals routing:** gatewayhosted approvals; operator UI prompt/resolve; node stops prompting.
- [ ] **TLS pinning for WS:** reuse bridge TLS runtime; discovery advertises fingerprint; client validation.

View File

@@ -8,7 +8,11 @@ import {
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { loadDeviceAuthToken, storeDeviceAuthToken } from "../infra/device-auth-store.js";
import {
clearDeviceAuthToken,
loadDeviceAuthToken,
storeDeviceAuthToken,
} from "../infra/device-auth-store.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
@@ -160,7 +164,8 @@ export class GatewayClient {
const storedToken = this.opts.deviceIdentity
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
: null;
const authToken = this.opts.token ?? storedToken ?? undefined;
const authToken = storedToken ?? this.opts.token ?? undefined;
const canFallbackToShared = Boolean(storedToken && this.opts.token);
const auth =
authToken || this.opts.password
? {
@@ -236,6 +241,12 @@ export class GatewayClient {
this.opts.onHelloOk?.(helloOk);
})
.catch((err) => {
if (canFallbackToShared && this.opts.deviceIdentity) {
clearDeviceAuthToken({
deviceId: this.opts.deviceIdentity.deviceId,
role,
});
}
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
const msg = `gateway connect failed: ${String(err)}`;
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) logDebug(msg);

View File

@@ -19,7 +19,9 @@ import {
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
function redactPairedDevice(device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>) {
function redactPairedDevice(
device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>,
) {
const { tokens, ...rest } = device;
return {
...rest,
@@ -72,6 +74,9 @@ export const deviceHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
context.logGateway.info(
`device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
);
context.broadcast(
"device.pair.resolved",
{
@@ -79,7 +84,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
deviceId: approved.device.deviceId,
decision: "approved",
ts: Date.now(),
},
},
{ dropIfSlow: true },
);
respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined);
@@ -116,7 +121,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
);
respond(true, rejected, undefined);
},
"device.token.rotate": async ({ params, respond }) => {
"device.token.rotate": async ({ params, respond, context }) => {
if (!validateDeviceTokenRotateParams(params)) {
respond(
false,
@@ -140,6 +145,9 @@ export const deviceHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
return;
}
context.logGateway.info(
`device token rotated device=${deviceId} role=${entry.role} scopes=${entry.scopes.join(",")}`,
);
respond(
true,
{
@@ -152,7 +160,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
undefined,
);
},
"device.token.revoke": async ({ params, respond }) => {
"device.token.revoke": async ({ params, respond, context }) => {
if (!validateDeviceTokenRevokeParams(params)) {
respond(
false,
@@ -172,6 +180,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
return;
}
context.logGateway.info(`device token revoked device=${deviceId} role=${entry.role}`);
respond(
true,
{ deviceId, role: entry.role, revokedAtMs: entry.revokedAtMs ?? Date.now() },

View File

@@ -8,6 +8,9 @@ import type { NodeRegistry } from "../node-registry.js";
import type { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js";
import type { ChannelRuntimeSnapshot } from "../server-channels.js";
import type { DedupeEntry } from "../server-shared.js";
import type { createSubsystemLogger } from "../../logging/subsystem.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
export type GatewayClient = {
connect: ConnectParams;
@@ -28,7 +31,7 @@ export type GatewayRequestContext = {
getHealthCache: () => HealthSummary | null;
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
logHealth: { error: (message: string) => void };
logGateway: { warn: (message: string) => void };
logGateway: SubsystemLogger;
incrementPresenceVersion: () => number;
getHealthVersion: () => number;
broadcast: (

View File

@@ -487,6 +487,9 @@ export function attachGatewayWsMessageHandler(params: {
if (pairing.request.silent === true) {
const approved = await approveDevicePairing(pairing.request.requestId);
if (approved) {
logGateway.info(
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
);
context.broadcast(
"device.pair.resolved",
{

View File

@@ -102,3 +102,22 @@ export function storeDeviceAuthToken(params: {
writeStore(filePath, next);
return entry;
}
export function clearDeviceAuthToken(params: {
deviceId: string;
role: string;
env?: NodeJS.ProcessEnv;
}): void {
const filePath = resolveDeviceAuthPath(params.env);
const store = readStore(filePath);
if (!store || store.deviceId !== params.deviceId) return;
const role = normalizeRole(params.role);
if (!store.tokens[role]) return;
const next: DeviceAuthStore = {
version: 1,
deviceId: store.deviceId,
tokens: { ...store.tokens },
};
delete next.tokens[role];
writeStore(filePath, next);
}

View File

@@ -1,4 +1,5 @@
import { loadChatHistory } from "./controllers/chat";
import { loadDevices } from "./controllers/devices";
import { loadNodes } from "./controllers/nodes";
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
import { GatewayBrowserClient } from "./gateway";
@@ -106,6 +107,7 @@ export function connectGateway(host: GatewayHost) {
host.hello = hello;
applySnapshot(host, hello);
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
},
onClose: ({ code, reason }) => {
@@ -169,6 +171,10 @@ export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
if (evt.event === "cron" && host.tab === "cron") {
void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
}
if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") {
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
}
}
export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {

View File

@@ -38,6 +38,13 @@ import { renderLogs } from "./views/logs";
import { renderNodes } from "./views/nodes";
import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions";
import {
approveDevicePairing,
loadDevices,
rejectDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
} from "./controllers/devices";
import { renderSkills } from "./views/skills";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
import { loadChannels } from "./controllers/channels";
@@ -301,6 +308,9 @@ export function renderApp(state: AppViewState) {
? renderNodes({
loading: state.nodesLoading,
nodes: state.nodes,
devicesLoading: state.devicesLoading,
devicesError: state.devicesError,
devicesList: state.devicesList,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading,
configSaving: state.configSaving,
@@ -315,6 +325,13 @@ export function renderApp(state: AppViewState) {
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) =>
revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => {
const target =

View File

@@ -3,6 +3,7 @@ import { loadCronJobs, loadCronStatus } from "./controllers/cron";
import { loadChannels } from "./controllers/channels";
import { loadDebug } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
import { loadDevices } from "./controllers/devices";
import { loadNodes } from "./controllers/nodes";
import { loadExecApprovals } from "./controllers/exec-approvals";
import { loadPresence } from "./controllers/presence";
@@ -136,6 +137,7 @@ export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "skills") await loadSkills(host as unknown as ClawdbotApp);
if (host.tab === "nodes") {
await loadNodes(host as unknown as ClawdbotApp);
await loadDevices(host as unknown as ClawdbotApp);
await loadConfig(host as unknown as ClawdbotApp);
await loadExecApprovals(host as unknown as ClawdbotApp);
}

View File

@@ -24,6 +24,7 @@ import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices";
export type AppViewState = {
settings: UiSettings;
@@ -48,6 +49,9 @@ export type AppViewState = {
chatQueue: ChatQueueItem[];
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
devicesLoading: boolean;
devicesError: string | null;
devicesList: DevicePairingList | null;
execApprovalsLoading: boolean;
execApprovalsSaving: boolean;
execApprovalsDirty: boolean;

View File

@@ -28,6 +28,7 @@ import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices";
import {
resetToolStream as resetToolStreamInternal,
toggleToolOutput as toggleToolOutputInternal,
@@ -108,6 +109,9 @@ export class ClawdbotApp extends LitElement {
@state() nodesLoading = false;
@state() nodes: Array<Record<string, unknown>> = [];
@state() devicesLoading = false;
@state() devicesError: string | null = null;
@state() devicesList: DevicePairingList | null = null;
@state() execApprovalsLoading = false;
@state() execApprovalsSaving = false;
@state() execApprovalsDirty = false;

View File

@@ -0,0 +1,135 @@
import type { GatewayBrowserClient } from "../gateway";
import { loadOrCreateDeviceIdentity } from "../device-identity";
import { clearDeviceAuthToken, storeDeviceAuthToken } from "../device-auth";
export type DeviceTokenSummary = {
role: string;
scopes?: string[];
createdAtMs?: number;
rotatedAtMs?: number;
revokedAtMs?: number;
lastUsedAtMs?: number;
};
export type PendingDevice = {
requestId: string;
deviceId: string;
displayName?: string;
role?: string;
remoteIp?: string;
isRepair?: boolean;
ts?: number;
};
export type PairedDevice = {
deviceId: string;
displayName?: string;
roles?: string[];
scopes?: string[];
remoteIp?: string;
tokens?: DeviceTokenSummary[];
createdAtMs?: number;
approvedAtMs?: number;
};
export type DevicePairingList = {
pending: PendingDevice[];
paired: PairedDevice[];
};
export type DevicesState = {
client: GatewayBrowserClient | null;
connected: boolean;
devicesLoading: boolean;
devicesError: string | null;
devicesList: DevicePairingList | null;
};
export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) {
if (!state.client || !state.connected) return;
if (state.devicesLoading) return;
state.devicesLoading = true;
if (!opts?.quiet) state.devicesError = null;
try {
const res = (await state.client.request("device.pair.list", {})) as DevicePairingList | null;
state.devicesList = {
pending: Array.isArray(res?.pending) ? res!.pending : [],
paired: Array.isArray(res?.paired) ? res!.paired : [],
};
} catch (err) {
if (!opts?.quiet) state.devicesError = String(err);
} finally {
state.devicesLoading = false;
}
}
export async function approveDevicePairing(state: DevicesState, requestId: string) {
if (!state.client || !state.connected) return;
try {
await state.client.request("device.pair.approve", { requestId });
await loadDevices(state);
} catch (err) {
state.devicesError = String(err);
}
}
export async function rejectDevicePairing(state: DevicesState, requestId: string) {
if (!state.client || !state.connected) return;
const confirmed = window.confirm("Reject this device pairing request?");
if (!confirmed) return;
try {
await state.client.request("device.pair.reject", { requestId });
await loadDevices(state);
} catch (err) {
state.devicesError = String(err);
}
}
export async function rotateDeviceToken(
state: DevicesState,
params: { deviceId: string; role: string; scopes?: string[] },
) {
if (!state.client || !state.connected) return;
try {
const res = (await state.client.request("device.token.rotate", params)) as
| { token?: string; role?: string; deviceId?: string; scopes?: string[] }
| undefined;
if (res?.token) {
const identity = await loadOrCreateDeviceIdentity();
const role = res.role ?? params.role;
if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) {
storeDeviceAuthToken({
deviceId: identity.deviceId,
role,
token: res.token,
scopes: res.scopes ?? params.scopes ?? [],
});
}
window.prompt("New device token (copy and store securely):", res.token);
}
await loadDevices(state);
} catch (err) {
state.devicesError = String(err);
}
}
export async function revokeDeviceToken(
state: DevicesState,
params: { deviceId: string; role: string },
) {
if (!state.client || !state.connected) return;
const confirmed = window.confirm(
`Revoke token for ${params.deviceId} (${params.role})?`,
);
if (!confirmed) return;
try {
await state.client.request("device.token.revoke", params);
const identity = await loadOrCreateDeviceIdentity();
if (params.deviceId === identity.deviceId) {
clearDeviceAuthToken({ deviceId: identity.deviceId, role: params.role });
}
await loadDevices(state);
} catch (err) {
state.devicesError = String(err);
}
}

99
ui/src/ui/device-auth.ts Normal file
View File

@@ -0,0 +1,99 @@
export type DeviceAuthEntry = {
token: string;
role: string;
scopes: string[];
updatedAtMs: number;
};
type DeviceAuthStore = {
version: 1;
deviceId: string;
tokens: Record<string, DeviceAuthEntry>;
};
const STORAGE_KEY = "clawdbot.device.auth.v1";
function normalizeRole(role: string): string {
return role.trim();
}
function normalizeScopes(scopes: string[] | undefined): string[] {
if (!Array.isArray(scopes)) return [];
const out = new Set<string>();
for (const scope of scopes) {
const trimmed = scope.trim();
if (trimmed) out.add(trimmed);
}
return [...out].sort();
}
function readStore(): DeviceAuthStore | null {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as DeviceAuthStore;
if (!parsed || parsed.version !== 1) return null;
if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null;
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
return parsed;
} catch {
return null;
}
}
function writeStore(store: DeviceAuthStore) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
// best-effort
}
}
export function loadDeviceAuthToken(params: {
deviceId: string;
role: string;
}): DeviceAuthEntry | null {
const store = readStore();
if (!store || store.deviceId !== params.deviceId) return null;
const role = normalizeRole(params.role);
const entry = store.tokens[role];
if (!entry || typeof entry.token !== "string") return null;
return entry;
}
export function storeDeviceAuthToken(params: {
deviceId: string;
role: string;
token: string;
scopes?: string[];
}): DeviceAuthEntry {
const role = normalizeRole(params.role);
const next: DeviceAuthStore = {
version: 1,
deviceId: params.deviceId,
tokens: {},
};
const existing = readStore();
if (existing && existing.deviceId === params.deviceId) {
next.tokens = { ...existing.tokens };
}
const entry: DeviceAuthEntry = {
token: params.token,
role,
scopes: normalizeScopes(params.scopes),
updatedAtMs: Date.now(),
};
next.tokens[role] = entry;
writeStore(next);
return entry;
}
export function clearDeviceAuthToken(params: { deviceId: string; role: string }) {
const store = readStore();
if (!store || store.deviceId !== params.deviceId) return;
const role = normalizeRole(params.role);
if (!store.tokens[role]) return;
const next = { ...store, tokens: { ...store.tokens } };
delete next.tokens[role];
writeStore(next);
}

View File

@@ -7,6 +7,7 @@ import {
} from "../../../src/gateway/protocol/client-info.js";
import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js";
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity";
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth";
export type GatewayEventFrame = {
type: "event";
@@ -29,6 +30,12 @@ export type GatewayHelloOk = {
protocol: number;
features?: { methods?: string[]; events?: string[] };
snapshot?: unknown;
auth?: {
deviceToken?: string;
role?: string;
scopes?: string[];
issuedAtMs?: number;
};
policy?: { tickIntervalMs?: number };
};
@@ -120,15 +127,21 @@ export class GatewayBrowserClient {
this.connectTimer = null;
}
const deviceIdentity = await loadOrCreateDeviceIdentity();
const scopes = ["operator.admin"];
const role = "operator";
const storedToken = loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role,
})?.token;
const authToken = storedToken ?? this.opts.token;
const canFallbackToShared = Boolean(storedToken && this.opts.token);
const auth =
this.opts.token || this.opts.password
authToken || this.opts.password
? {
token: this.opts.token,
token: authToken,
password: this.opts.password,
}
: undefined;
const scopes = ["operator.admin"];
const role = "operator";
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
@@ -138,7 +151,7 @@ export class GatewayBrowserClient {
role,
scopes,
signedAtMs,
token: this.opts.token ?? null,
token: authToken ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
@@ -169,10 +182,21 @@ export class GatewayBrowserClient {
void this.request<GatewayHelloOk>("connect", params)
.then((hello) => {
if (hello?.auth?.deviceToken) {
storeDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role: hello.auth.role ?? role,
token: hello.auth.deviceToken,
scopes: hello.auth.scopes ?? [],
});
}
this.backoffMs = 800;
this.opts.onHello?.(hello);
})
.catch(() => {
if (canFallbackToShared) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
});
}

View File

@@ -1,15 +1,24 @@
import { html, nothing } from "lit";
import { clampText, formatAgo } from "../format";
import { clampText, formatAgo, formatList } from "../format";
import type {
ExecApprovalsAllowlistEntry,
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "../controllers/exec-approvals";
import type {
DevicePairingList,
DeviceTokenSummary,
PairedDevice,
PendingDevice,
} from "../controllers/devices";
export type NodesProps = {
loading: boolean;
nodes: Array<Record<string, unknown>>;
devicesLoading: boolean;
devicesError: string | null;
devicesList: DevicePairingList | null;
configForm: Record<string, unknown> | null;
configLoading: boolean;
configSaving: boolean;
@@ -24,6 +33,11 @@ export type NodesProps = {
execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null;
onRefresh: () => void;
onDevicesRefresh: () => void;
onDeviceApprove: (requestId: string) => void;
onDeviceReject: (requestId: string) => void;
onDeviceRotate: (deviceId: string, role: string, scopes?: string[]) => void;
onDeviceRevoke: (deviceId: string, role: string) => void;
onLoadConfig: () => void;
onLoadExecApprovals: () => void;
onBindDefault: (nodeId: string | null) => void;
@@ -42,6 +56,7 @@ export function renderNodes(props: NodesProps) {
return html`
${renderExecApprovals(approvalsState)}
${renderBindings(bindingState)}
${renderDevices(props)}
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
@@ -61,6 +76,128 @@ export function renderNodes(props: NodesProps) {
`;
}
function renderDevices(props: NodesProps) {
const list = props.devicesList ?? { pending: [], paired: [] };
const pending = Array.isArray(list.pending) ? list.pending : [];
const paired = Array.isArray(list.paired) ? list.paired : [];
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Devices</div>
<div class="card-sub">Pairing requests + role tokens.</div>
</div>
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
${props.devicesLoading ? "Loading…" : "Refresh"}
</button>
</div>
${props.devicesError
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
: nothing}
<div class="list" style="margin-top: 16px;">
${pending.length > 0
? html`
<div class="muted" style="margin-bottom: 8px;">Pending</div>
${pending.map((req) => renderPendingDevice(req, props))}
`
: nothing}
${paired.length > 0
? html`
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
${paired.map((device) => renderPairedDevice(device, props))}
`
: nothing}
${pending.length === 0 && paired.length === 0
? html`<div class="muted">No paired devices.</div>`
: nothing}
</div>
</section>
`;
}
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
const name = req.displayName?.trim() || req.deviceId;
const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a";
const role = req.role?.trim() ? `role: ${req.role}` : "role: -";
const repair = req.isRepair ? " · repair" : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${name}</div>
<div class="list-sub">${req.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">
${role} · requested ${age}${repair}
</div>
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}>
Approve
</button>
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
Reject
</button>
</div>
</div>
</div>
`;
}
function renderPairedDevice(device: PairedDevice, props: NodesProps) {
const name = device.displayName?.trim() || device.deviceId;
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
const roles = `roles: ${formatList(device.roles)}`;
const scopes = `scopes: ${formatList(device.scopes)}`;
const tokens = Array.isArray(device.tokens) ? device.tokens : [];
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${name}</div>
<div class="list-sub">${device.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
${tokens.length === 0
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
: html`
<div class="muted" style="margin-top: 10px;">Tokens</div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
</div>
`}
</div>
</div>
`;
}
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
const status = token.revokedAtMs ? "revoked" : "active";
const scopes = `scopes: ${formatList(token.scopes)}`;
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
return html`
<div class="row" style="justify-content: space-between; gap: 8px;">
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
<button
class="btn btn--sm"
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
>
Rotate
</button>
${token.revokedAtMs
? nothing
: html`
<button
class="btn btn--sm danger"
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
>
Revoke
</button>
`}
</div>
</div>
`;
}
type BindingAgent = {
id: string;
name?: string;