refactor: extract websocket handshake auth helpers

This commit is contained in:
Peter Steinberger
2026-03-12 22:39:18 +00:00
parent 1c7ca391a8
commit 01e4845f6d
3 changed files with 350 additions and 172 deletions

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
import type { ConnectParams } from "../../protocol/index.js";
import {
BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP,
resolveHandshakeBrowserSecurityContext,
resolveUnauthorizedHandshakeContext,
shouldAllowSilentLocalPairing,
shouldSkipBackendSelfPairing,
} from "./handshake-auth-helpers.js";
describe("handshake auth helpers", () => {
it("pins browser-origin loopback clients to the synthetic rate-limit ip", () => {
const rateLimiter: AuthRateLimiter = {
check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }),
reset: () => {},
recordFailure: () => {},
size: () => 0,
prune: () => {},
dispose: () => {},
};
const browserRateLimiter: AuthRateLimiter = {
check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }),
reset: () => {},
recordFailure: () => {},
size: () => 0,
prune: () => {},
dispose: () => {},
};
const resolved = resolveHandshakeBrowserSecurityContext({
requestOrigin: "https://app.example",
clientIp: "127.0.0.1",
rateLimiter,
browserRateLimiter,
});
expect(resolved).toMatchObject({
hasBrowserOriginHeader: true,
enforceOriginCheckForAnyClient: true,
rateLimitClientIp: BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP,
authRateLimiter: browserRateLimiter,
});
});
it("recommends device-token retry only for shared-token mismatch with device identity", () => {
const resolved = resolveUnauthorizedHandshakeContext({
connectAuth: { token: "shared-token" },
failedAuth: { ok: false, reason: "token_mismatch" },
hasDeviceIdentity: true,
});
expect(resolved).toEqual({
authProvided: "token",
canRetryWithDeviceToken: true,
recommendedNextStep: "retry_with_device_token",
});
});
it("treats explicit device-token mismatch as credential update guidance", () => {
const resolved = resolveUnauthorizedHandshakeContext({
connectAuth: { deviceToken: "device-token" },
failedAuth: { ok: false, reason: "device_token_mismatch" },
hasDeviceIdentity: true,
});
expect(resolved).toEqual({
authProvided: "device-token",
canRetryWithDeviceToken: false,
recommendedNextStep: "update_auth_credentials",
});
});
it("allows silent local pairing only for not-paired and scope upgrades", () => {
expect(
shouldAllowSilentLocalPairing({
isLocalClient: true,
hasBrowserOriginHeader: false,
isControlUi: false,
isWebchat: false,
reason: "not-paired",
}),
).toBe(true);
expect(
shouldAllowSilentLocalPairing({
isLocalClient: true,
hasBrowserOriginHeader: false,
isControlUi: false,
isWebchat: false,
reason: "metadata-upgrade",
}),
).toBe(false);
});
it("skips backend self-pairing only for local shared-secret backend clients", () => {
const connectParams = {
client: {
id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
mode: GATEWAY_CLIENT_MODES.BACKEND,
},
} as ConnectParams;
expect(
shouldSkipBackendSelfPairing({
connectParams,
isLocalClient: true,
hasBrowserOriginHeader: false,
sharedAuthOk: true,
authMethod: "token",
}),
).toBe(true);
expect(
shouldSkipBackendSelfPairing({
connectParams,
isLocalClient: false,
hasBrowserOriginHeader: false,
sharedAuthOk: true,
authMethod: "token",
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,211 @@
import { verifyDeviceSignature } from "../../../infra/device-identity.js";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import type { GatewayAuthResult } from "../../auth.js";
import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js";
import { isLoopbackAddress } from "../../net.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
import type { ConnectParams } from "../../protocol/index.js";
import type { AuthProvidedKind } from "./auth-messages.js";
export const BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP = "198.18.0.1";
export type HandshakeBrowserSecurityContext = {
hasBrowserOriginHeader: boolean;
enforceOriginCheckForAnyClient: boolean;
rateLimitClientIp: string | undefined;
authRateLimiter?: AuthRateLimiter;
};
type HandshakeConnectAuth = {
token?: string;
bootstrapToken?: string;
deviceToken?: string;
password?: string;
};
export function resolveHandshakeBrowserSecurityContext(params: {
requestOrigin?: string;
clientIp: string | undefined;
rateLimiter?: AuthRateLimiter;
browserRateLimiter?: AuthRateLimiter;
}): HandshakeBrowserSecurityContext {
const hasBrowserOriginHeader = Boolean(
params.requestOrigin && params.requestOrigin.trim() !== "",
);
return {
hasBrowserOriginHeader,
enforceOriginCheckForAnyClient: hasBrowserOriginHeader,
rateLimitClientIp:
hasBrowserOriginHeader && isLoopbackAddress(params.clientIp)
? BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP
: params.clientIp,
authRateLimiter:
hasBrowserOriginHeader && params.browserRateLimiter
? params.browserRateLimiter
: params.rateLimiter,
};
}
export function shouldAllowSilentLocalPairing(params: {
isLocalClient: boolean;
hasBrowserOriginHeader: boolean;
isControlUi: boolean;
isWebchat: boolean;
reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade";
}): boolean {
return (
params.isLocalClient &&
(!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) &&
(params.reason === "not-paired" || params.reason === "scope-upgrade")
);
}
export function shouldSkipBackendSelfPairing(params: {
connectParams: ConnectParams;
isLocalClient: boolean;
hasBrowserOriginHeader: boolean;
sharedAuthOk: boolean;
authMethod: GatewayAuthResult["method"];
}): boolean {
const isGatewayBackendClient =
params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT &&
params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND;
if (!isGatewayBackendClient) {
return false;
}
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
return (
params.isLocalClient &&
!params.hasBrowserOriginHeader &&
params.sharedAuthOk &&
usesSharedSecretAuth
);
}
function resolveSignatureToken(connectParams: ConnectParams): string | null {
return (
connectParams.auth?.token ??
connectParams.auth?.deviceToken ??
connectParams.auth?.bootstrapToken ??
null
);
}
export function resolveDeviceSignaturePayloadVersion(params: {
device: {
id: string;
signature: string;
publicKey: string;
};
connectParams: ConnectParams;
role: string;
scopes: string[];
signedAtMs: number;
nonce: string;
}): "v3" | "v2" | null {
const signatureToken = resolveSignatureToken(params.connectParams);
const payloadV3 = buildDeviceAuthPayloadV3({
deviceId: params.device.id,
clientId: params.connectParams.client.id,
clientMode: params.connectParams.client.mode,
role: params.role,
scopes: params.scopes,
signedAtMs: params.signedAtMs,
token: signatureToken,
nonce: params.nonce,
platform: params.connectParams.client.platform,
deviceFamily: params.connectParams.client.deviceFamily,
});
if (verifyDeviceSignature(params.device.publicKey, payloadV3, params.device.signature)) {
return "v3";
}
const payloadV2 = buildDeviceAuthPayload({
deviceId: params.device.id,
clientId: params.connectParams.client.id,
clientMode: params.connectParams.client.mode,
role: params.role,
scopes: params.scopes,
signedAtMs: params.signedAtMs,
token: signatureToken,
nonce: params.nonce,
});
if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) {
return "v2";
}
return null;
}
export function resolveAuthProvidedKind(
connectAuth: HandshakeConnectAuth | null | undefined,
): AuthProvidedKind {
return connectAuth?.password
? "password"
: connectAuth?.token
? "token"
: connectAuth?.bootstrapToken
? "bootstrap-token"
: connectAuth?.deviceToken
? "device-token"
: "none";
}
export function resolveUnauthorizedHandshakeContext(params: {
connectAuth: HandshakeConnectAuth | null | undefined;
failedAuth: GatewayAuthResult;
hasDeviceIdentity: boolean;
}): {
authProvided: AuthProvidedKind;
canRetryWithDeviceToken: boolean;
recommendedNextStep:
| "retry_with_device_token"
| "update_auth_configuration"
| "update_auth_credentials"
| "wait_then_retry"
| "review_auth_configuration";
} {
const authProvided = resolveAuthProvidedKind(params.connectAuth);
const canRetryWithDeviceToken =
params.failedAuth.reason === "token_mismatch" &&
params.hasDeviceIdentity &&
authProvided === "token" &&
!params.connectAuth?.deviceToken;
if (canRetryWithDeviceToken) {
return {
authProvided,
canRetryWithDeviceToken,
recommendedNextStep: "retry_with_device_token",
};
}
switch (params.failedAuth.reason) {
case "token_missing":
case "token_missing_config":
case "password_missing":
case "password_missing_config":
return {
authProvided,
canRetryWithDeviceToken,
recommendedNextStep: "update_auth_configuration",
};
case "token_mismatch":
case "password_mismatch":
case "device_token_mismatch":
return {
authProvided,
canRetryWithDeviceToken,
recommendedNextStep: "update_auth_credentials",
};
case "rate_limited":
return {
authProvided,
canRetryWithDeviceToken,
recommendedNextStep: "wait_then_retry",
};
default:
return {
authProvided,
canRetryWithDeviceToken,
recommendedNextStep: "review_auth_configuration",
};
}
}

View File

@@ -6,7 +6,6 @@ import { verifyDeviceBootstrapToken } from "../../../infra/device-bootstrap.js";
import {
deriveDeviceIdFromPublicKey,
normalizeDevicePublicKeyBase64Url,
verifyDeviceSignature,
} from "../../../infra/device-identity.js";
import {
approveDevicePairing,
@@ -33,11 +32,7 @@ import {
CANVAS_CAPABILITY_TTL_MS,
mintCanvasCapabilityToken,
} from "../../canvas-capability.js";
import {
buildDeviceAuthPayload,
buildDeviceAuthPayloadV3,
normalizeDeviceMetadataForAuth,
} from "../../device-auth.js";
import { normalizeDeviceMetadataForAuth } from "../../device-auth.js";
import {
isLocalishHost,
isLoopbackAddress,
@@ -46,7 +41,7 @@ import {
} from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import { checkBrowserOrigin } from "../../origin-check.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
import {
ConnectErrorDetailCodes,
resolveDeviceAuthConnectErrorDetailCode,
@@ -83,143 +78,30 @@ import {
} from "../health-state.js";
import type { GatewayWsClient } from "../ws-types.js";
import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-context.js";
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
import { formatGatewayAuthFailureMessage } from "./auth-messages.js";
import {
evaluateMissingDeviceIdentity,
isTrustedProxyControlUiOperatorAuth,
resolveControlUiAuthPolicy,
shouldSkipControlUiPairing,
} from "./connect-policy.js";
import {
resolveDeviceSignaturePayloadVersion,
resolveHandshakeBrowserSecurityContext,
resolveUnauthorizedHandshakeContext,
shouldAllowSilentLocalPairing,
shouldSkipBackendSelfPairing,
} from "./handshake-auth-helpers.js";
import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000;
const BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP = "198.18.0.1";
export type WsOriginCheckMetrics = {
hostHeaderFallbackAccepted: number;
};
type HandshakeBrowserSecurityContext = {
hasBrowserOriginHeader: boolean;
enforceOriginCheckForAnyClient: boolean;
rateLimitClientIp: string | undefined;
authRateLimiter?: AuthRateLimiter;
};
function resolveHandshakeBrowserSecurityContext(params: {
requestOrigin?: string;
hasProxyHeaders: boolean;
clientIp: string | undefined;
rateLimiter?: AuthRateLimiter;
browserRateLimiter?: AuthRateLimiter;
}): HandshakeBrowserSecurityContext {
const hasBrowserOriginHeader = Boolean(
params.requestOrigin && params.requestOrigin.trim() !== "",
);
return {
hasBrowserOriginHeader,
enforceOriginCheckForAnyClient: hasBrowserOriginHeader,
rateLimitClientIp:
hasBrowserOriginHeader && isLoopbackAddress(params.clientIp)
? BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP
: params.clientIp,
authRateLimiter:
hasBrowserOriginHeader && params.browserRateLimiter
? params.browserRateLimiter
: params.rateLimiter,
};
}
function shouldAllowSilentLocalPairing(params: {
isLocalClient: boolean;
hasBrowserOriginHeader: boolean;
isControlUi: boolean;
isWebchat: boolean;
reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade";
}): boolean {
return (
params.isLocalClient &&
(!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) &&
(params.reason === "not-paired" || params.reason === "scope-upgrade")
);
}
function shouldSkipBackendSelfPairing(params: {
connectParams: ConnectParams;
isLocalClient: boolean;
hasBrowserOriginHeader: boolean;
sharedAuthOk: boolean;
authMethod: GatewayAuthResult["method"];
}): boolean {
const isGatewayBackendClient =
params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT &&
params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND;
if (!isGatewayBackendClient) {
return false;
}
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
return (
params.isLocalClient &&
!params.hasBrowserOriginHeader &&
params.sharedAuthOk &&
usesSharedSecretAuth
);
}
function resolveDeviceSignaturePayloadVersion(params: {
device: {
id: string;
signature: string;
publicKey: string;
};
connectParams: ConnectParams;
role: string;
scopes: string[];
signedAtMs: number;
nonce: string;
}): "v3" | "v2" | null {
const payloadV3 = buildDeviceAuthPayloadV3({
deviceId: params.device.id,
clientId: params.connectParams.client.id,
clientMode: params.connectParams.client.mode,
role: params.role,
scopes: params.scopes,
signedAtMs: params.signedAtMs,
token:
params.connectParams.auth?.token ??
params.connectParams.auth?.deviceToken ??
params.connectParams.auth?.bootstrapToken ??
null,
nonce: params.nonce,
platform: params.connectParams.client.platform,
deviceFamily: params.connectParams.client.deviceFamily,
});
if (verifyDeviceSignature(params.device.publicKey, payloadV3, params.device.signature)) {
return "v3";
}
const payloadV2 = buildDeviceAuthPayload({
deviceId: params.device.id,
clientId: params.connectParams.client.id,
clientMode: params.connectParams.client.mode,
role: params.role,
scopes: params.scopes,
signedAtMs: params.signedAtMs,
token:
params.connectParams.auth?.token ??
params.connectParams.auth?.deviceToken ??
params.connectParams.auth?.bootstrapToken ??
null,
nonce: params.nonce,
});
if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) {
return "v2";
}
return null;
}
function resolvePinnedClientMetadata(params: {
claimedPlatform?: string;
claimedDeviceFamily?: string;
@@ -362,7 +244,6 @@ export function attachGatewayWsMessageHandler(params: {
const unauthorizedFloodGuard = new UnauthorizedFloodGuard();
const browserSecurity = resolveHandshakeBrowserSecurityContext({
requestOrigin,
hasProxyHeaders,
clientIp,
rateLimiter,
browserRateLimiter,
@@ -589,57 +470,21 @@ export function attachGatewayWsMessageHandler(params: {
clientIp: browserRateLimitClientIp,
});
const rejectUnauthorized = (failedAuth: GatewayAuthResult) => {
const canRetryWithDeviceToken =
failedAuth.reason === "token_mismatch" &&
Boolean(device) &&
hasSharedAuth &&
!connectParams.auth?.deviceToken;
const recommendedNextStep = (() => {
if (canRetryWithDeviceToken) {
return "retry_with_device_token";
}
switch (failedAuth.reason) {
case "token_missing":
case "token_missing_config":
case "password_missing":
case "password_missing_config":
return "update_auth_configuration";
case "token_mismatch":
case "password_mismatch":
case "device_token_mismatch":
return "update_auth_credentials";
case "rate_limited":
return "wait_then_retry";
default:
return "review_auth_configuration";
}
})();
const { authProvided, canRetryWithDeviceToken, recommendedNextStep } =
resolveUnauthorizedHandshakeContext({
connectAuth: connectParams.auth,
failedAuth,
hasDeviceIdentity: Boolean(device),
});
markHandshakeFailure("unauthorized", {
authMode: resolvedAuth.mode,
authProvided: connectParams.auth?.password
? "password"
: connectParams.auth?.token
? "token"
: connectParams.auth?.bootstrapToken
? "bootstrap-token"
: connectParams.auth?.deviceToken
? "device-token"
: "none",
authProvided,
authReason: failedAuth.reason,
allowTailscale: resolvedAuth.allowTailscale,
});
logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${failedAuth.reason ?? "unknown"}`,
);
const authProvided: AuthProvidedKind = connectParams.auth?.password
? "password"
: connectParams.auth?.token
? "token"
: connectParams.auth?.bootstrapToken
? "bootstrap-token"
: connectParams.auth?.deviceToken
? "device-token"
: "none";
const authMessage = formatGatewayAuthFailureMessage({
authMode: resolvedAuth.mode,
authProvided,