Files
dance-lessons-coach/pkg/user/api/admin_handler.go
Gabriel Radureau f71495b6fc
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 57s
CI/CD Pipeline / Trigger Docker Push (push) Has been cancelled
CI/CD Pipeline / CI Pipeline (push) Has been cancelled
feat(admin): GET /api/v1/admin/jwt/secrets — metadata-only introspection (#51)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 09:51:54 +02:00

171 lines
5.7 KiB
Go

package api
import (
"encoding/json"
"net/http"
"time"
"dance-lessons-coach/pkg/user"
"github.com/go-chi/chi/v5"
)
// AdminHandler handles admin-related HTTP requests
type AdminHandler struct {
authService user.AuthService
}
// NewAdminHandler creates a new admin handler
func NewAdminHandler(authService user.AuthService) *AdminHandler {
return &AdminHandler{
authService: authService,
}
}
// RegisterRoutes registers admin routes
func (h *AdminHandler) RegisterRoutes(router chi.Router) {
router.Route("/jwt", func(r chi.Router) {
r.Get("/secrets", h.handleListJWTSecrets)
r.Post("/secrets", h.handleAddJWTSecret)
r.Post("/secrets/rotate", h.handleRotateJWTSecret)
})
}
// handleListJWTSecrets godoc
//
// @Summary List JWT secrets metadata
// @Description Returns metadata for every tracked JWT secret. The actual secret values are NOT included — exposing them via an admin endpoint would defeat the retention/rotation infrastructure. Each entry has is_primary, created_at_unix, expires_at_unix (optional), age_seconds, is_expired, and a SHA-256 fingerprint (first 16 hex chars) for ops correlation.
// @Tags API/v1/Admin
// @Produce json
// @Success 200 {object} map[string]interface{} "List of secret metadata"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Router /v1/admin/jwt/secrets [get]
func (h *AdminHandler) handleListJWTSecrets(w http.ResponseWriter, r *http.Request) {
infos := h.authService.ListJWTSecretsInfo()
resp := map[string]interface{}{
"count": len(infos),
"secrets": infos,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
// AddJWTSecretRequest represents a request to add a new JWT secret
type AddJWTSecretRequest struct {
Secret string `json:"secret" validate:"required,min=16"`
IsPrimary bool `json:"is_primary"`
ExpiresIn int64 `json:"expires_in"` // Expiration time in hours
}
// handleAddJWTSecret godoc
//
// @Summary Add JWT secret
// @Description Add a new JWT secret for rotation purposes
// @Tags API/v1/Admin
// @Accept json
// @Produce json
// @Param request body AddJWTSecretRequest true "JWT secret details"
// @Success 200 {object} map[string]string "Secret added successfully"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Failure 500 {object} map[string]string "Server error"
// @Router /v1/admin/jwt/secrets [post]
func (h *AdminHandler) handleAddJWTSecret(w http.ResponseWriter, r *http.Request) {
// Decode request body into a map to handle flexible boolean parsing
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
return
}
// Extract and validate fields
secret, ok := body["secret"].(string)
if !ok || secret == "" {
http.Error(w, `{"error":"invalid_request","message":"secret is required and must be a string"}`, http.StatusBadRequest)
return
}
// Handle is_primary as either bool or string
isPrimary := false // default
if val, exists := body["is_primary"]; exists {
switch v := val.(type) {
case bool:
isPrimary = v
case string:
isPrimary = v == "true"
default:
http.Error(w, `{"error":"invalid_request","message":"is_primary must be a boolean or string"}`, http.StatusBadRequest)
return
}
}
// Handle expires_in as either int64 or float64 (JSON numbers)
expiresInHours := int64(0)
if val, exists := body["expires_in"]; exists {
switch v := val.(type) {
case int64:
expiresInHours = v
case float64:
expiresInHours = int64(v)
default:
http.Error(w, `{"error":"invalid_request","message":"expires_in must be a number"}`, http.StatusBadRequest)
return
}
}
// Convert expires_in from hours to time.Duration
expiresIn := time.Duration(expiresInHours) * time.Hour
if expiresIn <= 0 {
// If expires_in is 0 or not provided, set to no expiration for secondary secrets
// For primary secrets, use a reasonable default
if isPrimary {
expiresIn = 24 * 365 * time.Hour // 1 year for primary secrets
} else {
expiresIn = 0 // No expiration for secondary secrets
}
}
// Add the secret to the manager
h.authService.AddJWTSecret(secret, isPrimary, expiresIn)
// Return success
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "JWT secret added successfully"})
}
// RotateJWTSecretRequest represents a request to rotate JWT secrets
type RotateJWTSecretRequest struct {
NewSecret string `json:"new_secret" validate:"required,min=16"`
}
// handleRotateJWTSecret godoc
//
// @Summary Rotate JWT secret
// @Description Rotate to a new primary JWT secret
// @Tags API/v1/Admin
// @Accept json
// @Produce json
// @Param request body RotateJWTSecretRequest true "New JWT secret"
// @Success 200 {object} map[string]string "Secret rotated successfully"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Failure 500 {object} map[string]string "Server error"
// @Router /v1/admin/jwt/secrets/rotate [post]
func (h *AdminHandler) handleRotateJWTSecret(w http.ResponseWriter, r *http.Request) {
var req RotateJWTSecretRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
return
}
// Rotate to the new secret
h.authService.RotateJWTSecret(req.NewSecret)
// Return success
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "JWT secret rotated successfully"})
}