mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:38:38 +00:00
fix(pairing): preserve operator scopes for ios onboarding
This commit is contained in:
committed by
Nimrod Gutman
parent
7ecfc1d93c
commit
1da23be302
@@ -2140,9 +2140,7 @@ private extension NodeAppModel {
|
|||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
clientMode: "ui",
|
clientMode: "ui",
|
||||||
clientDisplayName: displayName,
|
clientDisplayName: displayName,
|
||||||
// Operator traffic should authenticate via shared gateway auth only.
|
includeDeviceIdentity: true)
|
||||||
// Including device identity here can trigger duplicate pairing flows.
|
|
||||||
includeDeviceIdentity: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
||||||
|
|||||||
@@ -55,6 +55,38 @@ describe("device pairing tokens", () => {
|
|||||||
expect(second.request.requestId).toBe(first.request.requestId);
|
expect(second.request.requestId).toBe(first.request.requestId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("merges pending roles/scopes for the same device before approval", async () => {
|
||||||
|
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||||
|
const first = await requestDevicePairing(
|
||||||
|
{
|
||||||
|
deviceId: "device-1",
|
||||||
|
publicKey: "public-key-1",
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
},
|
||||||
|
baseDir,
|
||||||
|
);
|
||||||
|
const second = await requestDevicePairing(
|
||||||
|
{
|
||||||
|
deviceId: "device-1",
|
||||||
|
publicKey: "public-key-1",
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.read", "operator.write"],
|
||||||
|
},
|
||||||
|
baseDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(second.created).toBe(false);
|
||||||
|
expect(second.request.requestId).toBe(first.request.requestId);
|
||||||
|
expect(second.request.roles).toEqual(["node", "operator"]);
|
||||||
|
expect(second.request.scopes).toEqual(["operator.read", "operator.write"]);
|
||||||
|
|
||||||
|
await approveDevicePairing(first.request.requestId, baseDir);
|
||||||
|
const paired = await getPairedDevice("device-1", baseDir);
|
||||||
|
expect(paired?.roles).toEqual(["node", "operator"]);
|
||||||
|
expect(paired?.scopes).toEqual(["operator.read", "operator.write"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("generates base64url device tokens with 256-bit entropy output length", async () => {
|
test("generates base64url device tokens with 256-bit entropy output length", async () => {
|
||||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||||
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
pruneExpiredPending,
|
pruneExpiredPending,
|
||||||
readJsonFile,
|
readJsonFile,
|
||||||
resolvePairingPaths,
|
resolvePairingPaths,
|
||||||
upsertPendingPairingRequest,
|
|
||||||
writeJsonAtomic,
|
writeJsonAtomic,
|
||||||
} from "./pairing-files.js";
|
} from "./pairing-files.js";
|
||||||
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
|
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
|
||||||
@@ -153,6 +152,61 @@ function mergeScopes(...items: Array<string[] | undefined>): string[] | undefine
|
|||||||
return [...scopes];
|
return [...scopes];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function equalOptionalStringArray(a: string[] | undefined, b: string[] | undefined): boolean {
|
||||||
|
if (!a && !b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!a || !b || a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
if (a[i] !== b[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePendingDevicePairingRequest(
|
||||||
|
existing: DevicePairingPendingRequest,
|
||||||
|
incoming: Omit<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair"> & {
|
||||||
|
isRepair: boolean;
|
||||||
|
},
|
||||||
|
): { request: DevicePairingPendingRequest; changed: boolean } {
|
||||||
|
const existingRole = normalizeRole(existing.role);
|
||||||
|
const incomingRole = normalizeRole(incoming.role);
|
||||||
|
const nextRole = existingRole ?? incomingRole ?? undefined;
|
||||||
|
const nextRoles = mergeRoles(existing.roles, existing.role, incoming.role);
|
||||||
|
const nextScopes = mergeScopes(existing.scopes, incoming.scopes);
|
||||||
|
const nextSilent = Boolean(existing.silent && incoming.silent);
|
||||||
|
const nextRequest: DevicePairingPendingRequest = {
|
||||||
|
...existing,
|
||||||
|
displayName: incoming.displayName ?? existing.displayName,
|
||||||
|
platform: incoming.platform ?? existing.platform,
|
||||||
|
clientId: incoming.clientId ?? existing.clientId,
|
||||||
|
clientMode: incoming.clientMode ?? existing.clientMode,
|
||||||
|
role: nextRole,
|
||||||
|
roles: nextRoles,
|
||||||
|
scopes: nextScopes,
|
||||||
|
remoteIp: incoming.remoteIp ?? existing.remoteIp,
|
||||||
|
silent: nextSilent,
|
||||||
|
isRepair: existing.isRepair || incoming.isRepair,
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
const changed =
|
||||||
|
nextRequest.displayName !== existing.displayName ||
|
||||||
|
nextRequest.platform !== existing.platform ||
|
||||||
|
nextRequest.clientId !== existing.clientId ||
|
||||||
|
nextRequest.clientMode !== existing.clientMode ||
|
||||||
|
nextRequest.role !== existing.role ||
|
||||||
|
!equalOptionalStringArray(nextRequest.roles, existing.roles) ||
|
||||||
|
!equalOptionalStringArray(nextRequest.scopes, existing.scopes) ||
|
||||||
|
nextRequest.remoteIp !== existing.remoteIp ||
|
||||||
|
nextRequest.silent !== existing.silent ||
|
||||||
|
nextRequest.isRepair !== existing.isRepair;
|
||||||
|
return { request: nextRequest, changed };
|
||||||
|
}
|
||||||
|
|
||||||
function newToken() {
|
function newToken() {
|
||||||
return generatePairingToken();
|
return generatePairingToken();
|
||||||
}
|
}
|
||||||
@@ -217,29 +271,41 @@ export async function requestDevicePairing(
|
|||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
throw new Error("deviceId required");
|
throw new Error("deviceId required");
|
||||||
}
|
}
|
||||||
|
const isRepair = Boolean(state.pairedByDeviceId[deviceId]);
|
||||||
return await upsertPendingPairingRequest({
|
const existing = Object.values(state.pendingById).find(
|
||||||
pendingById: state.pendingById,
|
(pending) => pending.deviceId === deviceId,
|
||||||
isExisting: (pending) => pending.deviceId === deviceId,
|
);
|
||||||
isRepair: Boolean(state.pairedByDeviceId[deviceId]),
|
if (existing) {
|
||||||
createRequest: (isRepair) => ({
|
const merged = mergePendingDevicePairingRequest(existing, {
|
||||||
requestId: randomUUID(),
|
...req,
|
||||||
deviceId,
|
|
||||||
publicKey: req.publicKey,
|
|
||||||
displayName: req.displayName,
|
|
||||||
platform: req.platform,
|
|
||||||
clientId: req.clientId,
|
|
||||||
clientMode: req.clientMode,
|
|
||||||
role: req.role,
|
|
||||||
roles: req.role ? [req.role] : undefined,
|
|
||||||
scopes: req.scopes,
|
|
||||||
remoteIp: req.remoteIp,
|
|
||||||
silent: req.silent,
|
|
||||||
isRepair,
|
isRepair,
|
||||||
ts: Date.now(),
|
});
|
||||||
}),
|
state.pendingById[existing.requestId] = merged.request;
|
||||||
persist: async () => await persistState(state, baseDir),
|
if (merged.changed) {
|
||||||
});
|
await persistState(state, baseDir);
|
||||||
|
}
|
||||||
|
return { status: "pending" as const, request: merged.request, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: DevicePairingPendingRequest = {
|
||||||
|
requestId: randomUUID(),
|
||||||
|
deviceId,
|
||||||
|
publicKey: req.publicKey,
|
||||||
|
displayName: req.displayName,
|
||||||
|
platform: req.platform,
|
||||||
|
clientId: req.clientId,
|
||||||
|
clientMode: req.clientMode,
|
||||||
|
role: req.role,
|
||||||
|
roles: req.role ? [req.role] : undefined,
|
||||||
|
scopes: req.scopes,
|
||||||
|
remoteIp: req.remoteIp,
|
||||||
|
silent: req.silent,
|
||||||
|
isRepair,
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
state.pendingById[request.requestId] = request;
|
||||||
|
await persistState(state, baseDir);
|
||||||
|
return { status: "pending" as const, request, created: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user