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,66 @@
export const ConnectErrorDetailCodes = {
AUTH_REQUIRED: "AUTH_REQUIRED",
AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED",
AUTH_TOKEN_MISSING: "AUTH_TOKEN_MISSING",
AUTH_TOKEN_MISMATCH: "AUTH_TOKEN_MISMATCH",
AUTH_TOKEN_NOT_CONFIGURED: "AUTH_TOKEN_NOT_CONFIGURED",
AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING",
AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH",
AUTH_PASSWORD_NOT_CONFIGURED: "AUTH_PASSWORD_NOT_CONFIGURED",
AUTH_DEVICE_TOKEN_MISMATCH: "AUTH_DEVICE_TOKEN_MISMATCH",
AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED",
AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING",
AUTH_TAILSCALE_PROXY_MISSING: "AUTH_TAILSCALE_PROXY_MISSING",
AUTH_TAILSCALE_WHOIS_FAILED: "AUTH_TAILSCALE_WHOIS_FAILED",
AUTH_TAILSCALE_IDENTITY_MISMATCH: "AUTH_TAILSCALE_IDENTITY_MISMATCH",
CONTROL_UI_DEVICE_IDENTITY_REQUIRED: "CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
DEVICE_IDENTITY_REQUIRED: "DEVICE_IDENTITY_REQUIRED",
DEVICE_AUTH_INVALID: "DEVICE_AUTH_INVALID",
PAIRING_REQUIRED: "PAIRING_REQUIRED",
} as const;
export type ConnectErrorDetailCode =
(typeof ConnectErrorDetailCodes)[keyof typeof ConnectErrorDetailCodes];
export function resolveAuthConnectErrorDetailCode(
reason: string | undefined,
): ConnectErrorDetailCode {
switch (reason) {
case "token_missing":
return ConnectErrorDetailCodes.AUTH_TOKEN_MISSING;
case "token_mismatch":
return ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH;
case "token_missing_config":
return ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED;
case "password_missing":
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING;
case "password_mismatch":
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH;
case "password_missing_config":
return ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED;
case "tailscale_user_missing":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING;
case "tailscale_proxy_missing":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING;
case "tailscale_whois_failed":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED;
case "tailscale_user_mismatch":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH;
case "rate_limited":
return ConnectErrorDetailCodes.AUTH_RATE_LIMITED;
case "device_token_mismatch":
return ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH;
case undefined:
return ConnectErrorDetailCodes.AUTH_REQUIRED;
default:
return ConnectErrorDetailCodes.AUTH_UNAUTHORIZED;
}
}
export function readConnectErrorDetailCode(details: unknown): string | null {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return null;
}
const code = (details as { code?: unknown }).code;
return typeof code === "string" && code.trim().length > 0 ? code : null;
}

View File

@@ -3,6 +3,7 @@ import { WebSocket } from "ws";
import { withEnvAsync } from "../test-utils/env.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import { ConnectErrorDetailCodes } from "./protocol/connect-error-details.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { getHandshakeTimeoutMs } from "./server-constants.js";
import {
@@ -716,6 +717,9 @@ describe("gateway server auth/connect", () => {
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("secure context");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
);
ws.close();
});
});
@@ -898,6 +902,9 @@ describe("gateway server auth/connect", () => {
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("pairing required");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.PAIRING_REQUIRED,
);
ws.close();
});
} finally {
@@ -1004,6 +1011,9 @@ describe("gateway server auth/connect", () => {
expect(res2.ok).toBe(false);
expect(res2.error?.message ?? "").toContain("gateway token mismatch");
expect(res2.error?.message ?? "").not.toContain("device token mismatch");
expect((res2.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
);
ws2.close();
await server.close();
@@ -1023,6 +1033,9 @@ describe("gateway server auth/connect", () => {
});
expect(res2.ok).toBe(false);
expect(res2.error?.message ?? "").toContain("device token mismatch");
expect((res2.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
);
ws2.close();
await server.close();

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");

View File

@@ -409,7 +409,7 @@ type ConnectResponse = {
id: string;
ok: boolean;
payload?: Record<string, unknown>;
error?: { message?: string };
error?: { message?: string; code?: string; details?: unknown };
};
export async function readConnectChallengeNonce(