package auth import ( "encoding/json" "errors" "net/http" ) // Handlers exposes HTTP handlers for the magic-link login flow. type Handlers struct{ Service *Service } // LoginRequest is the JSON body for POST /auth/login. type LoginRequest struct { Email string `json:"email"` } // HandleLogin accepts an email and triggers a magic-link send. It // always returns 202 even when the email is unknown or rate-limited // for clients we want to expose; we return distinguishable errors only // for malformed input. This avoids account-enumeration leaks. func (h Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req LoginRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&req); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } err := h.Service.RequestLogin(r.Context(), req.Email) switch { case err == nil: case errors.Is(err, ErrRateLimited): // Surface rate limiting as 429 — the email is known to the // client (they sent it) so we don't leak account existence. http.Error(w, "rate limited", http.StatusTooManyRequests) return default: // Any other failure is internal; mask details. // In particular, validation failures look the same as success // to the client; we still log the actual error server-side. w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte(`{"status":"sent"}`)) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte(`{"status":"sent"}`)) } // VerifyResponse is the JSON body returned by GET /auth/verify on success. type VerifyResponse struct { JWT string `json:"jwt"` UserID string `json:"user_id"` Email string `json:"email"` Expires int64 `json:"expires_at"` } // HandleVerify validates a magic-link token and issues a session JWT. func (h Handlers) HandleVerify(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } token := r.URL.Query().Get("token") if token == "" { http.Error(w, "missing token", http.StatusBadRequest) return } res, err := h.Service.Verify(r.Context(), token) if err != nil { http.Error(w, "invalid or expired token", http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(VerifyResponse{ JWT: res.JWT, UserID: res.User.ID, Email: res.User.Email, Expires: nowFn().Add(SessionLifetime).Unix(), }) }