forked from baron/baron-sso
966 lines
29 KiB
Go
966 lines
29 KiB
Go
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"`
|
|
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
|
Status string `json:"status"`
|
|
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
|
|
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
|
|
|
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
|
|
}
|
|
|
|
var deletedAt *time.Time
|
|
status := "active"
|
|
if consent.DeletedAt.Valid {
|
|
deletedAt = &consent.DeletedAt.Time
|
|
status = "revoked"
|
|
}
|
|
|
|
// Filter by status if requested
|
|
if statusFilter != "" && statusFilter != "all" {
|
|
if statusFilter == "active" && status != "active" {
|
|
continue
|
|
}
|
|
if statusFilter == "revoked" && status != "revoked" {
|
|
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,
|
|
DeletedAt: deletedAt,
|
|
Status: status,
|
|
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
|
|
}
|