[feat](alarm): let devices subscribe without an account, with a token this service mints

Receiving an alarm should not require signing up, and it should not require
pretending to be a user either: an anonymous subscriber is not an account, it
is a subscription. The row is the identity.

joinAlert now takes NO access token. The subscribe token from the QR is the
authorisation, and the service answers with a deviceToken (32 CSPRNG bytes,
base64url, stored only as SHA-256) returned exactly once. Later subscriber
calls send it as X-Device-Token (?deviceToken= on the WebSocket upgrade, which
cannot set headers): getAlert, alarmStatus, ackAlarm, removeSubscription,
qrImage and GET /ws/alarm.

The credential is deliberately narrow. It names one subscription on one alert,
so an ack addresses the ledger row directly and 'am I subscribed' is a field
comparison. createAlert/triggerAlert/disarmAlert/deleteAlert/listSubscriptions
never see it, so they keep answering 401 for a subscriber without any new gate:
owning an alert still needs an account, and the admin token stays the only
secret that can ring or silence one.

Re-joining from the same device rotates the token (one row per alert+device),
so a lost credential is recoverable with the QR and a leaked one dies on the
next join. ALARM_ANONYMOUS=false puts joinAlert back behind login and stores the
subscription against the account, exactly like rev 9.

Migration 010 is additive and idempotent: subscriber_user_id becomes nullable,
device_token_hash gets a unique index, and alarm_events.actor_user_id admits
NULL because an anonymous join logs an actor-less event.

Verified through Maestro against the dev stack (full table in
planning/platform/anonymous-access/plan.md): join with no token -> 200 and the
auth.users count does not move; getAlert/alarmStatus/ackAlarm with the token;
trigger -> 409 with the frame -> ack acked:1 destroyed:true; WS 101 with both
credentials (403 wrong subscribe token, 401 bad device token); old token 401
after a re-join; admin actions 401.
This commit is contained in:
2026-10-19 18:37:17 +01:00
parent 914fd6570d
commit 0e24c4e4f0
15 changed files with 777 additions and 98 deletions
+81
View File
@@ -16,6 +16,8 @@
package realtime
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"strings"
@@ -34,6 +36,8 @@ import (
// ?alertId=<uuid> required
// &token=<secret> required; the alert's admin token OR its subscribe token
// &deviceId=<id> optional; picks which device row is kept alive
// &deviceToken=<t> the subscriber credential (rev 10); replaces the access
// token for a device that has no account
//
// The caller's identity always comes from a verified X-Access-Token header. The
// access token may also be supplied as ?accessToken= / ?access_token= for
@@ -44,6 +48,16 @@ import (
// the alert, so knowing the shared token is not enough on its own. The admin
// token is only honoured for the user the alert was created by.
func authenticateWS(r *http.Request) (*Identity, error) {
// Device path first (rev 10): no access token, no account — the device token
// names a subscription and `token` must still prove the caller was shown the
// QR.
if deviceTok := strings.TrimSpace(r.URL.Query().Get("deviceToken")); deviceTok != "" || strings.TrimSpace(r.Header.Get("X-Device-Token")) != "" {
if deviceTok == "" {
deviceTok = strings.TrimSpace(r.Header.Get("X-Device-Token"))
}
return authenticateWSDevice(r, deviceTok)
}
access := strings.TrimSpace(r.Header.Get("X-Access-Token"))
if access == "" {
access = strings.TrimSpace(r.URL.Query().Get("accessToken"))
@@ -148,3 +162,70 @@ func resolveSubscription(r *http.Request, alertID, userID uuid.UUID) (*db.Subscr
}
return db.GetSubscription(r.Context(), alertID, ids[0])
}
// authenticateWSDevice upgrades a subscriber connection that has no account.
// The device token is looked up by hash; the shared subscribe token still has to
// match, so a leaked device token alone cannot attach to an alert's socket.
func authenticateWSDevice(r *http.Request, deviceToken string) (*Identity, error) {
alertID, err := uuid.Parse(strings.TrimSpace(r.URL.Query().Get("alertId")))
if err != nil {
return nil, &AuthError{
Status: http.StatusBadRequest, Code: apierr.Validation, Message: "alertId is required",
}
}
sub, err := db.GetSubscriptionByToken(r.Context(), deviceTokenHash(deviceToken))
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, &AuthError{
Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "invalid device token",
}
}
logger.Error("ws: device token lookup failed: %v", err)
return nil, &AuthError{
Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error",
}
}
if sub.AlertID != alertID {
return nil, &AuthError{
Status: http.StatusNotFound, Code: apierr.AlertNotSubscribed, Message: "device is not subscribed to this alert",
}
}
token := strings.TrimSpace(r.URL.Query().Get("token"))
if token == "" {
return nil, &AuthError{
Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "token is required",
}
}
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, &AuthError{
Status: http.StatusNotFound, Code: apierr.AlertNotFound, Message: "alert not found",
}
}
logger.Error("ws: alert lookup failed: %v", err)
return nil, &AuthError{
Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error",
}
}
if !alarmtoken.EqualToken(alert.SubscribeToken, token) {
logger.Warn("ws: subscribe token mismatch alert=%s device-sub=%s", alert.ID, sub.ID)
return nil, &AuthError{
Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "invalid token",
}
}
return &Identity{
AlertID: alert.ID,
SubscriptionID: &sub.ID,
DeviceID: sub.DeviceID,
IsDevice: true,
}, nil
}
// deviceTokenHash mirrors the service's device-token hash (plain SHA-256 hex):
// the WebSocket package verifies the same credential the actions accept, so the
// transform must be identical.
func deviceTokenHash(tok string) string {
sum := sha256.Sum256([]byte(tok))
return hex.EncodeToString(sum[:])
}