From 2ce267400c3b1c6b1c7ff4ddc8d8454feda2dc1a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 02:44:28 +0000 Subject: [PATCH] fix: allow token auth without device identity (#1314) (thanks @dbhurley) --- CHANGELOG.md | 1 + src/gateway/server.auth.test.ts | 27 +++++++++++++++++++ .../server/ws-connection/message-handler.ts | 20 +++++++------- src/gateway/test-helpers.server.ts | 3 ++- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e5f147e..fceba11aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.clawd.bot - Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk. - Doctor: clarify plugin auto-enable hint text in the startup banner. - Gateway: clarify unauthorized handshake responses with token/password mismatch guidance. +- Gateway: allow token-auth Control UI connections without device identity. (#1314) — thanks @dbhurley. - Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood. - Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner. - Config: log invalid config issues once per run and keep invalid-config errors stackless. diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index d36c6c825..1de8f03f5 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -102,6 +102,33 @@ describe("gateway server auth/connect", () => { } }); + test("accepts token auth without device identity", async () => { + const { server, ws, prevToken } = await startServerWithClient("secret"); + const res = await connectReq(ws, { token: "secret", device: null }); + expect(res.ok).toBe(true); + ws.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + + test("requires device identity when auth mode is none", async () => { + const { server, ws, prevToken } = await startServerWithClient(); + const res = await connectReq(ws, { device: null }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("device identity required"); + ws.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + test("accepts password auth when configured", async () => { testState.gatewayAuth = { mode: "password", password: "secret" }; const port = await getFreePort(); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 8ed224eeb..5e799cbb0 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -290,11 +290,18 @@ export function attachGatewayWsMessageHandler(params: { connectParams.role = role; connectParams.scopes = scopes; + const authResult = await authorizeGatewayConnect({ + auth: resolvedAuth, + connectAuth: connectParams.auth, + req: upgradeReq, + }); + let authOk = authResult.ok; + let authMethod = authResult.method ?? "none"; + const allowsDeviceOptional = authOk && authMethod === "token"; + const device = connectParams.device; let devicePublicKey: string | null = null; - // Allow token-authenticated connections (e.g., control-ui) to skip device identity - const hasTokenAuth = !!connectParams.auth?.token; - if (!device && !hasTokenAuth) { + if (!device && !allowsDeviceOptional) { setHandshakeState("failed"); setCloseCause("device-required", { client: connectParams.client.id, @@ -460,13 +467,6 @@ export function attachGatewayWsMessageHandler(params: { } } - const authResult = await authorizeGatewayConnect({ - auth: resolvedAuth, - connectAuth: connectParams.auth, - req: upgradeReq, - }); - let authOk = authResult.ok; - let authMethod = authResult.method ?? "none"; if (!authOk && connectParams.auth?.token && device) { const tokenCheck = await verifyDeviceToken({ deviceId: device.id, diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index e3668815f..cfbabd253 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -280,7 +280,7 @@ export async function connectReq( signature: string; signedAt: number; nonce?: string; - }; + } | null; }, ): Promise { const { randomUUID } = await import("node:crypto"); @@ -294,6 +294,7 @@ export async function connectReq( const role = opts?.role ?? "operator"; const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : []; const device = (() => { + if (opts?.device === null) return undefined; if (opts?.device) return opts.device; const identity = loadOrCreateDeviceIdentity(); const signedAtMs = Date.now();