// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package realtime import ( "crypto/sha256" "encoding/hex" "errors" "net/http" "strings" "git.dcntrlized.org/Dcntrlized/Alarm/db" "git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken" "git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr" "git.dcntrlized.org/Dcntrlized/Alarm/internal/deviceid" "git.dcntrlized.org/Dcntrlized/Alarm/logger" "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" "github.com/google/uuid" ) // authenticateWS resolves a GET /ws/alarm upgrade request: // // ?alertId= required // &token= required; the alert's admin token OR its subscribe token // &deviceId= optional; picks which device row is kept alive // &deviceToken= 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 // clients (and debugging tools) that cannot set headers on the upgrade; that is // a convenience, not a second trust path — the token is verified identically. // // A subscribe token authorises only a user who actually has a subscription on // 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")) } if access == "" { access = strings.TrimSpace(r.URL.Query().Get("access_token")) } if access == "" { return nil, &AuthError{ Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "missing access token", } } authReq := r.Clone(r.Context()) authReq.Header.Set("X-Access-Token", access) p, err := authclient.Authorize(authReq) if err != nil { logger.Warn("ws: access token rejected: %v", err) return nil, &AuthError{ Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "invalid access token", } } 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", } } 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", } } // Admin path: the alert's own admin token, presented by the alert's admin. if alarmtoken.EqualHash(alert.AdminTokenHash, token) { if alert.AdminUserID != p.ID { logger.Warn("ws: user=%s presented the admin token of alert=%s without owning it", p.ID, alert.ID) return nil, &AuthError{ Status: http.StatusForbidden, Code: apierr.AlertNotAdmin, Message: "caller is not the alert admin", } } return &Identity{AlertID: alert.ID, UserID: p.ID, IsAdmin: true}, nil } // Subscriber path: the shared subscribe token plus a real subscription. if !alarmtoken.EqualToken(alert.SubscribeToken, token) { logger.Warn("ws: token mismatch alert=%s user=%s", alert.ID, p.ID) return nil, &AuthError{ Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "invalid token", } } sub, err := resolveSubscription(r, alert.ID, p.ID) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, &AuthError{ Status: http.StatusNotFound, Code: apierr.AlertNotSubscribed, Message: "no subscription for this user on the alert", } } logger.Error("ws: subscription lookup failed: %v", err) return nil, &AuthError{ Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error", } } return &Identity{ AlertID: alert.ID, UserID: p.ID, SubscriptionID: &sub.ID, DeviceID: sub.DeviceID, }, nil } // resolveSubscription picks the device row a connection keeps alive: the one // named by ?deviceId= when given, otherwise the user's oldest subscription on // the alert. func resolveSubscription(r *http.Request, alertID, userID uuid.UUID) (*db.Subscription, error) { if deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId")); deviceID != "" { // The client sends the raw value; the row stores its digest. return db.GetSubscriptionByDevice(r.Context(), alertID, userID, deviceid.Hash(deviceID)) } ids, err := db.UserSubscriptionIDs(r.Context(), alertID, userID) if err != nil { return nil, err } if len(ids) == 0 { return nil, db.ErrNotFound } 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[:]) }