mirror of https://github.com/openclaw/openclaw.git
Device pairing: bind setup codes to node approvals
This commit is contained in:
parent
db20141993
commit
c71fb8cda0
|
|
@ -407,7 +407,12 @@ export default function register(api: OpenClawPluginApi) {
|
|||
|
||||
const payload: SetupPayload = {
|
||||
url: urlResult.url,
|
||||
bootstrapToken: (await issueDeviceBootstrapToken()).token,
|
||||
bootstrapToken: (
|
||||
await issueDeviceBootstrapToken({
|
||||
role: "node",
|
||||
scopes: [],
|
||||
})
|
||||
).token,
|
||||
};
|
||||
|
||||
if (action === "qr") {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,22 @@ describe("device bootstrap tokens", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("persists an intended role and scopes when requested", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
baseDir,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, { roles?: string[]; scopes?: string[] }>;
|
||||
expect(parsed[issued.token]).toMatchObject({
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies valid bootstrap tokens once and deletes them after success", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
|
@ -201,4 +217,44 @@ describe("device bootstrap tokens", () => {
|
|||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
});
|
||||
|
||||
it("rejects a role that does not match the issued pairing profile", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
baseDir,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyDeviceBootstrapToken({
|
||||
token: issued.token,
|
||||
deviceId: "device-123",
|
||||
publicKey: "public-key-123",
|
||||
role: "operator",
|
||||
scopes: [],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
});
|
||||
|
||||
it("rejects scopes that do not match the issued pairing profile", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
baseDir,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyDeviceBootstrapToken({
|
||||
token: issued.token,
|
||||
deviceId: "device-123",
|
||||
publicKey: "public-key-123",
|
||||
role: "node",
|
||||
scopes: ["operator.admin"],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import path from "node:path";
|
||||
import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "../shared/device-auth.js";
|
||||
import { resolvePairingPaths } from "./pairing-files.js";
|
||||
import {
|
||||
createAsyncLock,
|
||||
|
|
@ -63,16 +64,24 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string):
|
|||
export async function issueDeviceBootstrapToken(
|
||||
params: {
|
||||
baseDir?: string;
|
||||
role?: string;
|
||||
scopes?: readonly string[];
|
||||
} = {},
|
||||
): Promise<{ token: string; expiresAtMs: number }> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(params.baseDir);
|
||||
const token = generatePairingToken();
|
||||
const issuedAtMs = Date.now();
|
||||
const role = params.role?.trim();
|
||||
const scopes = normalizeDeviceAuthScopes(
|
||||
Array.isArray(params.scopes) ? [...params.scopes] : undefined,
|
||||
);
|
||||
state[token] = {
|
||||
token,
|
||||
ts: issuedAtMs,
|
||||
issuedAtMs,
|
||||
...(role ? { roles: [normalizeDeviceAuthRole(role)] } : {}),
|
||||
...(scopes.length > 0 || Array.isArray(params.scopes) ? { scopes } : {}),
|
||||
};
|
||||
await persistState(state, params.baseDir);
|
||||
return { token, expiresAtMs: issuedAtMs + DEVICE_BOOTSTRAP_TOKEN_TTL_MS };
|
||||
|
|
@ -102,10 +111,26 @@ export async function verifyDeviceBootstrapToken(params: {
|
|||
|
||||
const deviceId = params.deviceId.trim();
|
||||
const publicKey = params.publicKey.trim();
|
||||
const role = params.role.trim();
|
||||
const role = normalizeDeviceAuthRole(params.role);
|
||||
const requestedScopes = normalizeDeviceAuthScopes([...params.scopes]);
|
||||
if (!deviceId || !publicKey || !role) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
const allowedRoles = Array.isArray(entry.roles)
|
||||
? entry.roles.map((value) => normalizeDeviceAuthRole(String(value))).filter(Boolean)
|
||||
: [];
|
||||
if (allowedRoles.length > 0 && !allowedRoles.includes(role)) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
if (Array.isArray(entry.scopes)) {
|
||||
const allowedScopes = normalizeDeviceAuthScopes(entry.scopes);
|
||||
if (
|
||||
allowedScopes.length !== requestedScopes.length ||
|
||||
allowedScopes.some((value, index) => value !== requestedScopes[index])
|
||||
) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap setup codes are single-use. Consume the record before returning
|
||||
// success so the same token cannot be replayed to mutate a pending request.
|
||||
|
|
|
|||
|
|
@ -400,6 +400,8 @@ export async function resolvePairingSetupFromConfig(
|
|||
bootstrapToken: (
|
||||
await issueDeviceBootstrapToken({
|
||||
baseDir: options.pairingBaseDir,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
})
|
||||
).token,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue