mirror of https://github.com/openclaw/openclaw.git
refactor: centralize bootstrap profile handling
This commit is contained in:
parent
43557668d2
commit
6686f1cb2c
|
|
@ -52,14 +52,21 @@ describe("device bootstrap tokens", () => {
|
|||
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<
|
||||
string,
|
||||
{ token: string; ts: number; issuedAtMs: number }
|
||||
{
|
||||
token: string;
|
||||
ts: number;
|
||||
issuedAtMs: number;
|
||||
profile: { roles: string[]; scopes: string[] };
|
||||
}
|
||||
>;
|
||||
expect(parsed[issued.token]).toMatchObject({
|
||||
token: issued.token,
|
||||
ts: Date.now(),
|
||||
issuedAtMs: Date.now(),
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -126,8 +133,10 @@ describe("device bootstrap tokens", () => {
|
|||
token: issued.token,
|
||||
ts: issuedAtMs,
|
||||
issuedAtMs,
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
|
|
@ -174,6 +183,18 @@ describe("device bootstrap tokens", () => {
|
|||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
baseDir,
|
||||
profile: {
|
||||
roles: [" operator ", "operator"],
|
||||
scopes: ["operator.read", " operator.read "],
|
||||
},
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<
|
||||
string,
|
||||
{ profile: { roles: string[]; scopes: string[] } }
|
||||
>;
|
||||
expect(parsed[issued.token]?.profile).toEqual({
|
||||
roles: ["operator"],
|
||||
scopes: ["operator.read"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import path from "node:path";
|
||||
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
|
||||
import {
|
||||
normalizeDeviceBootstrapProfile,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
sameDeviceBootstrapProfile,
|
||||
type DeviceBootstrapProfile,
|
||||
type DeviceBootstrapProfileInput,
|
||||
} from "../shared/device-bootstrap-profile.js";
|
||||
import { resolvePairingPaths } from "./pairing-files.js";
|
||||
import {
|
||||
createAsyncLock,
|
||||
|
|
@ -16,6 +22,7 @@ export type DeviceBootstrapTokenRecord = {
|
|||
ts: number;
|
||||
deviceId?: string;
|
||||
publicKey?: string;
|
||||
profile?: DeviceBootstrapProfile;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
issuedAtMs: number;
|
||||
|
|
@ -26,31 +33,33 @@ type DeviceBootstrapStateFile = Record<string, DeviceBootstrapTokenRecord>;
|
|||
|
||||
const withLock = createAsyncLock();
|
||||
|
||||
function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {
|
||||
if (!Array.isArray(roles)) {
|
||||
return [];
|
||||
}
|
||||
const out = new Set<string>();
|
||||
for (const role of roles) {
|
||||
const trimmed = role.trim();
|
||||
if (trimmed) {
|
||||
out.add(trimmed);
|
||||
}
|
||||
}
|
||||
return [...out].toSorted();
|
||||
}
|
||||
|
||||
function sameStringSet(left: readonly string[], right: readonly string[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function resolveBootstrapPath(baseDir?: string): string {
|
||||
return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json");
|
||||
}
|
||||
|
||||
function resolvePersistedBootstrapProfile(
|
||||
record: Partial<DeviceBootstrapTokenRecord>,
|
||||
): DeviceBootstrapProfile {
|
||||
return normalizeDeviceBootstrapProfile(record.profile ?? record);
|
||||
}
|
||||
|
||||
function resolveIssuedBootstrapProfile(params: {
|
||||
profile?: DeviceBootstrapProfileInput;
|
||||
roles?: readonly string[];
|
||||
scopes?: readonly string[];
|
||||
}): DeviceBootstrapProfile {
|
||||
if (params.profile) {
|
||||
return normalizeDeviceBootstrapProfile(params.profile);
|
||||
}
|
||||
if (params.roles || params.scopes) {
|
||||
return normalizeDeviceBootstrapProfile({
|
||||
roles: params.roles,
|
||||
scopes: params.scopes,
|
||||
});
|
||||
}
|
||||
return PAIRING_SETUP_BOOTSTRAP_PROFILE;
|
||||
}
|
||||
|
||||
async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
|
||||
const bootstrapPath = resolveBootstrapPath(baseDir);
|
||||
const rawState = (await readJsonFile<DeviceBootstrapStateFile>(bootstrapPath)) ?? {};
|
||||
|
|
@ -66,11 +75,15 @@ async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
|
|||
const token =
|
||||
typeof record.token === "string" && record.token.trim().length > 0 ? record.token : tokenKey;
|
||||
const issuedAtMs = typeof record.issuedAtMs === "number" ? record.issuedAtMs : 0;
|
||||
const profile = resolvePersistedBootstrapProfile(record);
|
||||
state[tokenKey] = {
|
||||
...record,
|
||||
token,
|
||||
profile,
|
||||
deviceId: typeof record.deviceId === "string" ? record.deviceId : undefined,
|
||||
publicKey: typeof record.publicKey === "string" ? record.publicKey : undefined,
|
||||
issuedAtMs,
|
||||
ts: typeof record.ts === "number" ? record.ts : issuedAtMs,
|
||||
lastUsedAtMs: typeof record.lastUsedAtMs === "number" ? record.lastUsedAtMs : undefined,
|
||||
};
|
||||
}
|
||||
pruneExpiredPending(state, Date.now(), DEVICE_BOOTSTRAP_TOKEN_TTL_MS);
|
||||
|
|
@ -85,6 +98,7 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string):
|
|||
export async function issueDeviceBootstrapToken(
|
||||
params: {
|
||||
baseDir?: string;
|
||||
profile?: DeviceBootstrapProfileInput;
|
||||
roles?: readonly string[];
|
||||
scopes?: readonly string[];
|
||||
} = {},
|
||||
|
|
@ -93,13 +107,11 @@ export async function issueDeviceBootstrapToken(
|
|||
const state = await loadState(params.baseDir);
|
||||
const token = generatePairingToken();
|
||||
const issuedAtMs = Date.now();
|
||||
const roles = normalizeBootstrapRoles(params.roles ?? ["node"]);
|
||||
const scopes = normalizeDeviceAuthScopes(params.scopes ? [...params.scopes] : []);
|
||||
const profile = resolveIssuedBootstrapProfile(params);
|
||||
state[token] = {
|
||||
token,
|
||||
ts: issuedAtMs,
|
||||
roles,
|
||||
scopes,
|
||||
profile,
|
||||
issuedAtMs,
|
||||
};
|
||||
await persistState(state, params.baseDir);
|
||||
|
|
@ -170,16 +182,16 @@ export async function verifyDeviceBootstrapToken(params: {
|
|||
if (!deviceId || !publicKey || !role) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
const requestedRoles = normalizeBootstrapRoles([role]);
|
||||
const requestedScopes = normalizeDeviceAuthScopes([...params.scopes]);
|
||||
const allowedRoles = normalizeBootstrapRoles(record.roles);
|
||||
const allowedScopes = normalizeDeviceAuthScopes(record.scopes);
|
||||
const requestedProfile = normalizeDeviceBootstrapProfile({
|
||||
roles: [role],
|
||||
scopes: params.scopes,
|
||||
});
|
||||
const allowedProfile = resolvePersistedBootstrapProfile(record);
|
||||
// Fail closed for unbound legacy setup codes and for any attempt to redeem
|
||||
// the token outside the exact role/scope profile it was issued for.
|
||||
if (
|
||||
allowedRoles.length === 0 ||
|
||||
!sameStringSet(requestedRoles, allowedRoles) ||
|
||||
!sameStringSet(requestedScopes, allowedScopes)
|
||||
allowedProfile.roles.length === 0 ||
|
||||
!sameDeviceBootstrapProfile(requestedProfile, allowedProfile)
|
||||
) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,8 +56,10 @@ describe("pairing setup code", () => {
|
|||
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
|
||||
expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (params.url) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
pickMatchingExternalInterfaceAddress,
|
||||
safeNetworkInterfaces,
|
||||
} from "../infra/network-interfaces.js";
|
||||
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
|
||||
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
|
||||
import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js";
|
||||
import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
|
||||
|
|
@ -22,9 +23,6 @@ export type PairingSetupPayload = {
|
|||
bootstrapToken: string;
|
||||
};
|
||||
|
||||
const PAIRING_SETUP_BOOTSTRAP_ROLES = ["node"] as const;
|
||||
const PAIRING_SETUP_BOOTSTRAP_SCOPES: string[] = [];
|
||||
|
||||
export type PairingSetupCommandResult = {
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
|
|
@ -387,8 +385,7 @@ export async function resolvePairingSetupFromConfig(
|
|||
bootstrapToken: (
|
||||
await issueDeviceBootstrapToken({
|
||||
baseDir: options.pairingBaseDir,
|
||||
roles: PAIRING_SETUP_BOOTSTRAP_ROLES,
|
||||
scopes: PAIRING_SETUP_BOOTSTRAP_SCOPES,
|
||||
profile: PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
})
|
||||
).token,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "./device-auth.js";
|
||||
|
||||
export type DeviceBootstrapProfile = {
|
||||
roles: string[];
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
export type DeviceBootstrapProfileInput = {
|
||||
roles?: readonly string[];
|
||||
scopes?: readonly string[];
|
||||
};
|
||||
|
||||
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
};
|
||||
|
||||
function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {
|
||||
if (!Array.isArray(roles)) {
|
||||
return [];
|
||||
}
|
||||
const out = new Set<string>();
|
||||
for (const role of roles) {
|
||||
const normalized = normalizeDeviceAuthRole(role);
|
||||
if (normalized) {
|
||||
out.add(normalized);
|
||||
}
|
||||
}
|
||||
return [...out].toSorted();
|
||||
}
|
||||
|
||||
export function normalizeDeviceBootstrapProfile(
|
||||
input: DeviceBootstrapProfileInput | undefined,
|
||||
): DeviceBootstrapProfile {
|
||||
return {
|
||||
roles: normalizeBootstrapRoles(input?.roles),
|
||||
scopes: normalizeDeviceAuthScopes(input?.scopes ? [...input.scopes] : []),
|
||||
};
|
||||
}
|
||||
|
||||
export function sameDeviceBootstrapProfile(
|
||||
left: DeviceBootstrapProfile,
|
||||
right: DeviceBootstrapProfile,
|
||||
): boolean {
|
||||
return (
|
||||
left.roles.length === right.roles.length &&
|
||||
left.scopes.length === right.scopes.length &&
|
||||
left.roles.every((value, index) => value === right.roles[index]) &&
|
||||
left.scopes.every((value, index) => value === right.scopes[index])
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue