fix: harden connect auth flow and exec policy diagnostics

This commit is contained in:
Peter Steinberger
2026-02-22 20:20:11 +01:00
parent 7e83e7b3a7
commit bbdfba5694
19 changed files with 797 additions and 145 deletions

View File

@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js";
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
limiter: AuthRateLimiter;
reset: ReturnType<typeof vi.fn>;
} {
const allowed = params?.allowed ?? true;
const retryAfterMs = params?.retryAfterMs ?? 5_000;
const check = vi.fn(() => ({ allowed, retryAfterMs }));
const reset = vi.fn();
const recordFailure = vi.fn();
return {
limiter: {
check,
reset,
recordFailure,
} as unknown as AuthRateLimiter,
reset,
};
}
function createBaseState(overrides?: Partial<ConnectAuthState>): ConnectAuthState {
return {
authResult: { ok: false, reason: "token_mismatch" },
authOk: false,
authMethod: "token",
sharedAuthOk: false,
sharedAuthProvided: true,
deviceTokenCandidate: "device-token",
deviceTokenCandidateSource: "shared-token-fallback",
...overrides,
};
}
describe("resolveConnectAuthDecision", () => {
it("keeps shared-secret mismatch when fallback device-token check fails", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
const decision = await resolveConnectAuthDecision({
state: createBaseState(),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
verifyDeviceToken,
});
expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("token_mismatch");
expect(verifyDeviceToken).toHaveBeenCalledOnce();
});
it("reports explicit device-token mismatches as device_token_mismatch", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
deviceTokenCandidateSource: "explicit-device-token",
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
verifyDeviceToken,
});
expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("device_token_mismatch");
});
it("accepts valid device tokens and marks auth method as device-token", async () => {
const rateLimiter = createRateLimiter();
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState(),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
rateLimiter: rateLimiter.limiter,
clientIp: "203.0.113.20",
verifyDeviceToken,
});
expect(decision.authOk).toBe(true);
expect(decision.authMethod).toBe("device-token");
expect(verifyDeviceToken).toHaveBeenCalledOnce();
expect(rateLimiter.reset).toHaveBeenCalledOnce();
});
it("returns rate-limited auth result without verifying device token", async () => {
const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 });
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState(),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
rateLimiter: rateLimiter.limiter,
clientIp: "203.0.113.20",
verifyDeviceToken,
});
expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("rate_limited");
expect(decision.authResult.retryAfterMs).toBe(60_000);
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
it("returns the original decision when device fallback does not apply", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
authResult: { ok: true, method: "token" },
authOk: true,
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: [],
verifyDeviceToken,
});
expect(decision.authOk).toBe(true);
expect(decision.authMethod).toBe("token");
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import type { IncomingMessage } from "node:http";
import {
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
type AuthRateLimiter,
type RateLimitCheckResult,
@@ -29,6 +30,14 @@ export type ConnectAuthState = {
deviceTokenCandidateSource?: DeviceTokenCandidateSource;
};
type VerifyDeviceTokenResult = { ok: boolean };
export type ConnectAuthDecision = {
authResult: GatewayAuthResult;
authOk: boolean;
authMethod: GatewayAuthResult["method"];
};
function trimToUndefined(value: string | undefined): string | undefined {
if (!value) {
return undefined;
@@ -139,3 +148,67 @@ export async function resolveConnectAuthState(params: {
deviceTokenCandidateSource,
};
}
export async function resolveConnectAuthDecision(params: {
state: ConnectAuthState;
hasDeviceIdentity: boolean;
deviceId?: string;
role: string;
scopes: string[];
rateLimiter?: AuthRateLimiter;
clientIp?: string;
verifyDeviceToken: (params: {
deviceId: string;
token: string;
role: string;
scopes: string[];
}) => Promise<VerifyDeviceTokenResult>;
}): Promise<ConnectAuthDecision> {
let authResult = params.state.authResult;
let authOk = params.state.authOk;
let authMethod = params.state.authMethod;
const deviceTokenCandidate = params.state.deviceTokenCandidate;
if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) {
return { authResult, authOk, authMethod };
}
if (params.rateLimiter) {
const deviceRateCheck = params.rateLimiter.check(
params.clientIp,
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
);
if (!deviceRateCheck.allowed) {
authResult = {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: deviceRateCheck.retryAfterMs,
};
}
}
if (!authResult.rateLimited) {
const tokenCheck = await params.verifyDeviceToken({
deviceId: params.deviceId,
token: deviceTokenCandidate,
role: params.role,
scopes: params.scopes,
});
if (tokenCheck.ok) {
authOk = true;
authMethod = "device-token";
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
} else {
authResult = {
ok: false,
reason:
params.state.deviceTokenCandidateSource === "explicit-device-token"
? "device_token_mismatch"
: (authResult.reason ?? "device_token_mismatch"),
};
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
}
}
return { authResult, authOk, authMethod };
}

View File

@@ -24,7 +24,7 @@ import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
import { resolveRuntimeServiceVersion } from "../../../version.js";
import { AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, type AuthRateLimiter } from "../../auth-rate-limit.js";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
import { isLocalDirectRequest } from "../../auth.js";
import {
@@ -42,6 +42,10 @@ import {
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import { checkBrowserOrigin } from "../../origin-check.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
import {
ConnectErrorDetailCodes,
resolveAuthConnectErrorDetailCode,
} from "../../protocol/connect-error-details.js";
import {
type ConnectParams,
ErrorCodes,
@@ -67,7 +71,7 @@ import {
refreshGatewayHealthSnapshot,
} from "../health-state.js";
import type { GatewayWsClient } from "../ws-types.js";
import { resolveConnectAuthState } from "./auth-context.js";
import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-context.js";
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
import {
evaluateMissingDeviceIdentity,
@@ -401,7 +405,12 @@ export function attachGatewayWsMessageHandler(params: {
reason: failedAuth.reason,
client: connectParams.client,
});
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage);
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage, {
details: {
code: resolveAuthConnectErrorDetailCode(failedAuth.reason),
authReason: failedAuth.reason,
},
});
close(1008, truncateCloseReason(authMessage));
};
const clearUnboundScopes = () => {
@@ -434,7 +443,9 @@ export function attachGatewayWsMessageHandler(params: {
markHandshakeFailure("control-ui-insecure-auth", {
insecureAuthConfigured: controlUiAuthPolicy.allowInsecureAuthConfigured,
});
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage);
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage, {
details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED },
});
close(1008, errorMessage);
return false;
}
@@ -445,7 +456,9 @@ export function attachGatewayWsMessageHandler(params: {
}
markHandshakeFailure("device-required");
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required");
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required", {
details: { code: ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED },
});
close(1008, "device identity required");
return false;
};
@@ -464,7 +477,12 @@ export function attachGatewayWsMessageHandler(params: {
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, message),
error: errorShape(ErrorCodes.INVALID_REQUEST, message, {
details: {
code: ConnectErrorDetailCodes.DEVICE_AUTH_INVALID,
reason,
},
}),
});
close(1008, message);
};
@@ -514,39 +532,24 @@ export function attachGatewayWsMessageHandler(params: {
}
}
if (!authOk && device && deviceTokenCandidate) {
if (rateLimiter) {
const deviceRateCheck = rateLimiter.check(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
if (!deviceRateCheck.allowed) {
authResult = {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: deviceRateCheck.retryAfterMs,
};
}
}
if (!authResult.rateLimited) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,
token: deviceTokenCandidate,
role,
scopes,
});
if (tokenCheck.ok) {
authOk = true;
authMethod = "device-token";
rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
} else {
const mismatchReason =
deviceTokenCandidateSource === "explicit-device-token"
? "device_token_mismatch"
: (authResult.reason ?? "device_token_mismatch");
authResult = { ok: false, reason: mismatchReason };
rateLimiter?.recordFailure(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
}
}
}
({ authResult, authOk, authMethod } = await resolveConnectAuthDecision({
state: {
authResult,
authOk,
authMethod,
sharedAuthOk,
sharedAuthProvided: hasSharedAuth,
deviceTokenCandidate,
deviceTokenCandidateSource,
},
hasDeviceIdentity: Boolean(device),
deviceId: device?.id,
role,
scopes,
rateLimiter,
clientIp,
verifyDeviceToken,
}));
if (!authOk) {
rejectUnauthorized(authResult);
return;
@@ -636,7 +639,11 @@ export function attachGatewayWsMessageHandler(params: {
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.NOT_PAIRED, "pairing required", {
details: { requestId: pairing.request.requestId },
details: {
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
requestId: pairing.request.requestId,
reason,
},
}),
});
close(1008, "pairing required");