Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
2ce267400c fix: allow token auth without device identity (#1314) (thanks @dbhurley) 2026-01-21 02:44:28 +00:00
David Hurley
d353db42ba fix: allow token auth to bypass device identity requirement
The device identity check was rejecting connections before token
authentication could be attempted. This broke the control-ui (web UI)
which uses token-based authentication via URL parameter.

Changes:
- Skip device identity requirement when a token is provided
- Guard device token verification to only run when device is present

Fixes control-ui showing "device identity required" error when
connecting with a valid token.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 02:20:56 +00:00
4 changed files with 41 additions and 10 deletions

View File

@ -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. - 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. - Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance. - 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: 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. - 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. - Config: log invalid config issues once per run and keep invalid-config errors stackless.

View File

@ -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 () => { test("accepts password auth when configured", async () => {
testState.gatewayAuth = { mode: "password", password: "secret" }; testState.gatewayAuth = { mode: "password", password: "secret" };
const port = await getFreePort(); const port = await getFreePort();

View File

@ -290,9 +290,18 @@ export function attachGatewayWsMessageHandler(params: {
connectParams.role = role; connectParams.role = role;
connectParams.scopes = scopes; 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; const device = connectParams.device;
let devicePublicKey: string | null = null; let devicePublicKey: string | null = null;
if (!device) { if (!device && !allowsDeviceOptional) {
setHandshakeState("failed"); setHandshakeState("failed");
setCloseCause("device-required", { setCloseCause("device-required", {
client: connectParams.client.id, client: connectParams.client.id,
@ -458,14 +467,7 @@ export function attachGatewayWsMessageHandler(params: {
} }
} }
const authResult = await authorizeGatewayConnect({ if (!authOk && connectParams.auth?.token && device) {
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
});
let authOk = authResult.ok;
let authMethod = authResult.method ?? "none";
if (!authOk && connectParams.auth?.token) {
const tokenCheck = await verifyDeviceToken({ const tokenCheck = await verifyDeviceToken({
deviceId: device.id, deviceId: device.id,
token: connectParams.auth.token, token: connectParams.auth.token,

View File

@ -280,7 +280,7 @@ export async function connectReq(
signature: string; signature: string;
signedAt: number; signedAt: number;
nonce?: string; nonce?: string;
}; } | null;
}, },
): Promise<ConnectResponse> { ): Promise<ConnectResponse> {
const { randomUUID } = await import("node:crypto"); const { randomUUID } = await import("node:crypto");
@ -294,6 +294,7 @@ export async function connectReq(
const role = opts?.role ?? "operator"; const role = opts?.role ?? "operator";
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : []; const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
const device = (() => { const device = (() => {
if (opts?.device === null) return undefined;
if (opts?.device) return opts.device; if (opts?.device) return opts.device;
const identity = loadOrCreateDeviceIdentity(); const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now(); const signedAtMs = Date.now();