package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "log/slog" "os" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/google/uuid" ) type DevHandler struct { Hydra *service.HydraAdminService Redis domain.RedisRepository SecretRepo domain.ClientSecretRepository KratosAdmin *service.KratosAdminService ConsentRepo repository.ClientConsentRepository Keto service.KetoService RPSvc service.RelyingPartyService Auth interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) } } func NewDevHandler( redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService, keto service.KetoService, auth ...interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) }, ) *DevHandler { var authProvider interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) } if len(auth) > 0 { authProvider = auth[0] } return &DevHandler{ Hydra: service.NewHydraAdminService(), Redis: redis, SecretRepo: secretRepo, KratosAdmin: service.NewKratosAdminService(), ConsentRepo: consentRepo, Keto: keto, RPSvc: rpSvc, Auth: authProvider, } } type clientSummary struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Status string `json:"status"` CreatedAt *time.Time `json:"createdAt,omitempty"` RedirectURIs []string `json:"redirectUris"` Scopes []string `json:"scopes"` ClientSecret string `json:"clientSecret,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } type clientListResponse struct { Items []clientSummary `json:"items"` Limit int `json:"limit"` Offset int `json:"offset"` } type clientDetailResponse struct { Client clientSummary `json:"client"` Endpoints clientEndpoints `json:"endpoints"` } type clientEndpoints struct { Discovery string `json:"discovery"` Issuer string `json:"issuer"` Authorization string `json:"authorization"` Token string `json:"token"` UserInfo string `json:"userinfo"` } type consentSummary struct { Subject string `json:"subject"` UserName string `json:"userName,omitempty"` ClientID string `json:"clientId"` ClientName string `json:"clientName,omitempty"` GrantedScopes []string `json:"grantedScopes"` AuthenticatedAt string `json:"authenticatedAt,omitempty"` CreatedAt time.Time `json:"createdAt"` TenantID string `json:"tenantId,omitempty"` TenantName string `json:"tenantName,omitempty"` } type consentListResponse struct { Items []consentSummary `json:"items"` } type clientUpsertRequest struct { ID *string `json:"id"` Name *string `json:"name"` Type *string `json:"type"` Status *string `json:"status"` RedirectURIs *[]string `json:"redirectUris"` Scopes *[]string `json:"scopes"` GrantTypes *[]string `json:"grantTypes"` ResponseTypes *[]string `json:"responseTypes"` TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"` Metadata *map[string]interface{} `json:"metadata"` } func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) if (!ok || profile == nil) && h.Auth != nil { enriched, err := h.Auth.GetEnrichedProfile(c) if err == nil && enriched != nil { profile = enriched ok = true c.Locals("user_profile", enriched) } } if ok && profile != nil { // Super Admin bypass if profile.Role == domain.RoleSuperAdmin { slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID) return true, nil } if isAdminEmail(profile.Email) { slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email) return true, nil } // Check with Keto: System:AppManager#member allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member") if err != nil { return false, err } slog.Info("Dev private permission evaluated by Keto", "user_id", profile.ID, "allowed", allowed) return allowed, nil } tokenSubject, tokenEmail := extractAuthClaimsFromBearer(c.Get("Authorization")) if isAdminEmail(tokenEmail) { slog.Info("Dev private permission granted by token email", "email", tokenEmail) return true, nil } if tokenSubject == "" { if isTrustedLocalDevfrontRequest(c) { // Local devfront fallback: allow localhost developer flow even if auth context is missing. slog.Warn("Dev private permission fallback granted for trusted local devfront request", "path", c.Path(), "origin", c.Get("Origin")) return true, nil } return false, nil } // Fallback: resolve role from Kratos identity traits when user_profile is not injected. if h.KratosAdmin != nil { identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject) if err == nil && identity != nil { if rawRole, ok := identity.Traits["role"].(string); ok && rawRole == domain.RoleSuperAdmin { slog.Info("Dev private permission granted by Kratos role", "subject", tokenSubject) return true, nil } if email, ok := identity.Traits["email"].(string); ok && isAdminEmail(email) { slog.Info("Dev private permission granted by Kratos email", "subject", tokenSubject, "email", email) return true, nil } } } // Check with Keto: System:AppManager#member allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member") if err != nil { return false, err } slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed) return allowed, nil } func extractAuthClaimsFromBearer(authHeader string) (string, string) { authHeader = strings.TrimSpace(authHeader) if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { return "", "" } token := strings.TrimSpace(authHeader[len("Bearer "):]) if token == "" || strings.Count(token, ".") != 2 { return "", "" } parts := strings.Split(token, ".") if len(parts) != 3 { return "", "" } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { payload, err = base64.URLEncoding.DecodeString(parts[1]) if err != nil { return "", "" } } var claims map[string]interface{} if err := json.Unmarshal(payload, &claims); err != nil { return "", "" } sub := "" if sub, ok := claims["sub"].(string); ok { sub = strings.TrimSpace(sub) } email := "" if claimEmail, ok := claims["email"].(string); ok { email = strings.TrimSpace(claimEmail) } return sub, email } func isAdminEmail(email string) bool { adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")) return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail) } func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool { if c == nil { return false } origin := strings.ToLower(strings.TrimSpace(c.Get("Origin"))) referer := strings.ToLower(strings.TrimSpace(c.Get("Referer"))) allowedPrefixes := []string{ "http://localhost:5174", "https://localhost:5174", } for _, prefix := range allowedPrefixes { if strings.HasPrefix(origin, prefix) || strings.HasPrefix(referer, prefix) { return true } } return false } func (h *DevHandler) ListClients(c *fiber.Ctx) error { limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) if limit <= 0 { limit = 50 } if offset < 0 { offset = 0 } isAppManager, err := h.checkAppManagerPermission(c) if err != nil { slog.Error("Failed to check app manager permission", "error", err) } clients, err := h.Hydra.ListClients(c.Context(), limit, offset) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "clients not found") } errMsg := err.Error() if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") { return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.") } return errorJSON(c, fiber.StatusInternalServerError, errMsg) } items := make([]clientSummary, 0, len(clients)) for _, client := range clients { summary := h.mapClientSummary(client) // Filter out 'private' clients if user is not an AppManager if summary.Type == "private" && !isAppManager { continue } items = append(items, summary) } return c.JSON(clientListResponse{ Items: items, Limit: limit, Offset: offset, }) } func (h *DevHandler) GetClient(c *fiber.Ctx) error { clientID := c.Params("id") if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } client, err := h.Hydra.GetClient(c.Context(), clientID) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "client not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } summary := h.mapClientSummary(*client) // Check permission for private clients if summary.Type == "private" { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !isAppManager { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } 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 (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { clientID := c.Params("id") if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } var req struct { Status string `json:"status"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } status := strings.ToLower(strings.TrimSpace(req.Status)) if status != "active" && status != "inactive" { return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } // [Security] Check permission before patching current, err := h.Hydra.GetClient(c.Context(), clientID) if err == nil { summary := h.mapClientSummary(*current) if summary.Type == "private" { isAppManager, _ := h.checkAppManagerPermission(c) if !isAppManager { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } } updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "client not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } summary := h.mapClientSummary(*updated) 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 (h *DevHandler) CreateClient(c *fiber.Ctx) error { var req clientUpsertRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } clientID := strings.TrimSpace(valueOr(req.ID, "")) if clientID == "" { clientID = uuid.NewString() } name := strings.TrimSpace(valueOr(req.Name, "")) if name == "" { name = clientID } redirectURIs := derefSlice(req.RedirectURIs, nil) if len(redirectURIs) == 0 { return errorJSON(c, fiber.StatusBadRequest, "redirectUris is required") } scopes := derefSlice(req.Scopes, defaultClientScopes()) grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes()) responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes()) clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private"))) if clientType != "pkce" && clientType != "private" { return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private") } // [Security] Check permission for private clients if clientType == "private" { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !isAppManager { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client") } } status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active"))) if status != "active" && status != "inactive" { return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } metadata := mergeMetadata(nil, req.Metadata) if metadata == nil { metadata = map[string]interface{}{} } metadata["status"] = status metadata["created_at"] = time.Now().Format(time.RFC3339) tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) if tokenAuthMethod == "" { if clientType == "pkce" { tokenAuthMethod = "none" } else { tokenAuthMethod = "client_secret_basic" } } clientReq := domain.HydraClient{ ClientID: clientID, ClientName: name, RedirectURIs: redirectURIs, GrantTypes: grantTypes, ResponseTypes: responseTypes, Scope: strings.Join(scopes, " "), TokenEndpointAuthMethod: tokenAuthMethod, Metadata: metadata, } created, err := h.Hydra.CreateClient(c.Context(), clientReq) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Store secret in metadata for later retrieval if created.ClientSecret != "" { // 1. Store in PostgreSQL (Source of Truth) if h.SecretRepo != nil { _ = h.SecretRepo.Upsert(c.Context(), created.ClientID, created.ClientSecret) } // 2. Also store in Redis (Cache) if h.Redis != nil { _ = h.Redis.Set("client_secret:"+created.ClientID, created.ClientSecret, 0) } } summary := h.mapClientSummary(*created) return c.Status(fiber.StatusCreated).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 (h *DevHandler) UpdateClient(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } var req clientUpsertRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } current, err := h.Hydra.GetClient(c.Context(), clientID) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "client not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } clientType := "" if req.Type != nil { clientType = strings.ToLower(strings.TrimSpace(*req.Type)) if clientType != "pkce" && clientType != "private" { return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private") } } // [Security] Check permission for private clients (both current and new type) currentSummary := h.mapClientSummary(*current) if currentSummary.Type == "private" || clientType == "private" { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !isAppManager { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } status := "" if req.Status != nil { status = strings.ToLower(strings.TrimSpace(*req.Status)) if status != "active" && status != "inactive" { return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } } tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) if tokenAuthMethod == "" && clientType != "" { if clientType == "pkce" { tokenAuthMethod = "none" } else { tokenAuthMethod = "client_secret_basic" } } if req.RedirectURIs != nil && len(*req.RedirectURIs) == 0 { return errorJSON(c, fiber.StatusBadRequest, "redirectUris cannot be empty") } metadata := mergeMetadata(current.Metadata, req.Metadata) if status != "" { if metadata == nil { metadata = map[string]interface{}{} } metadata["status"] = status } updated := domain.HydraClient{ ClientID: current.ClientID, ClientName: valueOr(req.Name, current.ClientName), RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs), GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes), ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes), Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))), TokenEndpointAuthMethod: resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod), Metadata: metadata, } updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "client not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } summary := h.mapClientSummary(*updatedClient) 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 (h *DevHandler) DeleteClient(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } // [Security] Check permission for private clients current, err := h.Hydra.GetClient(c.Context(), clientID) if err == nil { summary := h.mapClientSummary(*current) if summary.Type == "private" { isAppManager, _ := h.checkAppManagerPermission(c) if !isAppManager { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } } if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "client not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // 1. Clean up PostgreSQL if h.SecretRepo != nil { _ = h.SecretRepo.Delete(c.Context(), clientID) } // 2. Clean up Redis if h.Redis != nil { _ = h.Redis.Delete("client_secret:" + clientID) } return c.SendStatus(fiber.StatusNoContent) } func (h *DevHandler) ListConsents(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Query("client_id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client_id is required") } subject := strings.TrimSpace(c.Query("subject")) limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) if limit <= 0 { limit = 50 } // [Isolation] Get admin tenant ID from header or locals adminTenantID := c.Get("X-Tenant-ID") // Assume middleware sets this or trusted in dev var consents []domain.ClientConsentWithTenantInfo var total int64 var err error if subject != "" { // Resolve subject if it's email/name (Legacy support) if _, err := uuid.Parse(subject); err != nil { resolved, _ := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject) if resolved != "" { subject = resolved } } // Single user fetch from Hydra (to get latest status) or Local DB // Issue says: "List All", so we prefer Local DB for consistency in listing // But for a single user, we could still use Hydra. // Let's use Local DB to support tenant filtering even for search. // For simplicity, we just filter the list later if search is used. } if adminTenantID != "" { consents, total, err = h.ConsentRepo.ListByTenant(c.Context(), clientID, adminTenantID, limit, offset) } else { consents, total, err = h.ConsentRepo.List(c.Context(), clientID, limit, offset) } if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } items := make([]consentSummary, 0, len(consents)) for _, consent := range consents { // Filter by subject if search is active if subject != "" && consent.Subject != subject { continue } userName := "" identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject) if err == nil && identity != nil { if name, ok := identity.Traits["name"].(string); ok { userName = name } else if email, ok := identity.Traits["email"].(string); ok { userName = email } } items = append(items, consentSummary{ Subject: consent.Subject, UserName: userName, ClientID: consent.ClientID, GrantedScopes: consent.GrantedScopes, AuthenticatedAt: consent.UpdatedAt.Format(time.RFC3339), CreatedAt: consent.CreatedAt, TenantID: consent.TenantID, TenantName: consent.TenantName, }) } return c.JSON(fiber.Map{ "items": items, "total": total, }) } func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { subject := strings.TrimSpace(c.Query("subject")) if subject == "" { return errorJSON(c, fiber.StatusBadRequest, "subject is required") } clientID := strings.TrimSpace(c.Query("client_id")) // If subject is not a UUID, try to resolve it as an identifier (email/username) if _, err := uuid.Parse(subject); err != nil { resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject) if err == nil && resolved != "" { subject = resolved } } // 1. Revoke in Hydra if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // 2. Sync to Local DB (Delete) if h.ConsentRepo != nil { _ = h.ConsentRepo.Delete(c.Context(), subject, clientID) } return c.SendStatus(fiber.StatusNoContent) } func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } // [Security] Check permission for private clients current, err := h.Hydra.GetClient(c.Context(), clientID) if err == nil { summary := h.mapClientSummary(*current) if summary.Type == "private" { isAppManager, _ := h.checkAppManagerPermission(c) if !isAppManager { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } } // 1. Generate new secret newSecret, err := generateRandomSecret(20) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to generate secret") } // 2. Get current client to preserve other fields (already fetched above) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "client not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // 3. Update Hydra current.ClientSecret = newSecret updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, 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" var createdAt *time.Time if client.Metadata != nil { if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" { status = "inactive" } if value, ok := client.Metadata["created_at"].(string); ok { if t, err := time.Parse(time.RFC3339, value); err == nil { createdAt = &t } } } clientType := "private" if strings.EqualFold(client.TokenEndpointAuthMethod, "none") { clientType = "pkce" } name := strings.TrimSpace(client.ClientName) if name == "" { name = client.ClientID } scopes := strings.Fields(client.Scope) clientSecret := client.ClientSecret // 1. Check Metadata (Legacy/Fallback) if clientSecret == "" && client.Metadata != nil { if val, ok := client.Metadata["client_secret"].(string); ok { clientSecret = val } } // 2. Check Redis (Cache) if clientSecret == "" && h.Redis != nil { if val, err := h.Redis.Get("client_secret:" + client.ClientID); err == nil && val != "" { clientSecret = val } } // 3. Check PostgreSQL (Source of Truth) & Cache Warming if clientSecret == "" && h.SecretRepo != nil { if val, err := h.SecretRepo.GetByID(context.Background(), client.ClientID); err == nil && val != "" { clientSecret = val // Warm up cache if h.Redis != nil { _ = h.Redis.Set("client_secret:"+client.ClientID, clientSecret, 0) } } } return clientSummary{ ID: client.ClientID, Name: name, Type: clientType, Status: status, CreatedAt: createdAt, RedirectURIs: client.RedirectURIs, Scopes: scopes, ClientSecret: clientSecret, Metadata: client.Metadata, } } func defaultClientScopes() []string { return []string{"openid", "profile", "email"} } func defaultGrantTypes() []string { return []string{"authorization_code", "refresh_token"} } func defaultResponseTypes() []string { return []string{"code"} } func buildScope(scopes []string) string { return strings.Join(scopes, " ") } func valueOr(ptr *string, fallback string) string { if ptr == nil { return fallback } return *ptr } func valueOrSlice(ptr *[]string, fallback []string) []string { if ptr == nil { return fallback } return *ptr } func derefSlice(ptr *[]string, fallback []string) []string { if ptr == nil { return fallback } return *ptr } func mergeMetadata(current map[string]interface{}, incoming *map[string]interface{}) map[string]interface{} { if incoming == nil { return current } merged := map[string]interface{}{} for k, v := range current { merged[k] = v } for k, v := range *incoming { merged[k] = v } return merged } func resolveTokenAuthMethod(requested, fallback string) string { if strings.TrimSpace(requested) == "" { return fallback } return requested }