From a443ab3e72ef963cd83f737f6586e89a1769f52c Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 6 Feb 2026 16:15:51 +0900 Subject: [PATCH] =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8?= =?UTF-8?q?=20=EB=B9=84=EB=B0=80=ED=82=A4=20=EC=9E=AC=EB=B0=9C=EA=B8=89(Ro?= =?UTF-8?q?tate)=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 1 + backend/internal/handler/dev_handler.go | 68 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 11818e2c..855f0545 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -613,6 +613,7 @@ func main() { dev.Post("/clients", devHandler.CreateClient) dev.Get("/clients/:id", devHandler.GetClient) dev.Put("/clients/:id", devHandler.UpdateClient) + dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret) dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus) dev.Delete("/clients/:id", devHandler.DeleteClient) dev.Get("/consents", devHandler.ListConsents) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 18d8347d..0d05d87d 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -5,7 +5,10 @@ import ( "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "context" + "crypto/rand" + "encoding/base64" "errors" + "fmt" "strings" "time" @@ -508,6 +511,71 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } +func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { + clientID := strings.TrimSpace(c.Params("id")) + if clientID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) + } + + // 1. Generate new secret + newSecret, err := generateRandomSecret(20) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"}) + } + + // 2. Get current client to preserve other fields + current, err := h.Hydra.GetClient(c.Context(), clientID) + if err != nil { + if errors.Is(err, service.ErrHydraNotFound) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + // 3. Update Hydra + current.ClientSecret = newSecret + updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + // 4. Update Persistence (DB & Redis) + if h.SecretRepo != nil { + if err := h.SecretRepo.Upsert(c.Context(), clientID, newSecret); err != nil { + // Log error but don't fail the request as Hydra is already updated + fmt.Printf("failed to update secret in repo: %v\n", err) + } + } + + if h.Redis != nil { + _ = h.Redis.Set("client_secret:"+clientID, newSecret, 0) + } + + // Return the new secret + summary := h.mapClientSummary(*updated) + summary.ClientSecret = newSecret + + return c.JSON(clientDetailResponse{ + Client: summary, + Endpoints: clientEndpoints{ + Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration", + Issuer: h.Hydra.PublicURL, + Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth", + Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token", + UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo", + }, + }) +} + +func generateRandomSecret(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + // Use Base64 URL encoding (no padding) to look like Hydra's native secrets + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { status := "active" if client.Metadata != nil {