feat: add device token auth and devices cli
This commit is contained in:
104
src/infra/device-auth-store.ts
Normal file
104
src/infra/device-auth-store.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
|
||||
export type DeviceAuthEntry = {
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
type DeviceAuthStore = {
|
||||
version: 1;
|
||||
deviceId: string;
|
||||
tokens: Record<string, DeviceAuthEntry>;
|
||||
};
|
||||
|
||||
const DEVICE_AUTH_FILE = "device-auth.json";
|
||||
|
||||
function resolveDeviceAuthPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(resolveStateDir(env), "identity", DEVICE_AUTH_FILE);
|
||||
}
|
||||
|
||||
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(filePath: string): DeviceAuthStore | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as DeviceAuthStore;
|
||||
if (parsed?.version !== 1 || typeof parsed.deviceId !== "string") return null;
|
||||
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(filePath: string, store: DeviceAuthStore): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
||||
try {
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDeviceAuthToken(params: {
|
||||
deviceId: string;
|
||||
role: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): DeviceAuthEntry | null {
|
||||
const filePath = resolveDeviceAuthPath(params.env);
|
||||
const store = readStore(filePath);
|
||||
if (!store) return null;
|
||||
if (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[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): DeviceAuthEntry {
|
||||
const filePath = resolveDeviceAuthPath(params.env);
|
||||
const existing = readStore(filePath);
|
||||
const role = normalizeRole(params.role);
|
||||
const next: DeviceAuthStore = {
|
||||
version: 1,
|
||||
deviceId: params.deviceId,
|
||||
tokens:
|
||||
existing && existing.deviceId === params.deviceId && existing.tokens
|
||||
? { ...existing.tokens }
|
||||
: {},
|
||||
};
|
||||
const entry: DeviceAuthEntry = {
|
||||
token: params.token,
|
||||
role,
|
||||
scopes: normalizeScopes(params.scopes),
|
||||
updatedAtMs: Date.now(),
|
||||
};
|
||||
next.tokens[role] = entry;
|
||||
writeStore(filePath, next);
|
||||
return entry;
|
||||
}
|
||||
43
src/infra/device-pairing.test.ts
Normal file
43
src/infra/device-pairing.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
getPairedDevice,
|
||||
requestDevicePairing,
|
||||
rotateDeviceToken,
|
||||
} from "./device-pairing.js";
|
||||
|
||||
describe("device pairing tokens", () => {
|
||||
test("preserves existing token scopes when rotating without scopes", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "clawdbot-device-pairing-"));
|
||||
const request = await requestDevicePairing(
|
||||
{
|
||||
deviceId: "device-1",
|
||||
publicKey: "public-key-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
await approveDevicePairing(request.request.requestId, baseDir);
|
||||
|
||||
await rotateDeviceToken({
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
baseDir,
|
||||
});
|
||||
let paired = await getPairedDevice("device-1", baseDir);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||
|
||||
await rotateDeviceToken({
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
baseDir,
|
||||
});
|
||||
paired = await getPairedDevice("device-1", baseDir);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,25 @@ export type DevicePairingPendingRequest = {
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export type DeviceAuthToken = {
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
createdAtMs: number;
|
||||
rotatedAtMs?: number;
|
||||
revokedAtMs?: number;
|
||||
lastUsedAtMs?: number;
|
||||
};
|
||||
|
||||
export type DeviceAuthTokenSummary = {
|
||||
role: string;
|
||||
scopes: string[];
|
||||
createdAtMs: number;
|
||||
rotatedAtMs?: number;
|
||||
revokedAtMs?: number;
|
||||
lastUsedAtMs?: number;
|
||||
};
|
||||
|
||||
export type PairedDevice = {
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
@@ -31,6 +50,7 @@ export type PairedDevice = {
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
remoteIp?: string;
|
||||
tokens?: Record<string, DeviceAuthToken>;
|
||||
createdAtMs: number;
|
||||
approvedAtMs: number;
|
||||
};
|
||||
@@ -136,6 +156,11 @@ function normalizeDeviceId(deviceId: string) {
|
||||
return deviceId.trim();
|
||||
}
|
||||
|
||||
function normalizeRole(role: string | undefined): string | null {
|
||||
const trimmed = role?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function mergeRoles(...items: Array<string | string[] | undefined>): string[] | undefined {
|
||||
const roles = new Set<string>();
|
||||
for (const item of items) {
|
||||
@@ -167,6 +192,27 @@ function mergeScopes(...items: Array<string[] | undefined>): string[] | undefine
|
||||
return [...scopes];
|
||||
}
|
||||
|
||||
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 scopesAllow(requested: string[], allowed: string[]): boolean {
|
||||
if (requested.length === 0) return true;
|
||||
if (allowed.length === 0) return false;
|
||||
const allowedSet = new Set(allowed);
|
||||
return requested.every((scope) => allowedSet.has(scope));
|
||||
}
|
||||
|
||||
function newToken() {
|
||||
return randomUUID().replaceAll("-", "");
|
||||
}
|
||||
|
||||
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
|
||||
@@ -237,6 +283,22 @@ export async function approveDevicePairing(
|
||||
const existing = state.pairedByDeviceId[pending.deviceId];
|
||||
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
|
||||
const scopes = mergeScopes(existing?.scopes, pending.scopes);
|
||||
const tokens = existing?.tokens ? { ...existing.tokens } : {};
|
||||
const roleForToken = normalizeRole(pending.role);
|
||||
if (roleForToken) {
|
||||
const nextScopes = normalizeScopes(pending.scopes);
|
||||
const existingToken = tokens[roleForToken];
|
||||
const now = Date.now();
|
||||
tokens[roleForToken] = {
|
||||
token: newToken(),
|
||||
role: roleForToken,
|
||||
scopes: nextScopes,
|
||||
createdAtMs: existingToken?.createdAtMs ?? now,
|
||||
rotatedAtMs: existingToken ? now : undefined,
|
||||
revokedAtMs: undefined,
|
||||
lastUsedAtMs: existingToken?.lastUsedAtMs,
|
||||
};
|
||||
}
|
||||
const device: PairedDevice = {
|
||||
deviceId: pending.deviceId,
|
||||
publicKey: pending.publicKey,
|
||||
@@ -248,6 +310,7 @@ export async function approveDevicePairing(
|
||||
roles,
|
||||
scopes,
|
||||
remoteIp: pending.remoteIp,
|
||||
tokens,
|
||||
createdAtMs: existing?.createdAtMs ?? now,
|
||||
approvedAtMs: now,
|
||||
};
|
||||
@@ -296,3 +359,142 @@ export async function updatePairedDeviceMetadata(
|
||||
await persistState(state, baseDir);
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizeDeviceTokens(
|
||||
tokens: Record<string, DeviceAuthToken> | undefined,
|
||||
): DeviceAuthTokenSummary[] | undefined {
|
||||
if (!tokens) return undefined;
|
||||
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,
|
||||
}))
|
||||
.sort((a, b) => a.role.localeCompare(b.role));
|
||||
return summaries.length > 0 ? summaries : undefined;
|
||||
}
|
||||
|
||||
export async function verifyDeviceToken(params: {
|
||||
deviceId: string;
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
baseDir?: string;
|
||||
}): Promise<{ ok: boolean; reason?: string }> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(params.baseDir);
|
||||
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
|
||||
if (!device) return { ok: false, reason: "device-not-paired" };
|
||||
const role = normalizeRole(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 (entry.token !== params.token) return { ok: false, reason: "token-mismatch" };
|
||||
const requestedScopes = normalizeScopes(params.scopes);
|
||||
if (!scopesAllow(requestedScopes, entry.scopes)) {
|
||||
return { ok: false, reason: "scope-mismatch" };
|
||||
}
|
||||
entry.lastUsedAtMs = Date.now();
|
||||
device.tokens = { ...(device.tokens ?? {}), [role]: entry };
|
||||
state.pairedByDeviceId[device.deviceId] = device;
|
||||
await persistState(state, params.baseDir);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureDeviceToken(params: {
|
||||
deviceId: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
baseDir?: string;
|
||||
}): Promise<DeviceAuthToken | null> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(params.baseDir);
|
||||
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
|
||||
if (!device) return null;
|
||||
const role = normalizeRole(params.role);
|
||||
if (!role) return null;
|
||||
const requestedScopes = normalizeScopes(params.scopes);
|
||||
const tokens = device.tokens ? { ...device.tokens } : {};
|
||||
const existing = tokens[role];
|
||||
if (existing && !existing.revokedAtMs) {
|
||||
if (scopesAllow(requestedScopes, existing.scopes)) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
const now = Date.now();
|
||||
const next: DeviceAuthToken = {
|
||||
token: newToken(),
|
||||
role,
|
||||
scopes: requestedScopes,
|
||||
createdAtMs: existing?.createdAtMs ?? now,
|
||||
rotatedAtMs: existing ? now : undefined,
|
||||
revokedAtMs: undefined,
|
||||
lastUsedAtMs: existing?.lastUsedAtMs,
|
||||
};
|
||||
tokens[role] = next;
|
||||
device.tokens = tokens;
|
||||
state.pairedByDeviceId[device.deviceId] = device;
|
||||
await persistState(state, params.baseDir);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export async function rotateDeviceToken(params: {
|
||||
deviceId: string;
|
||||
role: string;
|
||||
scopes?: string[];
|
||||
baseDir?: string;
|
||||
}): Promise<DeviceAuthToken | null> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(params.baseDir);
|
||||
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
|
||||
if (!device) return null;
|
||||
const role = normalizeRole(params.role);
|
||||
if (!role) return null;
|
||||
const tokens = device.tokens ? { ...device.tokens } : {};
|
||||
const existing = tokens[role];
|
||||
const requestedScopes = normalizeScopes(params.scopes ?? existing?.scopes ?? device.scopes);
|
||||
const now = Date.now();
|
||||
const next: DeviceAuthToken = {
|
||||
token: newToken(),
|
||||
role,
|
||||
scopes: requestedScopes,
|
||||
createdAtMs: existing?.createdAtMs ?? now,
|
||||
rotatedAtMs: now,
|
||||
revokedAtMs: undefined,
|
||||
lastUsedAtMs: existing?.lastUsedAtMs,
|
||||
};
|
||||
tokens[role] = next;
|
||||
device.tokens = tokens;
|
||||
state.pairedByDeviceId[device.deviceId] = device;
|
||||
await persistState(state, params.baseDir);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeDeviceToken(params: {
|
||||
deviceId: string;
|
||||
role: string;
|
||||
baseDir?: string;
|
||||
}): Promise<DeviceAuthToken | null> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(params.baseDir);
|
||||
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
|
||||
if (!device) return null;
|
||||
const role = normalizeRole(params.role);
|
||||
if (!role) return null;
|
||||
if (!device.tokens?.[role]) return null;
|
||||
const tokens = { ...device.tokens };
|
||||
const entry = { ...tokens[role], revokedAtMs: Date.now() };
|
||||
tokens[role] = entry;
|
||||
device.tokens = tokens;
|
||||
state.pairedByDeviceId[device.deviceId] = device;
|
||||
await persistState(state, params.baseDir);
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user