Skip to content

Commit 01e4845

Browse files
committed
refactor: extract websocket handshake auth helpers
1 parent 1c7ca39 commit 01e4845

File tree

3 files changed

+350
-172
lines changed

3 files changed

+350
-172
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
3+
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
4+
import type { ConnectParams } from "../../protocol/index.js";
5+
import {
6+
BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP,
7+
resolveHandshakeBrowserSecurityContext,
8+
resolveUnauthorizedHandshakeContext,
9+
shouldAllowSilentLocalPairing,
10+
shouldSkipBackendSelfPairing,
11+
} from "./handshake-auth-helpers.js";
12+
13+
describe("handshake auth helpers", () => {
14+
it("pins browser-origin loopback clients to the synthetic rate-limit ip", () => {
15+
const rateLimiter: AuthRateLimiter = {
16+
check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }),
17+
reset: () => {},
18+
recordFailure: () => {},
19+
size: () => 0,
20+
prune: () => {},
21+
dispose: () => {},
22+
};
23+
const browserRateLimiter: AuthRateLimiter = {
24+
check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }),
25+
reset: () => {},
26+
recordFailure: () => {},
27+
size: () => 0,
28+
prune: () => {},
29+
dispose: () => {},
30+
};
31+
const resolved = resolveHandshakeBrowserSecurityContext({
32+
requestOrigin: "https://app.example",
33+
clientIp: "127.0.0.1",
34+
rateLimiter,
35+
browserRateLimiter,
36+
});
37+
38+
expect(resolved).toMatchObject({
39+
hasBrowserOriginHeader: true,
40+
enforceOriginCheckForAnyClient: true,
41+
rateLimitClientIp: BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP,
42+
authRateLimiter: browserRateLimiter,
43+
});
44+
});
45+
46+
it("recommends device-token retry only for shared-token mismatch with device identity", () => {
47+
const resolved = resolveUnauthorizedHandshakeContext({
48+
connectAuth: { token: "shared-token" },
49+
failedAuth: { ok: false, reason: "token_mismatch" },
50+
hasDeviceIdentity: true,
51+
});
52+
53+
expect(resolved).toEqual({
54+
authProvided: "token",
55+
canRetryWithDeviceToken: true,
56+
recommendedNextStep: "retry_with_device_token",
57+
});
58+
});
59+
60+
it("treats explicit device-token mismatch as credential update guidance", () => {
61+
const resolved = resolveUnauthorizedHandshakeContext({
62+
connectAuth: { deviceToken: "device-token" },
63+
failedAuth: { ok: false, reason: "device_token_mismatch" },
64+
hasDeviceIdentity: true,
65+
});
66+
67+
expect(resolved).toEqual({
68+
authProvided: "device-token",
69+
canRetryWithDeviceToken: false,
70+
recommendedNextStep: "update_auth_credentials",
71+
});
72+
});
73+
74+
it("allows silent local pairing only for not-paired and scope upgrades", () => {
75+
expect(
76+
shouldAllowSilentLocalPairing({
77+
isLocalClient: true,
78+
hasBrowserOriginHeader: false,
79+
isControlUi: false,
80+
isWebchat: false,
81+
reason: "not-paired",
82+
}),
83+
).toBe(true);
84+
expect(
85+
shouldAllowSilentLocalPairing({
86+
isLocalClient: true,
87+
hasBrowserOriginHeader: false,
88+
isControlUi: false,
89+
isWebchat: false,
90+
reason: "metadata-upgrade",
91+
}),
92+
).toBe(false);
93+
});
94+
95+
it("skips backend self-pairing only for local shared-secret backend clients", () => {
96+
const connectParams = {
97+
client: {
98+
id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
99+
mode: GATEWAY_CLIENT_MODES.BACKEND,
100+
},
101+
} as ConnectParams;
102+
103+
expect(
104+
shouldSkipBackendSelfPairing({
105+
connectParams,
106+
isLocalClient: true,
107+
hasBrowserOriginHeader: false,
108+
sharedAuthOk: true,
109+
authMethod: "token",
110+
}),
111+
).toBe(true);
112+
expect(
113+
shouldSkipBackendSelfPairing({
114+
connectParams,
115+
isLocalClient: false,
116+
hasBrowserOriginHeader: false,
117+
sharedAuthOk: true,
118+
authMethod: "token",
119+
}),
120+
).toBe(false);
121+
});
122+
});
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { verifyDeviceSignature } from "../../../infra/device-identity.js";
2+
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
3+
import type { GatewayAuthResult } from "../../auth.js";
4+
import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js";
5+
import { isLoopbackAddress } from "../../net.js";
6+
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
7+
import type { ConnectParams } from "../../protocol/index.js";
8+
import type { AuthProvidedKind } from "./auth-messages.js";
9+
10+
export const BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP = "198.18.0.1";
11+
12+
export type HandshakeBrowserSecurityContext = {
13+
hasBrowserOriginHeader: boolean;
14+
enforceOriginCheckForAnyClient: boolean;
15+
rateLimitClientIp: string | undefined;
16+
authRateLimiter?: AuthRateLimiter;
17+
};
18+
19+
type HandshakeConnectAuth = {
20+
token?: string;
21+
bootstrapToken?: string;
22+
deviceToken?: string;
23+
password?: string;
24+
};
25+
26+
export function resolveHandshakeBrowserSecurityContext(params: {
27+
requestOrigin?: string;
28+
clientIp: string | undefined;
29+
rateLimiter?: AuthRateLimiter;
30+
browserRateLimiter?: AuthRateLimiter;
31+
}): HandshakeBrowserSecurityContext {
32+
const hasBrowserOriginHeader = Boolean(
33+
params.requestOrigin && params.requestOrigin.trim() !== "",
34+
);
35+
return {
36+
hasBrowserOriginHeader,
37+
enforceOriginCheckForAnyClient: hasBrowserOriginHeader,
38+
rateLimitClientIp:
39+
hasBrowserOriginHeader && isLoopbackAddress(params.clientIp)
40+
? BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP
41+
: params.clientIp,
42+
authRateLimiter:
43+
hasBrowserOriginHeader && params.browserRateLimiter
44+
? params.browserRateLimiter
45+
: params.rateLimiter,
46+
};
47+
}
48+
49+
export function shouldAllowSilentLocalPairing(params: {
50+
isLocalClient: boolean;
51+
hasBrowserOriginHeader: boolean;
52+
isControlUi: boolean;
53+
isWebchat: boolean;
54+
reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade";
55+
}): boolean {
56+
return (
57+
params.isLocalClient &&
58+
(!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) &&
59+
(params.reason === "not-paired" || params.reason === "scope-upgrade")
60+
);
61+
}
62+
63+
export function shouldSkipBackendSelfPairing(params: {
64+
connectParams: ConnectParams;
65+
isLocalClient: boolean;
66+
hasBrowserOriginHeader: boolean;
67+
sharedAuthOk: boolean;
68+
authMethod: GatewayAuthResult["method"];
69+
}): boolean {
70+
const isGatewayBackendClient =
71+
params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT &&
72+
params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND;
73+
if (!isGatewayBackendClient) {
74+
return false;
75+
}
76+
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
77+
return (
78+
params.isLocalClient &&
79+
!params.hasBrowserOriginHeader &&
80+
params.sharedAuthOk &&
81+
usesSharedSecretAuth
82+
);
83+
}
84+
85+
function resolveSignatureToken(connectParams: ConnectParams): string | null {
86+
return (
87+
connectParams.auth?.token ??
88+
connectParams.auth?.deviceToken ??
89+
connectParams.auth?.bootstrapToken ??
90+
null
91+
);
92+
}
93+
94+
export function resolveDeviceSignaturePayloadVersion(params: {
95+
device: {
96+
id: string;
97+
signature: string;
98+
publicKey: string;
99+
};
100+
connectParams: ConnectParams;
101+
role: string;
102+
scopes: string[];
103+
signedAtMs: number;
104+
nonce: string;
105+
}): "v3" | "v2" | null {
106+
const signatureToken = resolveSignatureToken(params.connectParams);
107+
const payloadV3 = buildDeviceAuthPayloadV3({
108+
deviceId: params.device.id,
109+
clientId: params.connectParams.client.id,
110+
clientMode: params.connectParams.client.mode,
111+
role: params.role,
112+
scopes: params.scopes,
113+
signedAtMs: params.signedAtMs,
114+
token: signatureToken,
115+
nonce: params.nonce,
116+
platform: params.connectParams.client.platform,
117+
deviceFamily: params.connectParams.client.deviceFamily,
118+
});
119+
if (verifyDeviceSignature(params.device.publicKey, payloadV3, params.device.signature)) {
120+
return "v3";
121+
}
122+
123+
const payloadV2 = buildDeviceAuthPayload({
124+
deviceId: params.device.id,
125+
clientId: params.connectParams.client.id,
126+
clientMode: params.connectParams.client.mode,
127+
role: params.role,
128+
scopes: params.scopes,
129+
signedAtMs: params.signedAtMs,
130+
token: signatureToken,
131+
nonce: params.nonce,
132+
});
133+
if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) {
134+
return "v2";
135+
}
136+
return null;
137+
}
138+
139+
export function resolveAuthProvidedKind(
140+
connectAuth: HandshakeConnectAuth | null | undefined,
141+
): AuthProvidedKind {
142+
return connectAuth?.password
143+
? "password"
144+
: connectAuth?.token
145+
? "token"
146+
: connectAuth?.bootstrapToken
147+
? "bootstrap-token"
148+
: connectAuth?.deviceToken
149+
? "device-token"
150+
: "none";
151+
}
152+
153+
export function resolveUnauthorizedHandshakeContext(params: {
154+
connectAuth: HandshakeConnectAuth | null | undefined;
155+
failedAuth: GatewayAuthResult;
156+
hasDeviceIdentity: boolean;
157+
}): {
158+
authProvided: AuthProvidedKind;
159+
canRetryWithDeviceToken: boolean;
160+
recommendedNextStep:
161+
| "retry_with_device_token"
162+
| "update_auth_configuration"
163+
| "update_auth_credentials"
164+
| "wait_then_retry"
165+
| "review_auth_configuration";
166+
} {
167+
const authProvided = resolveAuthProvidedKind(params.connectAuth);
168+
const canRetryWithDeviceToken =
169+
params.failedAuth.reason === "token_mismatch" &&
170+
params.hasDeviceIdentity &&
171+
authProvided === "token" &&
172+
!params.connectAuth?.deviceToken;
173+
if (canRetryWithDeviceToken) {
174+
return {
175+
authProvided,
176+
canRetryWithDeviceToken,
177+
recommendedNextStep: "retry_with_device_token",
178+
};
179+
}
180+
switch (params.failedAuth.reason) {
181+
case "token_missing":
182+
case "token_missing_config":
183+
case "password_missing":
184+
case "password_missing_config":
185+
return {
186+
authProvided,
187+
canRetryWithDeviceToken,
188+
recommendedNextStep: "update_auth_configuration",
189+
};
190+
case "token_mismatch":
191+
case "password_mismatch":
192+
case "device_token_mismatch":
193+
return {
194+
authProvided,
195+
canRetryWithDeviceToken,
196+
recommendedNextStep: "update_auth_credentials",
197+
};
198+
case "rate_limited":
199+
return {
200+
authProvided,
201+
canRetryWithDeviceToken,
202+
recommendedNextStep: "wait_then_retry",
203+
};
204+
default:
205+
return {
206+
authProvided,
207+
canRetryWithDeviceToken,
208+
recommendedNextStep: "review_auth_configuration",
209+
};
210+
}
211+
}

0 commit comments

Comments
 (0)