Gateway: improve device-auth v2 migration diagnostics (#28305)

* Gateway: add device-auth detail code resolver

* Gateway: emit specific device-auth detail codes

* Gateway tests: cover nonce and signature detail codes

* Docs: add gateway device-auth migration diagnostics

* Docs: add device-auth v2 troubleshooting signatures
This commit is contained in:
Vincent Koc
2026-02-27 00:05:43 -05:00
committed by GitHub
parent 22ad7523f1
commit cb9374a2a1
5 changed files with 132 additions and 2 deletions

View File

@@ -16,6 +16,12 @@ export const ConnectErrorDetailCodes = {
CONTROL_UI_DEVICE_IDENTITY_REQUIRED: "CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
DEVICE_IDENTITY_REQUIRED: "DEVICE_IDENTITY_REQUIRED",
DEVICE_AUTH_INVALID: "DEVICE_AUTH_INVALID",
DEVICE_AUTH_DEVICE_ID_MISMATCH: "DEVICE_AUTH_DEVICE_ID_MISMATCH",
DEVICE_AUTH_SIGNATURE_EXPIRED: "DEVICE_AUTH_SIGNATURE_EXPIRED",
DEVICE_AUTH_NONCE_REQUIRED: "DEVICE_AUTH_NONCE_REQUIRED",
DEVICE_AUTH_NONCE_MISMATCH: "DEVICE_AUTH_NONCE_MISMATCH",
DEVICE_AUTH_SIGNATURE_INVALID: "DEVICE_AUTH_SIGNATURE_INVALID",
DEVICE_AUTH_PUBLIC_KEY_INVALID: "DEVICE_AUTH_PUBLIC_KEY_INVALID",
PAIRING_REQUIRED: "PAIRING_REQUIRED",
} as const;
@@ -57,6 +63,27 @@ export function resolveAuthConnectErrorDetailCode(
}
}
export function resolveDeviceAuthConnectErrorDetailCode(
reason: string | undefined,
): ConnectErrorDetailCode {
switch (reason) {
case "device-id-mismatch":
return ConnectErrorDetailCodes.DEVICE_AUTH_DEVICE_ID_MISMATCH;
case "device-signature-stale":
return ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_EXPIRED;
case "device-nonce-missing":
return ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED;
case "device-nonce-mismatch":
return ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH;
case "device-signature":
return ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_INVALID;
case "device-public-key":
return ConnectErrorDetailCodes.DEVICE_AUTH_PUBLIC_KEY_INVALID;
default:
return ConnectErrorDetailCodes.DEVICE_AUTH_INVALID;
}
}
export function readConnectErrorDetailCode(details: unknown): string | null {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return null;

View File

@@ -253,7 +253,13 @@ async function sendRawConnectReq(
id?: string;
ok?: boolean;
payload?: Record<string, unknown> | null;
error?: { message?: string };
error?: {
message?: string;
details?: {
code?: string;
reason?: string;
};
};
}>(ws, isConnectResMessage(params.id));
}
@@ -548,6 +554,10 @@ describe("gateway server auth/connect", () => {
});
expect(connectRes.ok).toBe(false);
expect(connectRes.error?.message ?? "").toContain("device signature invalid");
expect(connectRes.error?.details?.code).toBe(
ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_INVALID,
);
expect(connectRes.error?.details?.reason).toBe("device-signature");
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
@@ -613,6 +623,58 @@ describe("gateway server auth/connect", () => {
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
test("returns nonce-required detail code when nonce is blank", async () => {
const ws = await openWs(port);
const token = resolveGatewayTokenOrEnv();
const nonce = await readConnectChallengeNonce(ws);
const { device } = await createSignedDevice({
token,
scopes: ["operator.admin"],
clientId: TEST_OPERATOR_CLIENT.id,
clientMode: TEST_OPERATOR_CLIENT.mode,
nonce,
});
const connectRes = await sendRawConnectReq(ws, {
id: "c-blank-nonce",
token,
device: { ...device, nonce: " " },
});
expect(connectRes.ok).toBe(false);
expect(connectRes.error?.message ?? "").toContain("device nonce required");
expect(connectRes.error?.details?.code).toBe(
ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED,
);
expect(connectRes.error?.details?.reason).toBe("device-nonce-missing");
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
test("returns nonce-mismatch detail code when nonce does not match challenge", async () => {
const ws = await openWs(port);
const token = resolveGatewayTokenOrEnv();
const nonce = await readConnectChallengeNonce(ws);
const { device } = await createSignedDevice({
token,
scopes: ["operator.admin"],
clientId: TEST_OPERATOR_CLIENT.id,
clientMode: TEST_OPERATOR_CLIENT.mode,
nonce,
});
const connectRes = await sendRawConnectReq(ws, {
id: "c-wrong-nonce",
token,
device: { ...device, nonce: `${nonce}-stale` },
});
expect(connectRes.ok).toBe(false);
expect(connectRes.error?.message ?? "").toContain("device nonce mismatch");
expect(connectRes.error?.details?.code).toBe(
ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH,
);
expect(connectRes.error?.details?.reason).toBe("device-nonce-mismatch");
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
test("invalid connect params surface in response and close reason", async () => {
const ws = await openWs(port);
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {

View File

@@ -48,6 +48,7 @@ import { checkBrowserOrigin } from "../../origin-check.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
import {
ConnectErrorDetailCodes,
resolveDeviceAuthConnectErrorDetailCode,
resolveAuthConnectErrorDetailCode,
} from "../../protocol/connect-error-details.js";
import {
@@ -630,7 +631,7 @@ export function attachGatewayWsMessageHandler(params: {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, message, {
details: {
code: ConnectErrorDetailCodes.DEVICE_AUTH_INVALID,
code: resolveDeviceAuthConnectErrorDetailCode(reason),
reason,
},
}),