From 0c31e9b88d29f0f93f25a825519ab4eb4823e468 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 2 Nov 2026 17:12:15 -0500 Subject: [PATCH] fix(devices): pairing claim accepts urlencoded htmx form posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser's Pair device button submits an application/x-www-form- urlencoded
(htmx), but PairingCodeRequest/CancelRegistration- Request carried only json: tags. Echo v5 routes form posts to the 'form:' binder, which maps by the form: struct tag exclusively — the code arrived empty, normalized to "", and every browser claim failed with the generic 'invalid pairing code' 400 even though the separate lookup step (which reads c.FormValue directly, no struct) had just resolved the same code. The API clients (plugin, Android, tests) post JSON and were never affected. Both request structs now carry dual form:/json: tags, matching the auth.go convention. Adds TestPairingClaimAcceptsBrowserFormEncoding to pin the form path: form-encoded lookup resolves, form-encoded claim binds, device poll delivers credentials. Found by the user clicking through the real Add Device modal; the integration suite missed it because every prior claim test posted application/json. --- cmd/server/tests/device_pairing_test.go | 50 +++++++++++++++++++++++++ internal/handlers/devices.go | 6 ++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/cmd/server/tests/device_pairing_test.go b/cmd/server/tests/device_pairing_test.go index bfa1afb3..384af391 100644 --- a/cmd/server/tests/device_pairing_test.go +++ b/cmd/server/tests/device_pairing_test.go @@ -19,6 +19,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/google/uuid" @@ -91,6 +92,55 @@ func wrongPairingCode(code string) string { return string(rotated) } +// pairingFormRequest POSTs urlencoded form data — the Content-Type the +// browser's htmx forms actually send — with a Bearer token. +func pairingFormRequest(t *testing.T, setup *TestServerSetup, path, token, body string) (int, map[string]interface{}) { + t.Helper() + req := httptest.NewRequest("POST", path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + var resp map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &resp) + return rec.Code, resp +} + +// TestPairingClaimAcceptsBrowserFormEncoding pins the htmx path: the +// confirm card claims via a urlencoded , so echo v5 routes the +// Content-Type to the `form:` binder, which maps by `form:` struct tag +// only. (Regression: PairingCodeRequest shipped with a json-only tag, +// leaving the code empty and every browser claim failing with 400 — +// "how did lookup find the device if the code is wrong?") The JSON path +// stays pinned by the tests above; this covers the form twin. +func TestPairingClaimAcceptsBrowserFormEncoding(t *testing.T) { + setup := setupTestServer(t) + identifier := fmt.Sprintf("pairing-form-%s", uuid.New().String()) + + reg := registerPairingDevice(t, setup, identifier) + code := reg["pairing_code"].(string) + + // Form-encoded lookup (the same call the API-side confirm card makes). + status, resp := pairingFormRequest(t, setup, "/api/devices/claim/lookup", + setup.RegularToken, "pairing_code="+code) + require.Equal(t, http.StatusOK, status, "form-encoded lookup must resolve the live code") + assert.Equal(t, "Test Pairing Device", resp["device_name"]) + + // Form-encoded claim — exactly what the browser's Pair device + // button sends (urlencoded hidden input, not JSON). + status, resp = pairingFormRequest(t, setup, "/api/devices/claim", + setup.RegularToken, "pairing_code="+code) + require.Equal(t, http.StatusOK, status, "form-encoded claim must bind the device") + assert.Equal(t, true, resp["approved"]) + + // The device's own poll delivers credentials — binding really happened. + pollStatus, pollResp := pollRegistrationStatus(t, setup, reg["registration_id"].(string)) + require.Equal(t, http.StatusOK, pollStatus) + assert.Equal(t, "approved", pollResp["status"]) +} + func TestPairingClaimHappyPath(t *testing.T) { setup := setupTestServer(t) identifier := fmt.Sprintf("pairing-happy-%s", uuid.New().String()) diff --git a/internal/handlers/devices.go b/internal/handlers/devices.go index ffe2d77f..472ed558 100644 --- a/internal/handlers/devices.go +++ b/internal/handlers/devices.go @@ -104,7 +104,9 @@ type DeviceApprovalRequest struct { // PairingCodeRequest carries the 6-digit code shown on a registering // device's screen — the claim credential for the pairing flow. type PairingCodeRequest struct { - PairingCode string `json:"pairing_code"` + // form tag mirrors json: htmx posts urlencoded, the API posts JSON — + // echo v5's form binder maps by `form:` tag only (auth.go convention). + PairingCode string `form:"pairing_code" json:"pairing_code"` } // PairingLookupResponse describes a live registration without binding it — @@ -117,7 +119,7 @@ type PairingLookupResponse struct { } type CancelRegistrationRequest struct { - RegistrationID string `json:"registration_id" validate:"required"` + RegistrationID string `form:"registration_id" json:"registration_id" validate:"required"` } type PendingRegistration struct {