mirror of https://github.com/openclaw/openclaw.git
fix: allow setup-code bootstrap auth for operator pairing
This commit is contained in:
parent
e0281849c0
commit
2dced6b4a0
|
|
@ -64,24 +64,29 @@ describe("device bootstrap tokens", () => {
|
|||
ts: Date.now(),
|
||||
issuedAtMs: Date.now(),
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies valid bootstrap tokens once and deletes them after success", async () => {
|
||||
it("verifies valid bootstrap tokens without consuming them before expiry", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
||||
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "bootstrap_token_invalid",
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
|
||||
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
||||
expect(raw).toContain(issued.token);
|
||||
});
|
||||
|
||||
it("clears outstanding bootstrap tokens on demand", async () => {
|
||||
|
|
@ -120,7 +125,7 @@ describe("device bootstrap tokens", () => {
|
|||
await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("consumes bootstrap tokens by the persisted map key", async () => {
|
||||
it("verifies bootstrap tokens by the persisted map key without deleting them", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
const issuedAtMs = Date.now();
|
||||
|
|
@ -134,8 +139,8 @@ describe("device bootstrap tokens", () => {
|
|||
ts: issuedAtMs,
|
||||
issuedAtMs,
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -147,7 +152,8 @@ describe("device bootstrap tokens", () => {
|
|||
|
||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
||||
|
||||
await expect(fs.readFile(bootstrapPath, "utf8")).resolves.toBe("{}");
|
||||
const raw = await fs.readFile(bootstrapPath, "utf8");
|
||||
expect(raw).toContain(issued.token);
|
||||
});
|
||||
|
||||
it("keeps the token when required verification fields are blank", async () => {
|
||||
|
|
@ -179,6 +185,18 @@ describe("device bootstrap tokens", () => {
|
|||
expect(raw).toContain(issued.token);
|
||||
});
|
||||
|
||||
it("allows operator scope subsets within the issued bootstrap profile", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("supports explicitly bound bootstrap profiles", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
|
|
@ -207,7 +225,7 @@ describe("device bootstrap tokens", () => {
|
|||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("accepts trimmed bootstrap tokens and still consumes them once", async () => {
|
||||
it("accepts trimmed bootstrap tokens without consuming them", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
|
|
@ -215,7 +233,8 @@ describe("device bootstrap tokens", () => {
|
|||
ok: true,
|
||||
});
|
||||
|
||||
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
|
||||
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
||||
expect(raw).toContain(issued.token);
|
||||
});
|
||||
|
||||
it("rejects blank or unknown tokens", async () => {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import path from "node:path";
|
|||
import {
|
||||
normalizeDeviceBootstrapProfile,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
sameDeviceBootstrapProfile,
|
||||
type DeviceBootstrapProfile,
|
||||
type DeviceBootstrapProfileInput,
|
||||
} from "../shared/device-bootstrap-profile.js";
|
||||
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
|
||||
import { resolvePairingPaths } from "./pairing-files.js";
|
||||
import {
|
||||
createAsyncLock,
|
||||
|
|
@ -60,6 +60,21 @@ function resolveIssuedBootstrapProfile(params: {
|
|||
return PAIRING_SETUP_BOOTSTRAP_PROFILE;
|
||||
}
|
||||
|
||||
function bootstrapProfileAllowsRequest(params: {
|
||||
allowedProfile: DeviceBootstrapProfile;
|
||||
requestedRole: string;
|
||||
requestedScopes: readonly string[];
|
||||
}): boolean {
|
||||
return (
|
||||
params.allowedProfile.roles.includes(params.requestedRole) &&
|
||||
roleScopesAllow({
|
||||
role: params.requestedRole,
|
||||
requestedScopes: params.requestedScopes,
|
||||
allowedScopes: params.allowedProfile.scopes,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
|
||||
const bootstrapPath = resolveBootstrapPath(baseDir);
|
||||
const rawState = (await readJsonFile<DeviceBootstrapStateFile>(bootstrapPath)) ?? {};
|
||||
|
|
@ -174,7 +189,7 @@ export async function verifyDeviceBootstrapToken(params: {
|
|||
if (!found) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
const [tokenKey, record] = found;
|
||||
const [, record] = found;
|
||||
|
||||
const deviceId = params.deviceId.trim();
|
||||
const publicKey = params.publicKey.trim();
|
||||
|
|
@ -182,24 +197,23 @@ export async function verifyDeviceBootstrapToken(params: {
|
|||
if (!deviceId || !publicKey || !role) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
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.
|
||||
// the token outside the issued role/scope allowlist.
|
||||
if (
|
||||
allowedProfile.roles.length === 0 ||
|
||||
!sameDeviceBootstrapProfile(requestedProfile, allowedProfile)
|
||||
!bootstrapProfileAllowsRequest({
|
||||
allowedProfile,
|
||||
requestedRole: role,
|
||||
requestedScopes: params.scopes,
|
||||
})
|
||||
) {
|
||||
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.
|
||||
delete state[tokenKey];
|
||||
await persistState(state, params.baseDir);
|
||||
// Keep valid setup codes alive until they expire or are explicitly revoked.
|
||||
// Approval happens after bootstrap verification, so consuming the token here
|
||||
// makes post-approval reconnect impossible.
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ describe("pairing setup code", () => {
|
|||
expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export {
|
|||
export {
|
||||
normalizeDeviceBootstrapProfile,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
sameDeviceBootstrapProfile,
|
||||
type DeviceBootstrapProfile,
|
||||
type DeviceBootstrapProfileInput,
|
||||
} from "../shared/device-bootstrap-profile.js";
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ export type DeviceBootstrapProfileInput = {
|
|||
};
|
||||
|
||||
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
||||
};
|
||||
|
||||
function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {
|
||||
|
|
@ -37,15 +37,3 @@ export function normalizeDeviceBootstrapProfile(
|
|||
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