refactor(gateway): extract connect and role policy logic

This commit is contained in:
Peter Steinberger
2026-02-21 19:47:17 +01:00
parent f97c45c5b5
commit 51149fcaf1
7 changed files with 342 additions and 157 deletions

View File

@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import {
isRoleAuthorizedForMethod,
parseGatewayRole,
roleCanSkipDeviceIdentity,
} from "./role-policy.js";
describe("gateway role policy", () => {
test("parses supported roles", () => {
expect(parseGatewayRole("operator")).toBe("operator");
expect(parseGatewayRole("node")).toBe("node");
expect(parseGatewayRole("admin")).toBeNull();
expect(parseGatewayRole(undefined)).toBeNull();
});
test("allows device-less bypass only for operator + shared auth", () => {
expect(roleCanSkipDeviceIdentity("operator", true)).toBe(true);
expect(roleCanSkipDeviceIdentity("operator", false)).toBe(false);
expect(roleCanSkipDeviceIdentity("node", true)).toBe(false);
});
test("authorizes roles against node vs operator methods", () => {
expect(isRoleAuthorizedForMethod("node", "node.event")).toBe(true);
expect(isRoleAuthorizedForMethod("node", "status")).toBe(false);
expect(isRoleAuthorizedForMethod("operator", "status")).toBe(true);
expect(isRoleAuthorizedForMethod("operator", "node.event")).toBe(false);
});
});

View File

@@ -0,0 +1,23 @@
import { isNodeRoleMethod } from "./method-scopes.js";
export const GATEWAY_ROLES = ["operator", "node"] as const;
export type GatewayRole = (typeof GATEWAY_ROLES)[number];
export function parseGatewayRole(roleRaw: unknown): GatewayRole | null {
if (roleRaw === "operator" || roleRaw === "node") {
return roleRaw;
}
return null;
}
export function roleCanSkipDeviceIdentity(role: GatewayRole, sharedAuthOk: boolean): boolean {
return role === "operator" && sharedAuthOk;
}
export function isRoleAuthorizedForMethod(role: GatewayRole, method: string): boolean {
if (isNodeRoleMethod(method)) {
return role === "node";
}
return role === "operator";
}

View File

@@ -1,11 +1,8 @@
import { formatControlPlaneActor, resolveControlPlaneActor } from "./control-plane-audit.js"; import { formatControlPlaneActor, resolveControlPlaneActor } from "./control-plane-audit.js";
import { consumeControlPlaneWriteBudget } from "./control-plane-rate-limit.js"; import { consumeControlPlaneWriteBudget } from "./control-plane-rate-limit.js";
import { import { ADMIN_SCOPE, authorizeOperatorScopesForMethod } from "./method-scopes.js";
ADMIN_SCOPE,
authorizeOperatorScopesForMethod,
isNodeRoleMethod,
} from "./method-scopes.js";
import { ErrorCodes, errorShape } from "./protocol/index.js"; import { ErrorCodes, errorShape } from "./protocol/index.js";
import { isRoleAuthorizedForMethod, parseGatewayRole } from "./role-policy.js";
import { agentHandlers } from "./server-methods/agent.js"; import { agentHandlers } from "./server-methods/agent.js";
import { agentsHandlers } from "./server-methods/agents.js"; import { agentsHandlers } from "./server-methods/agents.js";
import { browserHandlers } from "./server-methods/browser.js"; import { browserHandlers } from "./server-methods/browser.js";
@@ -42,19 +39,17 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
if (method === "health") { if (method === "health") {
return null; return null;
} }
const role = client.connect.role ?? "operator"; const roleRaw = client.connect.role ?? "operator";
const role = parseGatewayRole(roleRaw);
if (!role) {
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${roleRaw}`);
}
const scopes = client.connect.scopes ?? []; const scopes = client.connect.scopes ?? [];
if (isNodeRoleMethod(method)) { if (!isRoleAuthorizedForMethod(role, method)) {
if (role === "node") {
return null;
}
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
} }
if (role === "node") { if (role === "node") {
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); return null;
}
if (role !== "operator") {
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
} }
if (scopes.includes(ADMIN_SCOPE)) { if (scopes.includes(ADMIN_SCOPE)) {
return null; return null;

View File

@@ -85,6 +85,13 @@ const CONTROL_UI_CLIENT = {
mode: GATEWAY_CLIENT_MODES.WEBCHAT, mode: GATEWAY_CLIENT_MODES.WEBCHAT,
}; };
const NODE_CLIENT = {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.NODE,
};
async function expectHelloOkServerVersion(port: number, expectedVersion: string) { async function expectHelloOkServerVersion(port: number, expectedVersion: string) {
const ws = await openWs(port); const ws = await openWs(port);
try { try {
@@ -359,29 +366,56 @@ describe("gateway server auth/connect", () => {
await expectMissingScopeAfterConnect(port, { scopes: [] }); await expectMissingScopeAfterConnect(port, { scopes: [] });
}); });
test("ignores requested scopes when device identity is omitted", async () => { test("device-less auth matrix", async () => {
await expectMissingScopeAfterConnect(port, { device: null });
});
test("rejects node role when device identity is omitted", async () => {
const ws = await openWs(port);
const token = resolveGatewayTokenOrEnv(); const token = resolveGatewayTokenOrEnv();
try { const matrix: Array<{
const res = await connectReq(ws, { name: string;
role: "node", opts: Parameters<typeof connectReq>[1];
token, expectConnectOk: boolean;
device: null, expectConnectError?: string;
client: { expectStatusError?: string;
id: GATEWAY_CLIENT_NAMES.NODE_HOST, }> = [
version: "1.0.0", {
platform: "test", name: "operator + valid shared token => connected with zero scopes",
mode: GATEWAY_CLIENT_MODES.NODE, opts: { role: "operator", token, device: null },
}, expectConnectOk: true,
}); expectStatusError: "missing scope",
expect(res.ok).toBe(false); },
expect(res.error?.message ?? "").toContain("device identity required"); {
} finally { name: "node + valid shared token => rejected without device",
ws.close(); opts: { role: "node", token, device: null, client: NODE_CLIENT },
expectConnectOk: false,
expectConnectError: "device identity required",
},
{
name: "operator + invalid shared token => unauthorized",
opts: { role: "operator", token: "wrong", device: null },
expectConnectOk: false,
expectConnectError: "unauthorized",
},
];
for (const scenario of matrix) {
const ws = await openWs(port);
try {
const res = await connectReq(ws, scenario.opts);
expect(res.ok, scenario.name).toBe(scenario.expectConnectOk);
if (!scenario.expectConnectOk) {
expect(res.error?.message ?? "", scenario.name).toContain(
String(scenario.expectConnectError ?? ""),
);
continue;
}
if (scenario.expectStatusError) {
const status = await rpcReq(ws, "status");
expect(status.ok, scenario.name).toBe(false);
expect(status.error?.message ?? "", scenario.name).toContain(
scenario.expectStatusError,
);
}
} finally {
ws.close();
}
} }
}); });

View 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);
});
});

View 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" };
}

View File

@@ -56,6 +56,7 @@ import {
validateConnectParams, validateConnectParams,
validateRequestFrame, validateRequestFrame,
} from "../../protocol/index.js"; } 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 { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
import { handleGatewayRequest } from "../../server-methods.js"; import { handleGatewayRequest } from "../../server-methods.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
@@ -71,45 +72,16 @@ import {
} from "../health-state.js"; } from "../health-state.js";
import type { GatewayWsClient } from "../ws-types.js"; import type { GatewayWsClient } from "../ws-types.js";
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js"; import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
import {
evaluateMissingDeviceIdentity,
resolveControlUiAuthPolicy,
shouldSkipControlUiPairing,
} from "./connect-policy.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>; type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000; 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: { export function attachGatewayWsMessageHandler(params: {
socket: WebSocket; socket: WebSocket;
upgradeReq: IncomingMessage; upgradeReq: IncomingMessage;
@@ -339,7 +311,7 @@ export function attachGatewayWsMessageHandler(params: {
} }
const roleRaw = connectParams.role ?? "operator"; const roleRaw = connectParams.role ?? "operator";
const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null; const role = parseGatewayRole(roleRaw);
if (!role) { if (!role) {
markHandshakeFailure("invalid-role", { markHandshakeFailure("invalid-role", {
role: roleRaw, role: roleRaw,
@@ -486,13 +458,23 @@ export function attachGatewayWsMessageHandler(params: {
} }
}; };
const handleMissingDeviceIdentity = (): boolean => { 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; return true;
} }
clearUnboundScopes();
const canSkipDevice = role === "operator" && sharedAuthOk;
if (isControlUi && !controlUiAuthPolicy.allowBypass) { if (decision.kind === "reject-control-ui-insecure-auth") {
const errorMessage = const errorMessage =
"control ui requires device identity (use HTTPS or localhost secure context)"; "control ui requires device identity (use HTTPS or localhost secure context)";
markHandshakeFailure("control-ui-insecure-auth", { markHandshakeFailure("control-ui-insecure-auth", {
@@ -503,29 +485,24 @@ export function attachGatewayWsMessageHandler(params: {
return false; return false;
} }
// Allow shared-secret authenticated connections (e.g., control-ui) to skip device identity. if (decision.kind === "reject-unauthorized") {
if (!canSkipDevice) { rejectUnauthorized(authResult);
if (!authOk && hasSharedAuth) {
rejectUnauthorized(authResult);
return false;
}
markHandshakeFailure("device-required");
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required");
close(1008, "device identity required");
return false; return false;
} }
return true; markHandshakeFailure("device-required");
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required");
close(1008, "device identity required");
return false;
}; };
if (!handleMissingDeviceIdentity()) { if (!handleMissingDeviceIdentity()) {
return; return;
} }
if (device) { if (device) {
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey); const rejectDeviceAuthInvalid = (reason: string, message: string) => {
if (!derivedId || derivedId !== device.id) {
setHandshakeState("failed"); setHandshakeState("failed");
setCloseCause("device-auth-invalid", { setCloseCause("device-auth-invalid", {
reason: "device-id-mismatch", reason,
client: connectParams.client.id, client: connectParams.client.id,
deviceId: device.id, deviceId: device.id,
}); });
@@ -533,9 +510,13 @@ export function attachGatewayWsMessageHandler(params: {
type: "res", type: "res",
id: frame.id, id: frame.id,
ok: false, 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; return;
} }
const signedAt = device.signedAt; const signedAt = device.signedAt;
@@ -543,53 +524,17 @@ export function attachGatewayWsMessageHandler(params: {
typeof signedAt !== "number" || typeof signedAt !== "number" ||
Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS
) { ) {
setHandshakeState("failed"); rejectDeviceAuthInvalid("device-signature-stale", "device signature expired");
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");
return; return;
} }
const nonceRequired = !isLocalClient; const nonceRequired = !isLocalClient;
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : ""; const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
if (nonceRequired && !providedNonce) { if (nonceRequired && !providedNonce) {
setHandshakeState("failed"); rejectDeviceAuthInvalid("device-nonce-missing", "device nonce required");
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");
return; return;
} }
if (providedNonce && providedNonce !== connectNonce) { if (providedNonce && providedNonce !== connectNonce) {
setHandshakeState("failed"); rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch");
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");
return; return;
} }
const payload = buildDeviceAuthPayload({ const payload = buildDeviceAuthPayload({
@@ -603,21 +548,8 @@ export function attachGatewayWsMessageHandler(params: {
nonce: providedNonce || undefined, nonce: providedNonce || undefined,
version: providedNonce ? "v2" : "v1", version: providedNonce ? "v2" : "v1",
}); });
const rejectDeviceSignatureInvalid = () => { const rejectDeviceSignatureInvalid = () =>
setHandshakeState("failed"); rejectDeviceAuthInvalid("device-signature", "device signature invalid");
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 signatureOk = verifyDeviceSignature(device.publicKey, payload, device.signature); const signatureOk = verifyDeviceSignature(device.publicKey, payload, device.signature);
const allowLegacy = !nonceRequired && !providedNonce; const allowLegacy = !nonceRequired && !providedNonce;
if (!signatureOk && allowLegacy) { if (!signatureOk && allowLegacy) {
@@ -643,19 +575,7 @@ export function attachGatewayWsMessageHandler(params: {
} }
devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey); devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
if (!devicePublicKey) { if (!devicePublicKey) {
setHandshakeState("failed"); rejectDeviceAuthInvalid("device-public-key", "device public key invalid");
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");
return; return;
} }
} }