fix(gateway): harden token fallback/reconnect behavior and docs (#42507)

* fix(gateway): harden token fallback and auth reconnect handling

* docs(gateway): clarify auth retry and token-drift recovery

* fix(gateway): tighten auth reconnect gating across clients

* fix: harden gateway token retry (#42507) (thanks @joshavant)
This commit is contained in:
Josh Avant
2026-03-10 17:05:57 -05:00
committed by GitHub
parent ff2e7a2945
commit a76e810193
21 changed files with 1188 additions and 80 deletions

View File

@@ -7,7 +7,6 @@ const wsInstances = vi.hoisted((): MockWebSocket[] => []);
const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
const loadDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
const storeDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
const clearDevicePairingMock = vi.hoisted(() => vi.fn());
const logDebugMock = vi.hoisted(() => vi.fn());
type WsEvent = "open" | "message" | "close" | "error";
@@ -52,7 +51,9 @@ class MockWebSocket {
}
}
close(_code?: number, _reason?: string): void {}
close(code?: number, reason?: string): void {
this.emitClose(code ?? 1000, reason ?? "");
}
send(data: string): void {
this.sent.push(data);
@@ -91,14 +92,6 @@ vi.mock("../infra/device-auth-store.js", async (importOriginal) => {
};
});
vi.mock("../infra/device-pairing.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/device-pairing.js")>();
return {
...actual,
clearDevicePairing: (...args: unknown[]) => clearDevicePairingMock(...args),
};
});
vi.mock("../logger.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../logger.js")>();
return {
@@ -250,8 +243,6 @@ describe("GatewayClient close handling", () => {
wsInstances.length = 0;
clearDeviceAuthTokenMock.mockClear();
clearDeviceAuthTokenMock.mockImplementation(() => undefined);
clearDevicePairingMock.mockClear();
clearDevicePairingMock.mockResolvedValue(true);
logDebugMock.mockClear();
});
@@ -266,7 +257,7 @@ describe("GatewayClient close handling", () => {
);
expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({ deviceId: "dev-1", role: "operator" });
expect(clearDevicePairingMock).toHaveBeenCalledWith("dev-1");
expect(logDebugMock).toHaveBeenCalledWith("cleared stale device-auth token for device dev-1");
expect(onClose).toHaveBeenCalledWith(
1008,
"unauthorized: DEVICE token mismatch (rotate/reissue device token)",
@@ -289,38 +280,18 @@ describe("GatewayClient close handling", () => {
expect(logDebugMock).toHaveBeenCalledWith(
expect.stringContaining("failed clearing stale device-auth token"),
);
expect(clearDevicePairingMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
client.stop();
});
it("does not break close flow when pairing clear rejects", async () => {
clearDevicePairingMock.mockRejectedValue(new Error("pairing store unavailable"));
const onClose = vi.fn();
const client = createClientWithIdentity("dev-3", onClose);
client.start();
expect(() => {
getLatestWs().emitClose(1008, "unauthorized: device token mismatch");
}).not.toThrow();
await Promise.resolve();
expect(logDebugMock).toHaveBeenCalledWith(
expect.stringContaining("failed clearing stale device pairing"),
);
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
client.stop();
});
it("does not clear auth state for non-mismatch close reasons", () => {
const onClose = vi.fn();
const client = createClientWithIdentity("dev-4", onClose);
const client = createClientWithIdentity("dev-3", onClose);
client.start();
getLatestWs().emitClose(1008, "unauthorized: signature invalid");
expect(clearDeviceAuthTokenMock).not.toHaveBeenCalled();
expect(clearDevicePairingMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: signature invalid");
client.stop();
});
@@ -328,7 +299,7 @@ describe("GatewayClient close handling", () => {
it("does not clear persisted device auth when explicit shared token is provided", () => {
const onClose = vi.fn();
const identity: DeviceIdentity = {
deviceId: "dev-5",
deviceId: "dev-4",
privateKeyPem: "private-key", // pragma: allowlist secret
publicKeyPem: "public-key",
};
@@ -343,7 +314,6 @@ describe("GatewayClient close handling", () => {
getLatestWs().emitClose(1008, "unauthorized: device token mismatch");
expect(clearDeviceAuthTokenMock).not.toHaveBeenCalled();
expect(clearDevicePairingMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
client.stop();
});
@@ -458,4 +428,156 @@ describe("GatewayClient connect auth payload", () => {
});
client.stop();
});
it("retries with stored device token after shared-token mismatch on trusted endpoints", async () => {
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
token: "shared-token",
});
client.start();
const ws1 = getLatestWs();
ws1.emitOpen();
emitConnectChallenge(ws1);
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"'));
expect(firstConnectRaw).toBeTruthy();
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as {
id?: string;
params?: { auth?: { token?: string; deviceToken?: string } };
};
expect(firstConnect.params?.auth?.token).toBe("shared-token");
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
ws1.emitMessage(
JSON.stringify({
type: "res",
id: firstConnect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
},
}),
);
await vi.waitFor(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 });
const ws2 = getLatestWs();
ws2.emitOpen();
emitConnectChallenge(ws2, "nonce-2");
expect(connectFrameFrom(ws2)).toMatchObject({
token: "shared-token",
deviceToken: "stored-device-token",
});
client.stop();
});
it("retries with stored device token when server recommends retry_with_device_token", async () => {
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
token: "shared-token",
});
client.start();
const ws1 = getLatestWs();
ws1.emitOpen();
emitConnectChallenge(ws1);
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"'));
expect(firstConnectRaw).toBeTruthy();
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string };
ws1.emitMessage(
JSON.stringify({
type: "res",
id: firstConnect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_UNAUTHORIZED", recommendedNextStep: "retry_with_device_token" },
},
}),
);
await vi.waitFor(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 });
const ws2 = getLatestWs();
ws2.emitOpen();
emitConnectChallenge(ws2, "nonce-2");
expect(connectFrameFrom(ws2)).toMatchObject({
token: "shared-token",
deviceToken: "stored-device-token",
});
client.stop();
});
it("does not auto-reconnect on AUTH_TOKEN_MISSING connect failures", async () => {
vi.useFakeTimers();
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
token: "shared-token",
});
client.start();
const ws1 = getLatestWs();
ws1.emitOpen();
emitConnectChallenge(ws1);
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"'));
expect(firstConnectRaw).toBeTruthy();
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string };
ws1.emitMessage(
JSON.stringify({
type: "res",
id: firstConnect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_TOKEN_MISSING" },
},
}),
);
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
client.stop();
vi.useRealTimers();
});
it("does not auto-reconnect on token mismatch when retry is not trusted", async () => {
vi.useFakeTimers();
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
const client = new GatewayClient({
url: "wss://gateway.example.com:18789",
token: "shared-token",
});
client.start();
const ws1 = getLatestWs();
ws1.emitOpen();
emitConnectChallenge(ws1);
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"'));
expect(firstConnectRaw).toBeTruthy();
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string };
ws1.emitMessage(
JSON.stringify({
type: "res",
id: firstConnect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
},
}),
);
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
client.stop();
vi.useRealTimers();
});
});

View File

@@ -11,7 +11,6 @@ import {
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { clearDevicePairing } from "../infra/device-pairing.js";
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
import { rawDataToString } from "../infra/ws.js";
import { logDebug, logError } from "../logger.js";
@@ -23,7 +22,13 @@ import {
} from "../utils/message-channel.js";
import { VERSION } from "../version.js";
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { isSecureWebSocketUrl } from "./net.js";
import { isLoopbackHost, isSecureWebSocketUrl } from "./net.js";
import {
ConnectErrorDetailCodes,
readConnectErrorDetailCode,
readConnectErrorRecoveryAdvice,
type ConnectErrorRecoveryAdvice,
} from "./protocol/connect-error-details.js";
import {
type ConnectParams,
type EventFrame,
@@ -41,6 +46,24 @@ type Pending = {
expectFinal: boolean;
};
type GatewayClientErrorShape = {
code?: string;
message?: string;
details?: unknown;
};
class GatewayClientRequestError extends Error {
readonly gatewayCode: string;
readonly details?: unknown;
constructor(error: GatewayClientErrorShape) {
super(error.message ?? "gateway request failed");
this.name = "GatewayClientRequestError";
this.gatewayCode = error.code ?? "UNAVAILABLE";
this.details = error.details;
}
}
export type GatewayClientOptions = {
url?: string; // ws://127.0.0.1:18789
connectDelayMs?: number;
@@ -93,6 +116,9 @@ export class GatewayClient {
private connectNonce: string | null = null;
private connectSent = false;
private connectTimer: NodeJS.Timeout | null = null;
private pendingDeviceTokenRetry = false;
private deviceTokenRetryBudgetUsed = false;
private pendingConnectErrorDetailCode: string | null = null;
// Track last tick to detect silent stalls.
private lastTick: number | null = null;
private tickIntervalMs = 30_000;
@@ -184,6 +210,8 @@ export class GatewayClient {
this.ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
this.ws.on("close", (code, reason) => {
const reasonText = rawDataToString(reason);
const connectErrorDetailCode = this.pendingConnectErrorDetailCode;
this.pendingConnectErrorDetailCode = null;
this.ws = null;
// Clear persisted device auth state only when device-token auth was active.
// Shared token/password failures can return the same close reason but should
@@ -199,9 +227,6 @@ export class GatewayClient {
const role = this.opts.role ?? "operator";
try {
clearDeviceAuthToken({ deviceId, role });
void clearDevicePairing(deviceId).catch((err) => {
logDebug(`failed clearing stale device pairing for device ${deviceId}: ${String(err)}`);
});
logDebug(`cleared stale device-auth token for device ${deviceId}`);
} catch (err) {
logDebug(
@@ -210,6 +235,10 @@ export class GatewayClient {
}
}
this.flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`));
if (this.shouldPauseReconnectAfterAuthFailure(connectErrorDetailCode)) {
this.opts.onClose?.(code, reasonText);
return;
}
this.scheduleReconnect();
this.opts.onClose?.(code, reasonText);
});
@@ -223,6 +252,9 @@ export class GatewayClient {
stop() {
this.closed = true;
this.pendingDeviceTokenRetry = false;
this.deviceTokenRetryBudgetUsed = false;
this.pendingConnectErrorDetailCode = null;
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
@@ -253,11 +285,20 @@ export class GatewayClient {
const storedToken = this.opts.deviceIdentity
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
: null;
const shouldUseDeviceRetryToken =
this.pendingDeviceTokenRetry &&
!explicitDeviceToken &&
Boolean(explicitGatewayToken) &&
Boolean(storedToken) &&
this.isTrustedDeviceRetryEndpoint();
if (shouldUseDeviceRetryToken) {
this.pendingDeviceTokenRetry = false;
}
// Keep shared gateway credentials explicit. Persisted per-device tokens only
// participate when no explicit shared token/password is provided.
const resolvedDeviceToken =
explicitDeviceToken ??
(!(explicitGatewayToken || this.opts.password?.trim())
(shouldUseDeviceRetryToken || !(explicitGatewayToken || this.opts.password?.trim())
? (storedToken ?? undefined)
: undefined);
// Legacy compatibility: keep `auth.token` populated for device-token auth when
@@ -327,6 +368,9 @@ export class GatewayClient {
void this.request<HelloOk>("connect", params)
.then((helloOk) => {
this.pendingDeviceTokenRetry = false;
this.deviceTokenRetryBudgetUsed = false;
this.pendingConnectErrorDetailCode = null;
const authInfo = helloOk?.auth;
if (authInfo?.deviceToken && this.opts.deviceIdentity) {
storeDeviceAuthToken({
@@ -346,6 +390,19 @@ export class GatewayClient {
this.opts.onHelloOk?.(helloOk);
})
.catch((err) => {
this.pendingConnectErrorDetailCode =
err instanceof GatewayClientRequestError ? readConnectErrorDetailCode(err.details) : null;
const shouldRetryWithDeviceToken = this.shouldRetryWithStoredDeviceToken({
error: err,
explicitGatewayToken,
resolvedDeviceToken,
storedToken: storedToken ?? undefined,
});
if (shouldRetryWithDeviceToken) {
this.pendingDeviceTokenRetry = true;
this.deviceTokenRetryBudgetUsed = true;
this.backoffMs = Math.min(this.backoffMs, 250);
}
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
const msg = `gateway connect failed: ${String(err)}`;
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) {
@@ -357,6 +414,86 @@ export class GatewayClient {
});
}
private shouldPauseReconnectAfterAuthFailure(detailCode: string | null): boolean {
if (!detailCode) {
return false;
}
if (
detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
) {
return true;
}
if (detailCode !== ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) {
return false;
}
if (this.pendingDeviceTokenRetry) {
return false;
}
// If the endpoint is not trusted for retry, mismatch is terminal until operator action.
if (!this.isTrustedDeviceRetryEndpoint()) {
return true;
}
// Pause mismatch reconnect loops once the one-shot device-token retry is consumed.
return this.deviceTokenRetryBudgetUsed;
}
private shouldRetryWithStoredDeviceToken(params: {
error: unknown;
explicitGatewayToken?: string;
storedToken?: string;
resolvedDeviceToken?: string;
}): boolean {
if (this.deviceTokenRetryBudgetUsed) {
return false;
}
if (params.resolvedDeviceToken) {
return false;
}
if (!params.explicitGatewayToken || !params.storedToken) {
return false;
}
if (!this.isTrustedDeviceRetryEndpoint()) {
return false;
}
if (!(params.error instanceof GatewayClientRequestError)) {
return false;
}
const detailCode = readConnectErrorDetailCode(params.error.details);
const advice: ConnectErrorRecoveryAdvice = readConnectErrorRecoveryAdvice(params.error.details);
const retryWithDeviceTokenRecommended =
advice.recommendedNextStep === "retry_with_device_token";
return (
advice.canRetryWithDeviceToken === true ||
retryWithDeviceTokenRecommended ||
detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH
);
}
private isTrustedDeviceRetryEndpoint(): boolean {
const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789";
try {
const parsed = new URL(rawUrl);
const protocol =
parsed.protocol === "https:"
? "wss:"
: parsed.protocol === "http:"
? "ws:"
: parsed.protocol;
if (isLoopbackHost(parsed.hostname)) {
return true;
}
return protocol === "wss:" && Boolean(this.opts.tlsFingerprint?.trim());
} catch {
return false;
}
}
private handleMessage(raw: string) {
try {
const parsed = JSON.parse(raw);
@@ -402,7 +539,13 @@ export class GatewayClient {
if (parsed.ok) {
pending.resolve(parsed.payload);
} else {
pending.reject(new Error(parsed.error?.message ?? "unknown error"));
pending.reject(
new GatewayClientRequestError({
code: parsed.error?.code,
message: parsed.error?.message ?? "unknown error",
details: parsed.error?.details,
}),
);
}
}
} catch (err) {

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
readConnectErrorDetailCode,
readConnectErrorRecoveryAdvice,
} from "./connect-error-details.js";
describe("readConnectErrorDetailCode", () => {
it("reads structured detail codes", () => {
expect(readConnectErrorDetailCode({ code: "AUTH_TOKEN_MISMATCH" })).toBe("AUTH_TOKEN_MISMATCH");
});
it("returns null for invalid detail payloads", () => {
expect(readConnectErrorDetailCode(null)).toBeNull();
expect(readConnectErrorDetailCode("AUTH_TOKEN_MISMATCH")).toBeNull();
});
});
describe("readConnectErrorRecoveryAdvice", () => {
it("reads retry advice fields when present", () => {
expect(
readConnectErrorRecoveryAdvice({
canRetryWithDeviceToken: true,
recommendedNextStep: "retry_with_device_token",
}),
).toEqual({
canRetryWithDeviceToken: true,
recommendedNextStep: "retry_with_device_token",
});
});
it("returns empty advice for invalid payloads", () => {
expect(readConnectErrorRecoveryAdvice(null)).toEqual({});
expect(readConnectErrorRecoveryAdvice("x")).toEqual({});
expect(readConnectErrorRecoveryAdvice({ canRetryWithDeviceToken: "yes" })).toEqual({});
expect(
readConnectErrorRecoveryAdvice({
canRetryWithDeviceToken: true,
recommendedNextStep: "retry_with_magic",
}),
).toEqual({ canRetryWithDeviceToken: true, recommendedNextStep: undefined });
});
});

View File

@@ -28,6 +28,26 @@ export const ConnectErrorDetailCodes = {
export type ConnectErrorDetailCode =
(typeof ConnectErrorDetailCodes)[keyof typeof ConnectErrorDetailCodes];
export type ConnectRecoveryNextStep =
| "retry_with_device_token"
| "update_auth_configuration"
| "update_auth_credentials"
| "wait_then_retry"
| "review_auth_configuration";
export type ConnectErrorRecoveryAdvice = {
canRetryWithDeviceToken?: boolean;
recommendedNextStep?: ConnectRecoveryNextStep;
};
const CONNECT_RECOVERY_NEXT_STEP_VALUES: ReadonlySet<ConnectRecoveryNextStep> = new Set([
"retry_with_device_token",
"update_auth_configuration",
"update_auth_credentials",
"wait_then_retry",
"review_auth_configuration",
]);
export function resolveAuthConnectErrorDetailCode(
reason: string | undefined,
): ConnectErrorDetailCode {
@@ -91,3 +111,26 @@ export function readConnectErrorDetailCode(details: unknown): string | null {
const code = (details as { code?: unknown }).code;
return typeof code === "string" && code.trim().length > 0 ? code : null;
}
export function readConnectErrorRecoveryAdvice(details: unknown): ConnectErrorRecoveryAdvice {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return {};
}
const raw = details as {
canRetryWithDeviceToken?: unknown;
recommendedNextStep?: unknown;
};
const canRetryWithDeviceToken =
typeof raw.canRetryWithDeviceToken === "boolean" ? raw.canRetryWithDeviceToken : undefined;
const normalizedNextStep =
typeof raw.recommendedNextStep === "string" ? raw.recommendedNextStep.trim() : "";
const recommendedNextStep = CONNECT_RECOVERY_NEXT_STEP_VALUES.has(
normalizedNextStep as ConnectRecoveryNextStep,
)
? (normalizedNextStep as ConnectRecoveryNextStep)
: undefined;
return {
canRetryWithDeviceToken,
recommendedNextStep,
};
}

View File

@@ -39,9 +39,15 @@ describe("isNonRecoverableAuthError", () => {
);
});
it("blocks reconnect for PAIRING_REQUIRED", () => {
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe(
true,
);
});
it("allows reconnect for AUTH_TOKEN_MISMATCH (device-token fallback flow)", () => {
// Browser client fallback: stale device token mismatch → sendConnect() clears it →
// next reconnect uses opts.token (shared gateway token). Blocking here breaks recovery.
// Browser client can queue a single trusted-device retry after shared token mismatch.
// Blocking reconnect on mismatch here would skip that bounded recovery attempt.
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH))).toBe(
false,
);

View File

@@ -0,0 +1,196 @@
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import {
connectReq,
CONTROL_UI_CLIENT,
ConnectErrorDetailCodes,
getFreePort,
openWs,
originForPort,
restoreGatewayToken,
startGatewayServer,
testState,
} from "./server.auth.shared.js";
function expectAuthErrorDetails(params: {
details: unknown;
expectedCode: string;
canRetryWithDeviceToken?: boolean;
recommendedNextStep?: string;
}) {
const details = params.details as
| {
code?: string;
canRetryWithDeviceToken?: boolean;
recommendedNextStep?: string;
}
| undefined;
expect(details?.code).toBe(params.expectedCode);
if (params.canRetryWithDeviceToken !== undefined) {
expect(details?.canRetryWithDeviceToken).toBe(params.canRetryWithDeviceToken);
}
if (params.recommendedNextStep !== undefined) {
expect(details?.recommendedNextStep).toBe(params.recommendedNextStep);
}
}
describe("gateway auth compatibility baseline", () => {
describe("token mode", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
let prevToken: string | undefined;
beforeAll(async () => {
prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
testState.gatewayAuth = { mode: "token", token: "secret" };
process.env.OPENCLAW_GATEWAY_TOKEN = "secret";
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
restoreGatewayToken(prevToken);
});
test("keeps valid shared-token connect behavior unchanged", async () => {
const ws = await openWs(port);
try {
const res = await connectReq(ws, { token: "secret" });
expect(res.ok).toBe(true);
} finally {
ws.close();
}
});
test("returns stable token-missing details for control ui without token", async () => {
const ws = await openWs(port, { origin: originForPort(port) });
try {
const res = await connectReq(ws, {
skipDefaultAuth: true,
client: { ...CONTROL_UI_CLIENT },
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("Control UI settings");
expectAuthErrorDetails({
details: res.error?.details,
expectedCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
canRetryWithDeviceToken: false,
recommendedNextStep: "update_auth_configuration",
});
} finally {
ws.close();
}
});
test("provides one-time retry hint for shared token mismatches", async () => {
const ws = await openWs(port);
try {
const res = await connectReq(ws, { token: "wrong" });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("gateway token mismatch");
expectAuthErrorDetails({
details: res.error?.details,
expectedCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
canRetryWithDeviceToken: true,
recommendedNextStep: "retry_with_device_token",
});
} finally {
ws.close();
}
});
test("keeps explicit device token mismatch semantics stable", async () => {
const ws = await openWs(port);
try {
const res = await connectReq(ws, {
skipDefaultAuth: true,
deviceToken: "not-a-valid-device-token",
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("device token mismatch");
expectAuthErrorDetails({
details: res.error?.details,
expectedCode: ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
canRetryWithDeviceToken: false,
recommendedNextStep: "update_auth_credentials",
});
} finally {
ws.close();
}
});
});
describe("password mode", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
let prevToken: string | undefined;
beforeAll(async () => {
prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
testState.gatewayAuth = { mode: "password", password: "secret" };
delete process.env.OPENCLAW_GATEWAY_TOKEN;
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
restoreGatewayToken(prevToken);
});
test("keeps valid shared-password connect behavior unchanged", async () => {
const ws = await openWs(port);
try {
const res = await connectReq(ws, { password: "secret" });
expect(res.ok).toBe(true);
} finally {
ws.close();
}
});
test("returns stable password mismatch details", async () => {
const ws = await openWs(port);
try {
const res = await connectReq(ws, { password: "wrong" });
expect(res.ok).toBe(false);
expectAuthErrorDetails({
details: res.error?.details,
expectedCode: ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
canRetryWithDeviceToken: false,
recommendedNextStep: "update_auth_credentials",
});
} finally {
ws.close();
}
});
});
describe("none mode", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
let prevToken: string | undefined;
beforeAll(async () => {
prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
testState.gatewayAuth = { mode: "none" };
delete process.env.OPENCLAW_GATEWAY_TOKEN;
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
restoreGatewayToken(prevToken);
});
test("keeps auth-none loopback behavior unchanged", async () => {
const ws = await openWs(port);
try {
const res = await connectReq(ws, { skipDefaultAuth: true });
expect(res.ok).toBe(true);
} finally {
ws.close();
}
});
});
});

View File

@@ -391,9 +391,16 @@ export function registerControlUiAndPairingSuite(): void {
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("gateway token mismatch");
expect(res.error?.message ?? "").not.toContain("device token mismatch");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
);
const details = res.error?.details as
| {
code?: string;
canRetryWithDeviceToken?: boolean;
recommendedNextStep?: string;
}
| undefined;
expect(details?.code).toBe(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH);
expect(details?.canRetryWithDeviceToken).toBe(true);
expect(details?.recommendedNextStep).toBe("retry_with_device_token");
},
},
{

View File

@@ -562,6 +562,31 @@ 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";
}
})();
markHandshakeFailure("unauthorized", {
authMode: resolvedAuth.mode,
authProvided: connectParams.auth?.password
@@ -594,6 +619,8 @@ export function attachGatewayWsMessageHandler(params: {
details: {
code: resolveAuthConnectErrorDetailCode(failedAuth.reason),
authReason: failedAuth.reason,
canRetryWithDeviceToken,
recommendedNextStep,
},
});
close(1008, truncateCloseReason(authMessage));