Files
mg4/server/pairing_test.go
T
nilsandClaude Opus 5.5 d5f68e1bd9 Server 2.1.0: pair cars by QR code instead of typing the token
Modelled on the OAuth device authorization grant (RFC 8628) and on
only when APPROVAL_PASSWORD is set (at least 12 characters):

- POST /v1/pair/start opens a request for an installation and returns
  a short user code and the /pair URL for the car's QR code;
  POST /v1/pair/poll returns pending, the car's own token (once),
  denied or expired. Requests live 10 minutes, in memory.
- GET/POST /pair is a script-free approval page for the phone
  (strict CSP, no framing). Five wrong passwords lock approvals for a
  minute.
- Each car gets its own 43-character token; only its SHA-256 is stored
  (measurement "device"), and the device is persisted before the car
  can collect the token. Tokens are loaded at startup, so a restart
  keeps paired cars working; the server refuses to start if they cannot
  be loaded.
- GET /v1/devices and POST /v1/devices/{id}/revoke, authenticated with
  the approval password, list and revoke cars.
- The shared INGEST_TOKEN keeps working.

31 tests (race detector clean), govulncheck clean. Tested end to end
against InfluxDB 2.7.12: pairing, wrong password, single collection,
persistence across restarts, revocation (also across a restart), and
that InfluxDB holds only the hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 16:16:19 +02:00

235 lines
8.8 KiB
Go

package main
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
)
func startPairing(t *testing.T, h http.Handler) (deviceCode, userCode, uriComplete string) {
t.Helper()
rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`)
if rec.Code != 201 {
t.Fatalf("start: %d %s", rec.Code, rec.Body)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
return got["deviceCode"].(string), got["userCode"].(string), got["verificationUriComplete"].(string)
}
func poll(t *testing.T, h http.Handler, deviceCode string) (int, map[string]string) {
t.Helper()
rec := do(t, h, "POST", "/v1/pair/poll", "", `{"deviceCode":"`+deviceCode+`"}`)
var got map[string]string
_ = json.Unmarshal(rec.Body.Bytes(), &got)
return rec.Code, got
}
func submit(t *testing.T, h http.Handler, code, password, action string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"code": {code}, "password": {password}, "action": {action}}
req := httptest.NewRequest("POST", "/pair", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestPairingHappyPath(t *testing.T) {
api, store := newTestAPI()
h := api.Routes()
deviceCode, userCode, uri := startPairing(t, h)
if !regexpUserCode.MatchString(userCode) {
t.Fatalf("user code %q", userCode)
}
if uri != "https://car.example.com/pair?code="+userCode {
t.Fatalf("verification URI %q", uri)
}
if status, _ := poll(t, h, deviceCode); status != 202 {
t.Fatalf("before approval: want 202, got %d", status)
}
// The phone opens the link: the page shows the code and asks for the password.
page := do(t, h, "GET", "/pair?code="+strings.ToLower(strings.ReplaceAll(userCode, "-", "")), "", "")
if page.Code != 200 || !strings.Contains(page.Body.String(), userCode) {
t.Fatalf("page: %d", page.Code)
}
if csp := page.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") {
t.Fatalf("CSP %q", csp)
}
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Approved") {
t.Fatalf("approve: %d %s", rec.Code, rec.Body)
}
if len(store.devices) != 1 || store.devices[0].InstallationID != testInstallation {
t.Fatalf("device not persisted: %+v", store.devices)
}
status, got := poll(t, h, deviceCode)
if status != 200 || len(got["token"]) < minIngestTokenLength || got["deviceId"] == "" {
t.Fatalf("after approval: %d %v", status, got)
}
// Only the hash is stored, never the token.
if store.devices[0].TokenHash == got["token"] || store.devices[0].TokenHash != hashToken(got["token"]) {
t.Fatal("stored token hash is wrong")
}
// The token is handed out once.
if status, _ := poll(t, h, deviceCode); status != 410 {
t.Fatalf("second collection: want 410, got %d", status)
}
// And it works on the ingest endpoints.
rec := do(t, h, "POST", "/v1/telemetry", got["token"], string(telemetryBody(`{"utc":1790000000,"soc":80}`)))
if rec.Code != 204 {
t.Fatalf("paired token on telemetry: %d %s", rec.Code, rec.Body)
}
}
func TestPairingWrongPasswordAndLockout(t *testing.T) {
api, store := newTestAPI()
h := api.Routes()
deviceCode, userCode, _ := startPairing(t, h)
for i := 0; i < maxFailedLogins; i++ {
if rec := submit(t, h, userCode, "wrong", "approve"); rec.Code != 401 {
t.Fatalf("attempt %d: want 401, got %d", i, rec.Code)
}
}
// Locked now — even the right password waits.
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 429 {
t.Fatalf("locked: want 429, got %d", rec.Code)
}
if status, _ := poll(t, h, deviceCode); status != 202 || len(store.devices) != 0 {
t.Fatal("a locked-out attempt changed the request")
}
// After the window the right password works again.
api.Pairing.Now = func() time.Time { return time.Now().Add(failedLoginsWindow + time.Second) }
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 {
t.Fatalf("after the window: want 200, got %d %s", rec.Code, rec.Body)
}
}
func TestPairingDenyAndExpiry(t *testing.T) {
api, _ := newTestAPI()
h := api.Routes()
deviceCode, userCode, _ := startPairing(t, h)
if rec := submit(t, h, userCode, testApprovalPassword, "deny"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Denied") {
t.Fatalf("deny: %d", rec.Code)
}
if status, _ := poll(t, h, deviceCode); status != 403 {
t.Fatalf("denied: want 403, got %d", status)
}
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 {
t.Fatalf("a decided code was approvable: %d", rec.Code)
}
deviceCode, userCode, _ = startPairing(t, h)
api.Pairing.Now = func() time.Time { return time.Now().Add(pairingTTL + time.Second) }
if status, _ := poll(t, h, deviceCode); status != 410 {
t.Fatalf("expired: want 410, got %d", status)
}
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 {
t.Fatalf("an expired code was approvable: %d", rec.Code)
}
}
func TestPairingNotPersistedMeansNoToken(t *testing.T) {
api, store := newTestAPI()
h := api.Routes()
deviceCode, userCode, _ := startPairing(t, h)
store.fail = errors.New("influx down")
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 500 {
t.Fatalf("want 500, got %d", rec.Code)
}
if status, _ := poll(t, h, deviceCode); status != 202 {
t.Fatalf("a token was handed out that was never saved: %d", status)
}
store.fail = nil
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 {
t.Fatalf("retry: %d", rec.Code)
}
if status, _ := poll(t, h, deviceCode); status != 200 {
t.Fatalf("after retry: %d", status)
}
}
func TestRevokedTokenIsRejected(t *testing.T) {
api, store := newTestAPI()
h := api.Routes()
deviceCode, userCode, _ := startPairing(t, h)
submit(t, h, userCode, testApprovalPassword, "approve")
_, got := poll(t, h, deviceCode)
token := got["token"]
if rec := do(t, h, "GET", "/v1/devices", "wrong-password", ""); rec.Code != 401 {
t.Fatalf("devices without the password: %d", rec.Code)
}
if rec := do(t, h, "GET", "/v1/devices", token, ""); rec.Code != 401 {
t.Fatal("a car token opened the device list")
}
rec := do(t, h, "GET", "/v1/devices", testApprovalPassword, "")
if rec.Code != 200 || !strings.Contains(rec.Body.String(), got["deviceId"]) || strings.Contains(rec.Body.String(), store.devices[0].TokenHash) {
t.Fatalf("list: %d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "POST", "/v1/devices/"+got["deviceId"]+"/revoke", testApprovalPassword, ""); rec.Code != 204 {
t.Fatalf("revoke: %d", rec.Code)
}
if last := store.devices[len(store.devices)-1]; !last.Revoked {
t.Fatal("revocation not persisted")
}
if rec := do(t, h, "POST", "/v1/telemetry", token, string(telemetryBody(`{"utc":1790000000}`))); rec.Code != 401 {
t.Fatalf("revoked token accepted: %d", rec.Code)
}
// The shared ingest token is unaffected.
if rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":1}`))); rec.Code != 204 {
t.Fatalf("shared token: %d", rec.Code)
}
}
func TestPairedDevicesSurviveARestart(t *testing.T) {
devices := []Device{{ID: "d1", TokenHash: hashToken("restored-token-0123456789abcdef"), CreatedAt: time.Now()},
{ID: "d2", TokenHash: hashToken("revoked-token-0123456789abcdef"), CreatedAt: time.Now(), Revoked: true}}
p := NewPairing(testApprovalPassword, devices)
if !p.Authorized("restored-token-0123456789abcdef") || p.Authorized("revoked-token-0123456789abcdef") {
t.Fatal("restored devices not honoured")
}
}
func TestPairingOffWithoutAPassword(t *testing.T) {
api, _ := newTestAPI()
api.Pairing = NewPairing("", nil)
h := api.Routes()
if rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`); rec.Code != 404 {
t.Fatalf("start: %d", rec.Code)
}
if rec := do(t, h, "GET", "/pair", "", ""); rec.Code != 404 {
t.Fatalf("page: %d", rec.Code)
}
if rec := do(t, h, "GET", "/v1/devices", "", ""); rec.Code != 404 {
t.Fatalf("devices: %d", rec.Code)
}
}
func TestUserCodesNormalise(t *testing.T) {
for in, want := range map[string]string{"k7f-3qx": "K7F-3QX", " K7F3QX ": "K7F-3QX", "k7f 3qx": "K7F-3QX", "K7F-3Q": "", "": ""} {
if got := normalizeUserCode(in); got != want {
t.Errorf("%q: got %q, want %q", in, got, want)
}
}
}
func TestApprovalPasswordMustBeLongEnough(t *testing.T) {
env := map[string]string{"INFLUX_URL": "u", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o",
"INGEST_TOKEN": "0123456789abcdef01234567", "APPROVAL_PASSWORD": "short"}
if _, err := loadConfig(func(k string) string { return env[k] }); err == nil {
t.Fatal("short approval password accepted")
}
}
var regexpUserCode = regexp.MustCompile(`^[` + userCodeAlphabet + `]{3}-[` + userCodeAlphabet + `]{3}$`)