From 017bc5261c9b82f37a5cd31b2e275fdeed320dec Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Wed, 1 Apr 2026 23:20:10 +0300 Subject: [PATCH] fix(gateway): prefer bootstrap auth over tailscale (#59232) * fix(gateway): prefer bootstrap auth over tailscale * fix(gateway): prefer bootstrap auth over tailscale (#59232) (thanks @ngutman) --- CHANGELOG.md | 1 + .../server/ws-connection/auth-context.test.ts | 45 ++++++++++++++++--- .../server/ws-connection/auth-context.ts | 15 +++---- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40df1cbf857..db2de1c3ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Matrix/multi-account: keep room-level `account` scoping, inherited room overrides, and implicit account selection consistent across top-level default auth, named accounts, and cached-credential env setups. (#58449) thanks @Daanvdplas and @gumadeiras. +- Gateway/pairing: prefer explicit QR bootstrap auth over earlier Tailscale auth classification so iOS `/pair qr` silent bootstrap pairing does not fall through to `pairing required`. (#59232) Thanks @ngutman. - Config/Discord: coerce safe integer numeric Discord IDs to strings during config validation, keep unsafe or precision-losing numeric snowflakes rejected, and align `openclaw doctor` repair guidance with the same fail-closed behavior. (#45125) Thanks @moliendocode. - Gateway/sessions: scope bare `sessions.create` aliases like `main` to the requested agent while preserving the canonical `global` and `unknown` sentinel keys. (#58207) thanks @jalehman. - `/context detail` now compares the tracked prompt estimate with cached context usage and surfaces untracked provider/runtime overhead when present. (#28391) thanks @ImLukeF. diff --git a/src/gateway/server/ws-connection/auth-context.test.ts b/src/gateway/server/ws-connection/auth-context.test.ts index 6da629c5d78..7db6c7f489b 100644 --- a/src/gateway/server/ws-connection/auth-context.test.ts +++ b/src/gateway/server/ws-connection/auth-context.test.ts @@ -169,23 +169,58 @@ describe("resolveConnectAuthDecision", () => { expect(verifyDeviceToken).not.toHaveBeenCalled(); }); - it("returns the original decision when device fallback does not apply", async () => { + it("prefers a valid bootstrap token over an already successful shared auth path", async () => { + const verifyBootstrapToken = vi.fn(async () => ({ ok: true })); const verifyDeviceToken = vi.fn(async () => ({ ok: true })); const decision = await resolveConnectAuthDecision({ state: createBaseState({ - authResult: { ok: true, method: "token" }, + authResult: { ok: true, method: "tailscale" }, authOk: true, + authMethod: "tailscale", + bootstrapTokenCandidate: "bootstrap-token", + deviceTokenCandidate: undefined, + deviceTokenCandidateSource: undefined, }), hasDeviceIdentity: true, deviceId: "dev-1", publicKey: "pub-1", - role: "operator", + role: "node", scopes: [], - verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }), + verifyBootstrapToken, verifyDeviceToken, }); expect(decision.authOk).toBe(true); - expect(decision.authMethod).toBe("token"); + expect(decision.authMethod).toBe("bootstrap-token"); + expect(verifyBootstrapToken).toHaveBeenCalledOnce(); + expect(verifyDeviceToken).not.toHaveBeenCalled(); + }); + + it("keeps the original successful auth path when bootstrap validation fails", async () => { + const verifyBootstrapToken = vi.fn(async () => ({ + ok: false, + reason: "bootstrap_token_invalid", + })); + const verifyDeviceToken = vi.fn(async () => ({ ok: true })); + const decision = await resolveConnectAuthDecision({ + state: createBaseState({ + authResult: { ok: true, method: "tailscale" }, + authOk: true, + authMethod: "tailscale", + bootstrapTokenCandidate: "bootstrap-token", + deviceTokenCandidate: undefined, + deviceTokenCandidateSource: undefined, + }), + hasDeviceIdentity: true, + deviceId: "dev-1", + publicKey: "pub-1", + role: "node", + scopes: [], + verifyBootstrapToken, + verifyDeviceToken, + }); + expect(decision.authOk).toBe(true); + expect(decision.authMethod).toBe("tailscale"); + expect(verifyBootstrapToken).toHaveBeenCalledOnce(); expect(verifyDeviceToken).not.toHaveBeenCalled(); }); }); diff --git a/src/gateway/server/ws-connection/auth-context.ts b/src/gateway/server/ws-connection/auth-context.ts index 1ef1cc6d0b4..840a5b27ba2 100644 --- a/src/gateway/server/ws-connection/auth-context.ts +++ b/src/gateway/server/ws-connection/auth-context.ts @@ -170,13 +170,7 @@ export async function resolveConnectAuthDecision(params: { let authMethod = params.state.authMethod; const bootstrapTokenCandidate = params.state.bootstrapTokenCandidate; - if ( - params.hasDeviceIdentity && - params.deviceId && - params.publicKey && - !authOk && - bootstrapTokenCandidate - ) { + if (params.hasDeviceIdentity && params.deviceId && params.publicKey && bootstrapTokenCandidate) { const tokenCheck = await params.verifyBootstrapToken({ deviceId: params.deviceId, publicKey: params.publicKey, @@ -185,9 +179,14 @@ export async function resolveConnectAuthDecision(params: { scopes: params.scopes, }); if (tokenCheck.ok) { + // Prefer an explicit valid bootstrap token even when another auth path + // (for example tailscale serve header auth) already succeeded. QR pairing + // relies on the server classifying the handshake as bootstrap-token so the + // initial node pairing can be silently auto-approved and the bootstrap + // token can be revoked after approval. authOk = true; authMethod = "bootstrap-token"; - } else { + } else if (!authOk) { authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" }; } }