mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 17:08:27 +00:00
refactor(gateway): extract connect and role policy logic
This commit is contained in:
115
src/gateway/server/ws-connection/connect-policy.test.ts
Normal file
115
src/gateway/server/ws-connection/connect-policy.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
evaluateMissingDeviceIdentity,
|
||||
resolveControlUiAuthPolicy,
|
||||
shouldSkipControlUiPairing,
|
||||
} from "./connect-policy.js";
|
||||
|
||||
describe("ws connect policy", () => {
|
||||
test("resolves control-ui auth policy", () => {
|
||||
const bypass = resolveControlUiAuthPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
|
||||
deviceRaw: { id: "dev-1", publicKey: "pk", signature: "sig", signedAt: Date.now() },
|
||||
});
|
||||
expect(bypass.allowBypass).toBe(true);
|
||||
expect(bypass.device).toBeNull();
|
||||
|
||||
const regular = resolveControlUiAuthPolicy({
|
||||
isControlUi: false,
|
||||
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
|
||||
deviceRaw: { id: "dev-2", publicKey: "pk", signature: "sig", signedAt: Date.now() },
|
||||
});
|
||||
expect(regular.allowBypass).toBe(false);
|
||||
expect(regular.device?.id).toBe("dev-2");
|
||||
});
|
||||
|
||||
test("evaluates missing-device decisions", () => {
|
||||
const policy = resolveControlUiAuthPolicy({
|
||||
isControlUi: false,
|
||||
controlUiConfig: undefined,
|
||||
deviceRaw: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateMissingDeviceIdentity({
|
||||
hasDeviceIdentity: true,
|
||||
role: "node",
|
||||
isControlUi: false,
|
||||
controlUiAuthPolicy: policy,
|
||||
sharedAuthOk: true,
|
||||
authOk: true,
|
||||
hasSharedAuth: true,
|
||||
}).kind,
|
||||
).toBe("allow");
|
||||
|
||||
const controlUiStrict = resolveControlUiAuthPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: { allowInsecureAuth: true, dangerouslyDisableDeviceAuth: false },
|
||||
deviceRaw: null,
|
||||
});
|
||||
expect(
|
||||
evaluateMissingDeviceIdentity({
|
||||
hasDeviceIdentity: false,
|
||||
role: "operator",
|
||||
isControlUi: true,
|
||||
controlUiAuthPolicy: controlUiStrict,
|
||||
sharedAuthOk: true,
|
||||
authOk: true,
|
||||
hasSharedAuth: true,
|
||||
}).kind,
|
||||
).toBe("reject-control-ui-insecure-auth");
|
||||
|
||||
expect(
|
||||
evaluateMissingDeviceIdentity({
|
||||
hasDeviceIdentity: false,
|
||||
role: "operator",
|
||||
isControlUi: false,
|
||||
controlUiAuthPolicy: policy,
|
||||
sharedAuthOk: true,
|
||||
authOk: true,
|
||||
hasSharedAuth: true,
|
||||
}).kind,
|
||||
).toBe("allow");
|
||||
|
||||
expect(
|
||||
evaluateMissingDeviceIdentity({
|
||||
hasDeviceIdentity: false,
|
||||
role: "operator",
|
||||
isControlUi: false,
|
||||
controlUiAuthPolicy: policy,
|
||||
sharedAuthOk: false,
|
||||
authOk: false,
|
||||
hasSharedAuth: true,
|
||||
}).kind,
|
||||
).toBe("reject-unauthorized");
|
||||
|
||||
expect(
|
||||
evaluateMissingDeviceIdentity({
|
||||
hasDeviceIdentity: false,
|
||||
role: "node",
|
||||
isControlUi: false,
|
||||
controlUiAuthPolicy: policy,
|
||||
sharedAuthOk: true,
|
||||
authOk: true,
|
||||
hasSharedAuth: true,
|
||||
}).kind,
|
||||
).toBe("reject-device-required");
|
||||
});
|
||||
|
||||
test("pairing bypass requires control-ui bypass + shared auth", () => {
|
||||
const bypass = resolveControlUiAuthPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
|
||||
deviceRaw: null,
|
||||
});
|
||||
const strict = resolveControlUiAuthPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: undefined,
|
||||
deviceRaw: null,
|
||||
});
|
||||
expect(shouldSkipControlUiPairing(bypass, true)).toBe(true);
|
||||
expect(shouldSkipControlUiPairing(bypass, false)).toBe(false);
|
||||
expect(shouldSkipControlUiPairing(strict, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
70
src/gateway/server/ws-connection/connect-policy.ts
Normal file
70
src/gateway/server/ws-connection/connect-policy.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { ConnectParams } from "../../protocol/index.js";
|
||||
import type { GatewayRole } from "../../role-policy.js";
|
||||
import { roleCanSkipDeviceIdentity } from "../../role-policy.js";
|
||||
|
||||
export type ControlUiAuthPolicy = {
|
||||
allowInsecureAuthConfigured: boolean;
|
||||
dangerouslyDisableDeviceAuth: boolean;
|
||||
allowBypass: boolean;
|
||||
device: ConnectParams["device"] | null | undefined;
|
||||
};
|
||||
|
||||
export function resolveControlUiAuthPolicy(params: {
|
||||
isControlUi: boolean;
|
||||
controlUiConfig:
|
||||
| {
|
||||
allowInsecureAuth?: boolean;
|
||||
dangerouslyDisableDeviceAuth?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
deviceRaw: ConnectParams["device"] | null | undefined;
|
||||
}): ControlUiAuthPolicy {
|
||||
const allowInsecureAuthConfigured =
|
||||
params.isControlUi && params.controlUiConfig?.allowInsecureAuth === true;
|
||||
const dangerouslyDisableDeviceAuth =
|
||||
params.isControlUi && params.controlUiConfig?.dangerouslyDisableDeviceAuth === true;
|
||||
return {
|
||||
allowInsecureAuthConfigured,
|
||||
dangerouslyDisableDeviceAuth,
|
||||
// `allowInsecureAuth` must not bypass secure-context/device-auth requirements.
|
||||
allowBypass: dangerouslyDisableDeviceAuth,
|
||||
device: dangerouslyDisableDeviceAuth ? null : params.deviceRaw,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldSkipControlUiPairing(
|
||||
policy: ControlUiAuthPolicy,
|
||||
sharedAuthOk: boolean,
|
||||
): boolean {
|
||||
return policy.allowBypass && sharedAuthOk;
|
||||
}
|
||||
|
||||
export type MissingDeviceIdentityDecision =
|
||||
| { kind: "allow" }
|
||||
| { kind: "reject-control-ui-insecure-auth" }
|
||||
| { kind: "reject-unauthorized" }
|
||||
| { kind: "reject-device-required" };
|
||||
|
||||
export function evaluateMissingDeviceIdentity(params: {
|
||||
hasDeviceIdentity: boolean;
|
||||
role: GatewayRole;
|
||||
isControlUi: boolean;
|
||||
controlUiAuthPolicy: ControlUiAuthPolicy;
|
||||
sharedAuthOk: boolean;
|
||||
authOk: boolean;
|
||||
hasSharedAuth: boolean;
|
||||
}): MissingDeviceIdentityDecision {
|
||||
if (params.hasDeviceIdentity) {
|
||||
return { kind: "allow" };
|
||||
}
|
||||
if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) {
|
||||
return { kind: "reject-control-ui-insecure-auth" };
|
||||
}
|
||||
if (roleCanSkipDeviceIdentity(params.role, params.sharedAuthOk)) {
|
||||
return { kind: "allow" };
|
||||
}
|
||||
if (!params.authOk && params.hasSharedAuth) {
|
||||
return { kind: "reject-unauthorized" };
|
||||
}
|
||||
return { kind: "reject-device-required" };
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
validateConnectParams,
|
||||
validateRequestFrame,
|
||||
} from "../../protocol/index.js";
|
||||
import { parseGatewayRole } from "../../role-policy.js";
|
||||
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
|
||||
import { handleGatewayRequest } from "../../server-methods.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
|
||||
@@ -71,45 +72,16 @@ import {
|
||||
} from "../health-state.js";
|
||||
import type { GatewayWsClient } from "../ws-types.js";
|
||||
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
|
||||
import {
|
||||
evaluateMissingDeviceIdentity,
|
||||
resolveControlUiAuthPolicy,
|
||||
shouldSkipControlUiPairing,
|
||||
} from "./connect-policy.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000;
|
||||
|
||||
type ControlUiAuthPolicy = {
|
||||
allowInsecureAuthConfigured: boolean;
|
||||
dangerouslyDisableDeviceAuth: boolean;
|
||||
allowBypass: boolean;
|
||||
device: ConnectParams["device"] | null | undefined;
|
||||
};
|
||||
|
||||
function resolveControlUiAuthPolicy(params: {
|
||||
isControlUi: boolean;
|
||||
controlUiConfig:
|
||||
| {
|
||||
allowInsecureAuth?: boolean;
|
||||
dangerouslyDisableDeviceAuth?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
deviceRaw: ConnectParams["device"] | null | undefined;
|
||||
}): ControlUiAuthPolicy {
|
||||
const allowInsecureAuthConfigured =
|
||||
params.isControlUi && params.controlUiConfig?.allowInsecureAuth === true;
|
||||
const dangerouslyDisableDeviceAuth =
|
||||
params.isControlUi && params.controlUiConfig?.dangerouslyDisableDeviceAuth === true;
|
||||
return {
|
||||
allowInsecureAuthConfigured,
|
||||
dangerouslyDisableDeviceAuth,
|
||||
// `allowInsecureAuth` must not bypass secure-context/device-auth requirements.
|
||||
allowBypass: dangerouslyDisableDeviceAuth,
|
||||
device: dangerouslyDisableDeviceAuth ? null : params.deviceRaw,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldSkipControlUiPairing(policy: ControlUiAuthPolicy, sharedAuthOk: boolean): boolean {
|
||||
return policy.allowBypass && sharedAuthOk;
|
||||
}
|
||||
|
||||
export function attachGatewayWsMessageHandler(params: {
|
||||
socket: WebSocket;
|
||||
upgradeReq: IncomingMessage;
|
||||
@@ -339,7 +311,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
}
|
||||
|
||||
const roleRaw = connectParams.role ?? "operator";
|
||||
const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null;
|
||||
const role = parseGatewayRole(roleRaw);
|
||||
if (!role) {
|
||||
markHandshakeFailure("invalid-role", {
|
||||
role: roleRaw,
|
||||
@@ -486,13 +458,23 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
}
|
||||
};
|
||||
const handleMissingDeviceIdentity = (): boolean => {
|
||||
if (device) {
|
||||
if (!device) {
|
||||
clearUnboundScopes();
|
||||
}
|
||||
const decision = evaluateMissingDeviceIdentity({
|
||||
hasDeviceIdentity: Boolean(device),
|
||||
role,
|
||||
isControlUi,
|
||||
controlUiAuthPolicy,
|
||||
sharedAuthOk,
|
||||
authOk,
|
||||
hasSharedAuth,
|
||||
});
|
||||
if (decision.kind === "allow") {
|
||||
return true;
|
||||
}
|
||||
clearUnboundScopes();
|
||||
const canSkipDevice = role === "operator" && sharedAuthOk;
|
||||
|
||||
if (isControlUi && !controlUiAuthPolicy.allowBypass) {
|
||||
if (decision.kind === "reject-control-ui-insecure-auth") {
|
||||
const errorMessage =
|
||||
"control ui requires device identity (use HTTPS or localhost secure context)";
|
||||
markHandshakeFailure("control-ui-insecure-auth", {
|
||||
@@ -503,29 +485,24 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow shared-secret authenticated connections (e.g., control-ui) to skip device identity.
|
||||
if (!canSkipDevice) {
|
||||
if (!authOk && hasSharedAuth) {
|
||||
rejectUnauthorized(authResult);
|
||||
return false;
|
||||
}
|
||||
markHandshakeFailure("device-required");
|
||||
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required");
|
||||
close(1008, "device identity required");
|
||||
if (decision.kind === "reject-unauthorized") {
|
||||
rejectUnauthorized(authResult);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
markHandshakeFailure("device-required");
|
||||
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required");
|
||||
close(1008, "device identity required");
|
||||
return false;
|
||||
};
|
||||
if (!handleMissingDeviceIdentity()) {
|
||||
return;
|
||||
}
|
||||
if (device) {
|
||||
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
|
||||
if (!derivedId || derivedId !== device.id) {
|
||||
const rejectDeviceAuthInvalid = (reason: string, message: string) => {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-auth-invalid", {
|
||||
reason: "device-id-mismatch",
|
||||
reason,
|
||||
client: connectParams.client.id,
|
||||
deviceId: device.id,
|
||||
});
|
||||
@@ -533,9 +510,13 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "device identity mismatch"),
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, message),
|
||||
});
|
||||
close(1008, "device identity mismatch");
|
||||
close(1008, message);
|
||||
};
|
||||
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
|
||||
if (!derivedId || derivedId !== device.id) {
|
||||
rejectDeviceAuthInvalid("device-id-mismatch", "device identity mismatch");
|
||||
return;
|
||||
}
|
||||
const signedAt = device.signedAt;
|
||||
@@ -543,53 +524,17 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
typeof signedAt !== "number" ||
|
||||
Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS
|
||||
) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-auth-invalid", {
|
||||
reason: "device-signature-stale",
|
||||
client: connectParams.client.id,
|
||||
deviceId: device.id,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature expired"),
|
||||
});
|
||||
close(1008, "device signature expired");
|
||||
rejectDeviceAuthInvalid("device-signature-stale", "device signature expired");
|
||||
return;
|
||||
}
|
||||
const nonceRequired = !isLocalClient;
|
||||
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
|
||||
if (nonceRequired && !providedNonce) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-auth-invalid", {
|
||||
reason: "device-nonce-missing",
|
||||
client: connectParams.client.id,
|
||||
deviceId: device.id,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "device nonce required"),
|
||||
});
|
||||
close(1008, "device nonce required");
|
||||
rejectDeviceAuthInvalid("device-nonce-missing", "device nonce required");
|
||||
return;
|
||||
}
|
||||
if (providedNonce && providedNonce !== connectNonce) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-auth-invalid", {
|
||||
reason: "device-nonce-mismatch",
|
||||
client: connectParams.client.id,
|
||||
deviceId: device.id,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "device nonce mismatch"),
|
||||
});
|
||||
close(1008, "device nonce mismatch");
|
||||
rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch");
|
||||
return;
|
||||
}
|
||||
const payload = buildDeviceAuthPayload({
|
||||
@@ -603,21 +548,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
nonce: providedNonce || undefined,
|
||||
version: providedNonce ? "v2" : "v1",
|
||||
});
|
||||
const rejectDeviceSignatureInvalid = () => {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-auth-invalid", {
|
||||
reason: "device-signature",
|
||||
client: connectParams.client.id,
|
||||
deviceId: device.id,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature invalid"),
|
||||
});
|
||||
close(1008, "device signature invalid");
|
||||
};
|
||||
const rejectDeviceSignatureInvalid = () =>
|
||||
rejectDeviceAuthInvalid("device-signature", "device signature invalid");
|
||||
const signatureOk = verifyDeviceSignature(device.publicKey, payload, device.signature);
|
||||
const allowLegacy = !nonceRequired && !providedNonce;
|
||||
if (!signatureOk && allowLegacy) {
|
||||
@@ -643,19 +575,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
}
|
||||
devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
|
||||
if (!devicePublicKey) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-auth-invalid", {
|
||||
reason: "device-public-key",
|
||||
client: connectParams.client.id,
|
||||
deviceId: device.id,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "device public key invalid"),
|
||||
});
|
||||
close(1008, "device public key invalid");
|
||||
rejectDeviceAuthInvalid("device-public-key", "device public key invalid");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user