package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/pagination" "errors" "strings" "time" "github.com/gofiber/fiber/v2" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) type ApiKeyHandler struct { DB *gorm.DB } func NewApiKeyHandler(db *gorm.DB) *ApiKeyHandler { return &ApiKeyHandler{DB: db} } type apiKeySummary struct { ID string `json:"id"` Name string `json:"name"` ClientID string `json:"client_id"` Scopes []string `json:"scopes"` Status string `json:"status"` LastUsedAt *string `json:"lastUsedAt"` CreatedAt time.Time `json:"createdAt"` } type apiKeyListResponse struct { Items []apiKeySummary `json:"items"` Total int64 `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` Cursor string `json:"cursor,omitempty"` NextCursor string `json:"nextCursor,omitempty"` } func apiKeyToSummary(k domain.ApiKey) apiKeySummary { lastUsed := "" if k.LastUsedAt != nil { lastUsed = k.LastUsedAt.Format(time.RFC3339) } return apiKeySummary{ ID: k.ID, Name: k.Name, ClientID: k.ClientID, Scopes: strings.Fields(strings.ReplaceAll(k.Scopes, ",", " ")), Status: k.Status, LastUsedAt: &lastUsed, CreatedAt: k.CreatedAt, } } func apiKeyWithUpdatedScopes(k domain.ApiKey, scopes []string) domain.ApiKey { k.Scopes = strings.Join(normalizeApiKeyScopes(scopes), " ") return k } func apiKeyWithRotatedSecretHash(k domain.ApiKey, hashedSecret string) domain.ApiKey { k.ClientSecretHash = hashedSecret return k } func normalizeApiKeyScopes(scopes []string) []string { seen := make(map[string]struct{}, len(scopes)) normalized := make([]string, 0, len(scopes)) for _, scope := range scopes { scope = strings.TrimSpace(scope) if scope == "" { continue } if _, exists := seen[scope]; exists { continue } seen[scope] = struct{}{} normalized = append(normalized, scope) } return normalized } func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) cursorRaw := strings.TrimSpace(c.Query("cursor")) if limit <= 0 { limit = 50 } if offset < 0 { offset = 0 } var total int64 if err := h.DB.Model(&domain.ApiKey{}).Count(&total).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } var keys []domain.ApiKey query := h.DB.Order("created_at desc, id desc").Limit(limit + 1) if cursorRaw != "" { cursor, err := pagination.Decode(cursorRaw) if err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid cursor") } query = pagination.ApplyCreatedAtIDCursor(query, cursor, "created_at", "id") offset = 0 } else { query = query.Offset(offset) } if err := query.Find(&keys).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } nextCursor := "" hasMore := len(keys) > limit if len(keys) > limit { keys = keys[:limit] } if cursorRaw == "" && total > int64(offset+len(keys)) { hasMore = true } if hasMore && len(keys) > 0 { last := keys[len(keys)-1] nextCursor = pagination.Encode(last.CreatedAt, last.ID) } items := make([]apiKeySummary, 0, len(keys)) for _, k := range keys { items = append(items, apiKeyToSummary(k)) } return c.JSON(apiKeyListResponse{ Items: items, Total: total, Limit: limit, Offset: offset, Cursor: cursorRaw, NextCursor: nextCursor, }) } func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } var req struct { Name string `json:"name"` Scopes []string `json:"scopes"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if strings.TrimSpace(req.Name) == "" { return errorJSON(c, fiber.StatusBadRequest, "name is required") } req.Scopes = normalizeApiKeyScopes(req.Scopes) if len(req.Scopes) == 0 { return errorJSON(c, fiber.StatusBadRequest, "at least one scope is required") } // Generate Client ID (16 chars hex) clientID := GenerateSecureToken(8) // Generate plain secret (16 chars hex) plainSecret := GenerateSecureToken(8) hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to hash secret") } apiKey := domain.ApiKey{ Name: req.Name, ClientID: clientID, ClientSecretHash: string(hashedSecret), Scopes: strings.Join(req.Scopes, " "), Status: "active", } if err := h.DB.Create(&apiKey).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Return summary + PLAIN SECRET (only this time) return c.Status(fiber.StatusCreated).JSON(fiber.Map{ "apiKey": apiKeyToSummary(apiKey), "clientSecret": plainSecret, // VERY IMPORTANT: user must save this now }) } func (h *ApiKeyHandler) UpdateApiKey(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } id := c.Params("id") if id == "" { return errorJSON(c, fiber.StatusBadRequest, "id is required") } var req struct { Scopes []string `json:"scopes"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } req.Scopes = normalizeApiKeyScopes(req.Scopes) if len(req.Scopes) == 0 { return errorJSON(c, fiber.StatusBadRequest, "at least one scope is required") } var apiKey domain.ApiKey if err := h.DB.First(&apiKey, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorJSON(c, fiber.StatusNotFound, "api key not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } apiKey = apiKeyWithUpdatedScopes(apiKey, req.Scopes) if err := h.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("scopes", apiKey.Scopes).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(apiKeyToSummary(apiKey)) } func (h *ApiKeyHandler) RotateApiKeySecret(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } id := c.Params("id") if id == "" { return errorJSON(c, fiber.StatusBadRequest, "id is required") } var apiKey domain.ApiKey if err := h.DB.First(&apiKey, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorJSON(c, fiber.StatusNotFound, "api key not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } plainSecret := GenerateSecureToken(8) hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to hash secret") } apiKey = apiKeyWithRotatedSecretHash(apiKey, string(hashedSecret)) if err := h.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("client_secret_hash", apiKey.ClientSecretHash).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(fiber.Map{ "apiKey": apiKeyToSummary(apiKey), "clientSecret": plainSecret, }) } func (h *ApiKeyHandler) DeleteApiKey(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } id := c.Params("id") if id == "" { return errorJSON(c, fiber.StatusBadRequest, "id is required") } if err := h.DB.Delete(&domain.ApiKey{}, "id = ?", id).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusNoContent) }