mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 06:14:23 +00:00
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:
@@ -215,6 +215,28 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
|
||||
is enabled for break-glass use.
|
||||
- All connections must sign the server-provided `connect.challenge` nonce.
|
||||
|
||||
### Device auth migration diagnostics
|
||||
|
||||
For legacy clients that still use pre-challenge signing behavior, `connect` now returns
|
||||
`DEVICE_AUTH_*` detail codes under `error.details.code` with a stable `error.details.reason`.
|
||||
|
||||
Common migration failures:
|
||||
|
||||
| Message | details.code | details.reason | Meaning |
|
||||
| --------------------------- | -------------------------------- | ------------------------ | -------------------------------------------------- |
|
||||
| `device nonce required` | `DEVICE_AUTH_NONCE_REQUIRED` | `device-nonce-missing` | Client omitted `device.nonce` (or sent blank). |
|
||||
| `device nonce mismatch` | `DEVICE_AUTH_NONCE_MISMATCH` | `device-nonce-mismatch` | Client signed with a stale/wrong nonce. |
|
||||
| `device signature invalid` | `DEVICE_AUTH_SIGNATURE_INVALID` | `device-signature` | Signature payload does not match v2 payload. |
|
||||
| `device signature expired` | `DEVICE_AUTH_SIGNATURE_EXPIRED` | `device-signature-stale` | Signed timestamp is outside allowed skew. |
|
||||
| `device identity mismatch` | `DEVICE_AUTH_DEVICE_ID_MISMATCH` | `device-id-mismatch` | `device.id` does not match public key fingerprint. |
|
||||
| `device public key invalid` | `DEVICE_AUTH_PUBLIC_KEY_INVALID` | `device-public-key` | Public key format/canonicalization failed. |
|
||||
|
||||
Migration target:
|
||||
|
||||
- Always wait for `connect.challenge`.
|
||||
- Sign the v2 payload that includes the server nonce.
|
||||
- Send the same nonce in `connect.params.device.nonce`.
|
||||
- Preferred signature payload is `v3`, which binds `platform` and `deviceFamily`
|
||||
in addition to device/client/role/scopes/token/nonce fields.
|
||||
- Legacy `v2` signatures remain accepted for compatibility, but paired-device
|
||||
|
||||
@@ -80,9 +80,27 @@ Look for:
|
||||
Common signatures:
|
||||
|
||||
- `device identity required` → non-secure context or missing device auth.
|
||||
- `device nonce required` / `device nonce mismatch` → client is not completing the
|
||||
challenge-based device auth flow (`connect.challenge` + `device.nonce`).
|
||||
- `device signature invalid` / `device signature expired` → client signed the wrong
|
||||
payload (or stale timestamp) for the current handshake.
|
||||
- `unauthorized` / reconnect loop → token/password mismatch.
|
||||
- `gateway connect failed:` → wrong host/port/url target.
|
||||
|
||||
Device auth v2 migration check:
|
||||
|
||||
```bash
|
||||
openclaw --version
|
||||
openclaw doctor
|
||||
openclaw gateway status
|
||||
```
|
||||
|
||||
If logs show nonce/signature errors, update the connecting client and verify it:
|
||||
|
||||
1. waits for `connect.challenge`
|
||||
2. signs the challenge-bound payload
|
||||
3. sends `connect.params.device.nonce` with the same challenge nonce
|
||||
|
||||
Related:
|
||||
|
||||
- [/web/control-ui](/web/control-ui)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user