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
This commit is contained in:
+26
-1
@@ -22,13 +22,16 @@ Environment only; the server refuses to start when a required value is missing.
|
||||
| `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` |
|
||||
| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward |
|
||||
| `ABRP_MIN_INTERVAL_SECONDS` | default 5 |
|
||||
| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) |
|
||||
| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty |
|
||||
| `LISTEN_ADDR` | default `:8080` |
|
||||
|
||||
The ABRP credentials never reach the car.
|
||||
|
||||
## Endpoints
|
||||
|
||||
All but `/v1/health` need `Authorization: Bearer <INGEST_TOKEN>`.
|
||||
The ingest endpoints accept `Authorization: Bearer <token>` with either the shared `INGEST_TOKEN`
|
||||
or a paired car's own token.
|
||||
|
||||
- `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm`
|
||||
field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Unknown fields are
|
||||
@@ -41,6 +44,27 @@ All but `/v1/health` need `Authorization: Bearer <INGEST_TOKEN>`.
|
||||
|
||||
A bad request is a 400 naming the field; a database failure is a 500 without detail.
|
||||
|
||||
## Pairing a car by QR code
|
||||
|
||||
So nobody types a token in the car. Modelled on the OAuth device authorization grant (RFC 8628);
|
||||
on only when `APPROVAL_PASSWORD` is set.
|
||||
|
||||
1. The car calls `POST /v1/pair/start` `{installationId}` and shows the returned
|
||||
`verificationUriComplete` as a QR code, plus the short `userCode` (e.g. `K7F-3QX`).
|
||||
2. The phone opens `/pair?code=…`, the owner checks the code and approves with the approval
|
||||
password. Five wrong passwords lock approvals for a minute.
|
||||
3. The car polls `POST /v1/pair/poll` `{deviceCode}`: 202 pending, 200 with its own token
|
||||
(handed out once), 403 denied, 410 expired (after 10 minutes) or unknown.
|
||||
|
||||
Each car gets its own 43-character token; only its SHA-256 is stored (measurement `device` in
|
||||
the same bucket), and the device is persisted before the car can collect the token, so a
|
||||
restart never loses a paired car. Manage cars with the approval password:
|
||||
|
||||
```sh
|
||||
curl -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.stinnesbeck.com/v1/devices
|
||||
curl -X POST -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.stinnesbeck.com/v1/devices/<id>/revoke
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
One bucket, `installationId` tag on every point:
|
||||
@@ -51,6 +75,7 @@ One bucket, `installationId` tag on every point:
|
||||
| `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent |
|
||||
| `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC |
|
||||
| `charging_curve` | point time | `sessionId` | powerKw, socPercent |
|
||||
| `device` | paired at | `deviceId` | tokenHash, installationId, revoked |
|
||||
|
||||
A point with the same measurement, tags and timestamp replaces the earlier one, so the car's
|
||||
retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the
|
||||
|
||||
Reference in New Issue
Block a user