package httpapi import ( "context" "crypto/sha256" "encoding/hex" "log/slog" "net/http" "net/url" "time" "commilitia.net/cdrop/internal/db" ) // Shared HTTP helpers for the auth surface. After the Auth Broker migration (path A) // cdrop no longer keeps server-side sessions, cookies, or refresh tokens — identity // and session lifecycle live in the broker. What remains here is the CSRF origin // check, the scan-login poll-secret hash, and the login-request reaper. // deriveSiteOrigin extracts scheme://host from the configured public URL to give the // CSRF Origin check a fixed expected value (and the scan-login QR its link origin). // Empty / unparseable (dev) disables the origin check. func deriveSiteOrigin(publicURL string) string { if publicURL == "" { return "" } u, err := url.Parse(publicURL) if err != nil || u.Scheme == "" || u.Host == "" { return "" } return u.Scheme + "://" + u.Host } // sessionID hashes an opaque token to its storage form (hex SHA-256). The scan-login // poll_secret is stored as this hash — the new device holds the plaintext, so a leaked // DB never yields a usable secret. func sessionID(raw string) string { sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:]) } // sameOrigin is belt-and-suspenders CSRF defence: when the browser sends an Origin // header (always, on fetch POST) it must match the deployment's own origin. Absent // Origin (non-browser clients) is allowed, as is an unconfigured site origin (dev). func (s *Server) sameOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" || s.siteOrigin == "" { return true } return origin == s.siteOrigin } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" } // RunLoginRequestReaper periodically reclaims expired scan-login request rows. They // are short-lived (default 120s) and already rejected at use time; this sweeps the // stragglers that are opened and never collected. func RunLoginRequestReaper(ctx context.Context, q *db.Queries) { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if n, err := q.DeleteExpiredLoginRequests(ctx, time.Now().Unix()); err != nil { slog.Warn("login request reaper failed", "err", err) } else if n > 0 { slog.Info("login requests reaped", "count", n) } } } }