1
0
forked from baron/baron-sso

Merge pull request 'dev/ory-hydra2' (#218) from dev/ory-hydra2 into main

Reviewed-on: ai-team/baron-sso#218
This commit is contained in:
2026-02-06 16:30:12 +09:00
39 changed files with 250 additions and 139 deletions

2
.gitignore vendored
View File

@@ -9,6 +9,7 @@
*.swp *.swp
*.log *.log
*.out *.out
*.exe
# Docker Services Data (Volumes) # Docker Services Data (Volumes)
postgres_data/ postgres_data/
@@ -20,6 +21,7 @@ backend/bin/
backend/vendor/ backend/vendor/
backend/tmp/ backend/tmp/
backend/.env backend/.env
backend/server
# userfront (Flutter) # userfront (Flutter)
# Note: userfront might have its own .gitignore, but adding here just in case # Note: userfront might have its own .gitignore, but adding here just in case

View File

@@ -12,8 +12,8 @@ func main() {
// KETO_READ_URL과 KETO_WRITE_URL은 컨테이너 외부 포트 또는 내부 주소에 맞게 설정 필요 // KETO_READ_URL과 KETO_WRITE_URL은 컨테이너 외부 포트 또는 내부 주소에 맞게 설정 필요
os.Setenv("KETO_READ_URL", "http://keto:4466") os.Setenv("KETO_READ_URL", "http://keto:4466")
os.Setenv("KETO_WRITE_URL", "http://keto:4467") os.Setenv("KETO_WRITE_URL", "http://keto:4467")
keto := service.NewKetoService() keto := service.NewKetoService()
ctx := context.Background() ctx := context.Background()
userID := "test-user-id" userID := "test-user-id"

View File

@@ -35,15 +35,25 @@ func main() {
godotenv.Load("backend/.env") godotenv.Load("backend/.env")
pgHost := os.Getenv("DB_HOST") pgHost := os.Getenv("DB_HOST")
if pgHost == "" { pgHost = "localhost" } if pgHost == "" {
pgHost = "localhost"
}
pgPort := os.Getenv("DB_PORT") pgPort := os.Getenv("DB_PORT")
if pgPort == "" { pgPort = "5432" } if pgPort == "" {
pgPort = "5432"
}
pgUser := os.Getenv("DB_USER") pgUser := os.Getenv("DB_USER")
if pgUser == "" { pgUser = "baron" } if pgUser == "" {
pgUser = "baron"
}
pgPass := os.Getenv("DB_PASSWORD") pgPass := os.Getenv("DB_PASSWORD")
if pgPass == "" { pgPass = "password" } if pgPass == "" {
pgPass = "password"
}
pgName := os.Getenv("DB_NAME") pgName := os.Getenv("DB_NAME")
if pgName == "" { pgName = "baron_sso" } if pgName == "" {
pgName = "baron_sso"
}
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
pgHost, pgUser, pgPass, pgName, pgPort) pgHost, pgUser, pgPass, pgName, pgPort)

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"baron-sso-backend/internal/bootstrap"
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/handler" "baron-sso-backend/internal/handler"
"baron-sso-backend/internal/idp" "baron-sso-backend/internal/idp"
@@ -28,8 +29,6 @@ import (
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
gormLogger "gorm.io/gorm/logger" gormLogger "gorm.io/gorm/logger"
"baron-sso-backend/internal/bootstrap"
) )
func getEnv(key, fallback string) string { func getEnv(key, fallback string) string {
@@ -492,10 +491,10 @@ func main() {
auth.Post("/login/code/verify", authHandler.VerifyLoginCode) auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode) auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
auth.Post("/password/login", authHandler.PasswordLogin) auth.Post("/password/login", authHandler.PasswordLogin)
auth.Get("/consent", authHandler.GetConsentRequest) auth.Get("/consent", authHandler.GetConsentRequest)
auth.Post("/consent/accept", authHandler.AcceptConsentRequest) auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
auth.Post("/consent/reject", authHandler.RejectConsentRequest) auth.Post("/consent/reject", authHandler.RejectConsentRequest)
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest) auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
@@ -613,6 +612,7 @@ func main() {
dev.Post("/clients", devHandler.CreateClient) dev.Post("/clients", devHandler.CreateClient)
dev.Get("/clients/:id", devHandler.GetClient) dev.Get("/clients/:id", devHandler.GetClient)
dev.Put("/clients/:id", devHandler.UpdateClient) dev.Put("/clients/:id", devHandler.UpdateClient)
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret)
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus) dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
dev.Delete("/clients/:id", devHandler.DeleteClient) dev.Delete("/clients/:id", devHandler.DeleteClient)
dev.Get("/consents", devHandler.ListConsents) dev.Get("/consents", devHandler.ListConsents)

View File

@@ -1,12 +1,12 @@
package domain package domain
type EnchantedLinkInitRequest struct { type EnchantedLinkInitRequest struct {
LoginID string `json:"loginId"` LoginID string `json:"loginId"`
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow) URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
Method string `json:"method,omitempty"` // "email" or "sms" Method string `json:"method,omitempty"` // "email" or "sms"
CodeOnly bool `json:"codeOnly,omitempty"` CodeOnly bool `json:"codeOnly,omitempty"`
DryRun bool `json:"dryRun,omitempty"` DryRun bool `json:"dryRun,omitempty"`
DrySend bool `json:"drySend,omitempty"` DrySend bool `json:"drySend,omitempty"`
} }
type EnchantedLinkInitResponse struct { type EnchantedLinkInitResponse struct {
@@ -68,15 +68,15 @@ type SignupRequest struct {
// User Profile Models // User Profile Models
type UserProfileResponse struct { type UserProfileResponse struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
Phone string `json:"phone"` Phone string `json:"phone"`
Role string `json:"role"` // 추가 Role string `json:"role"` // 추가
Department string `json:"department"` Department string `json:"department"`
AffiliationType string `json:"affiliationType"` AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"` CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가 TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"` Tenant *Tenant `json:"tenant,omitempty"`

View File

@@ -17,15 +17,15 @@ const (
// IdentityProviderConfig stores the configuration for an external Identity Provider. // IdentityProviderConfig stores the configuration for an external Identity Provider.
type IdentityProviderConfig struct { type IdentityProviderConfig struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
ClientID string `gorm:"type:uuid;not null;index" json:"client_id"` // Replaces TenantID ClientID string `gorm:"type:uuid;not null;index" json:"client_id"` // Replaces TenantID
ProviderType ProviderType `gorm:"type:varchar(10);not null" json:"provider_type"` ProviderType ProviderType `gorm:"type:varchar(10);not null" json:"provider_type"`
DisplayName string `gorm:"not null" json:"display_name"` DisplayName string `gorm:"not null" json:"display_name"`
Status string `gorm:"default:'active'" json:"status"` Status string `gorm:"default:'active'" json:"status"`
// OIDC Specific Fields // OIDC Specific Fields
IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"` IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"`
OIDCClientID *string `gorm:"null" json:"oidc_client_id,omitempty"` // Renamed from ClientID OIDCClientID *string `gorm:"null" json:"oidc_client_id,omitempty"` // Renamed from ClientID
OIDCClientSecret *string `gorm:"null" json:"oidc_client_secret,omitempty"` // Renamed from ClientSecret OIDCClientSecret *string `gorm:"null" json:"oidc_client_secret,omitempty"` // Renamed from ClientSecret
// Scopes are space-separated // Scopes are space-separated
Scopes *string `gorm:"null" json:"scopes,omitempty"` Scopes *string `gorm:"null" json:"scopes,omitempty"`

View File

@@ -7,17 +7,17 @@ import (
// AuditLog represents a single audit event // AuditLog represents a single audit event
type AuditLog struct { type AuditLog struct {
EventID string `json:"event_id"` EventID string `json:"event_id"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
SessionID string `json:"session_id,omitempty"` SessionID string `json:"session_id,omitempty"`
EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent" EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent"
Status string `json:"status"` // e.g., "success", "failure" Status string `json:"status"` // e.g., "success", "failure"
AuthMethod string `json:"auth_method,omitempty"` AuthMethod string `json:"auth_method,omitempty"`
IPAddress string `json:"ip_address"` IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"` UserAgent string `json:"user_agent"`
DeviceID string `json:"device_id,omitempty"` DeviceID string `json:"device_id,omitempty"`
Details string `json:"details,omitempty"` // JSON string or simple text Details string `json:"details,omitempty"` // JSON string or simple text
} }
// AuditRepository defines interface for storing logs // AuditRepository defines interface for storing logs

View File

@@ -31,4 +31,3 @@ func (ug *UserGroup) BeforeCreate(tx *gorm.DB) (err error) {
} }
return return
} }

View File

@@ -13,7 +13,7 @@ import (
) )
// Mock DB for ApiKey tests using a real GORM instance but with a hijacked connection // Mock DB for ApiKey tests using a real GORM instance but with a hijacked connection
// or just a simple mock if we only check nil. // or just a simple mock if we only check nil.
// For ApiKeyHandler, it uses DB for Create/List/Delete. // For ApiKeyHandler, it uses DB for Create/List/Delete.
func TestApiKeyHandler_CreateApiKey(t *testing.T) { func TestApiKeyHandler_CreateApiKey(t *testing.T) {
@@ -22,11 +22,11 @@ func TestApiKeyHandler_CreateApiKey(t *testing.T) {
// Since we don't have a real DB here, we'll check if it fails gracefully // Since we don't have a real DB here, we'll check if it fails gracefully
// or we can use sqlite in-memory for a more realistic test. // or we can use sqlite in-memory for a more realistic test.
h := &ApiKeyHandler{DB: nil} // Testing ServiceUnavailable h := &ApiKeyHandler{DB: nil} // Testing ServiceUnavailable
app.Post("/api-keys", h.CreateApiKey) app.Post("/api-keys", h.CreateApiKey)
input := map[string]interface{}{ input := map[string]interface{}{
"name": "M2M Test", "name": "M2M Test",
"scopes": []string{"read", "write"}, "scopes": []string{"read", "write"},
} }
body, _ := json.Marshal(input) body, _ := json.Marshal(input)
@@ -41,8 +41,8 @@ func TestApiKeyHandler_CreateApiKey(t *testing.T) {
func TestApiKeyHandler_Validation(t *testing.T) { func TestApiKeyHandler_Validation(t *testing.T) {
app := fiber.New() app := fiber.New()
// Using a dummy DB pointer to pass the nil check // Using a dummy DB pointer to pass the nil check
h := &ApiKeyHandler{DB: &gorm.DB{}} h := &ApiKeyHandler{DB: &gorm.DB{}}
app.Post("/api-keys", h.CreateApiKey) app.Post("/api-keys", h.CreateApiKey)
// Missing name // Missing name

View File

@@ -3820,6 +3820,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
return profile, nil return profile, nil
} }
func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) { func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
token := h.getBearerToken(c) token := h.getBearerToken(c)
if token != "" { if token != "" {

View File

@@ -31,12 +31,15 @@ type MockIdentityProvider struct {
func (m *MockIdentityProvider) Name() string { func (m *MockIdentityProvider) Name() string {
return "mock-idp" return "mock-idp"
} }
func (m *MockIdentityProvider) GetMetadata() (*domain.IDPMetadata, error) { func (m *MockIdentityProvider) GetMetadata() (*domain.IDPMetadata, error) {
return nil, nil return nil, nil
} }
func (m *MockIdentityProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { func (m *MockIdentityProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
return "", nil return "", nil
} }
func (m *MockIdentityProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { func (m *MockIdentityProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
args := m.Called(loginID, password) args := m.Called(loginID, password)
if args.Get(0) == nil { if args.Get(0) == nil {
@@ -44,27 +47,35 @@ func (m *MockIdentityProvider) SignIn(loginID, password string) (*domain.AuthInf
} }
return args.Get(0).(*domain.AuthInfo), args.Error(1) return args.Get(0).(*domain.AuthInfo), args.Error(1)
} }
func (m *MockIdentityProvider) UserExists(loginID string) (bool, error) { func (m *MockIdentityProvider) UserExists(loginID string) (bool, error) {
return true, nil return true, nil
} }
func (m *MockIdentityProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { func (m *MockIdentityProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
return nil, nil return nil, nil
} }
func (m *MockIdentityProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { func (m *MockIdentityProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
return nil, nil return nil, nil
} }
func (m *MockIdentityProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { func (m *MockIdentityProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
return nil, nil return nil, nil
} }
func (m *MockIdentityProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { func (m *MockIdentityProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
return nil, nil return nil, nil
} }
func (m *MockIdentityProvider) InitiatePasswordReset(loginID, redirectUrl string) error { func (m *MockIdentityProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
return nil return nil
} }
func (m *MockIdentityProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { func (m *MockIdentityProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
return nil, nil return nil, nil
} }
func (m *MockIdentityProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { func (m *MockIdentityProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
return nil return nil
} }
@@ -102,7 +113,7 @@ func mockHydraTransport(handler http.Handler) http.RoundTripper {
func TestPasswordLogin_OIDC_Success(t *testing.T) { func TestPasswordLogin_OIDC_Success(t *testing.T) {
mockIdp := new(MockIdentityProvider) mockIdp := new(MockIdentityProvider)
// Mock IDP SignIn Success // Mock IDP SignIn Success
mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt"}, SessionToken: &domain.Token{JWT: "valid-jwt"},
@@ -142,7 +153,7 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
// Inject Mock Kratos (Hack: overwrite the service field if it was an interface, but it's a struct pointer) // Inject Mock Kratos (Hack: overwrite the service field if it was an interface, but it's a struct pointer)
// AuthHandler uses *service.KratosAdminService struct pointer. // AuthHandler uses *service.KratosAdminService struct pointer.
// KratosAdminService methods are real. We need to mock HTTP client inside KratosAdminService too. // KratosAdminService methods are real. We need to mock HTTP client inside KratosAdminService too.
kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Mock FindIdentityIDByIdentifier response // Mock FindIdentityIDByIdentifier response
if strings.Contains(r.URL.Path, "/identities") { if strings.Contains(r.URL.Path, "/identities") {
@@ -159,8 +170,8 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
app := newAuthLoginTestApp(h) app := newAuthLoginTestApp(h)
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"loginId": "user@example.com", "loginId": "user@example.com",
"password": "password", "password": "password",
"login_challenge": "challenge-123", "login_challenge": "challenge-123",
}) })
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
@@ -209,7 +220,7 @@ func TestPasswordLogin_OIDC_InactiveClient(t *testing.T) {
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
}, },
} }
kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]map[string]interface{}{{"id": "kratos-identity-id"}}) json.NewEncoder(w).Encode([]map[string]interface{}{{"id": "kratos-identity-id"}})
}) })
@@ -219,8 +230,8 @@ func TestPasswordLogin_OIDC_InactiveClient(t *testing.T) {
app := newAuthLoginTestApp(h) app := newAuthLoginTestApp(h)
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"loginId": "user@example.com", "loginId": "user@example.com",
"password": "password", "password": "password",
"login_challenge": "challenge-inactive", "login_challenge": "challenge-inactive",
}) })
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
@@ -250,7 +261,7 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) {
KratosAdmin: service.NewKratosAdminService(), KratosAdmin: service.NewKratosAdminService(),
Hydra: service.NewHydraAdminService(), Hydra: service.NewHydraAdminService(),
} }
kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]map[string]interface{}{{"id": "kratos-identity-id"}}) json.NewEncoder(w).Encode([]map[string]interface{}{{"id": "kratos-identity-id"}})
}) })

View File

@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"baron-sso-backend/internal/service"
"bytes" "bytes"
"encoding/json" "encoding/json"
"io" "io"
@@ -9,8 +10,6 @@ import (
"testing" "testing"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"baron-sso-backend/internal/service"
) )
func newOidcLoginTestApp(h *AuthHandler) *fiber.App { func newOidcLoginTestApp(h *AuthHandler) *fiber.App {

View File

@@ -5,7 +5,10 @@ import (
"baron-sso-backend/internal/repository" "baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"context" "context"
"crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@@ -508,6 +511,71 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent) 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 { func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
status := "active" status := "active"
if client.Metadata != nil { if client.Metadata != nil {

View File

@@ -12,17 +12,17 @@ import (
// FederationHandler handles API requests for IdP federation. // FederationHandler handles API requests for IdP federation.
type FederationHandler struct { type FederationHandler struct {
fedSvc *service.FederationService fedSvc *service.FederationService
repo repository.FederationRepository // For IdP Config CRUD repo repository.FederationRepository // For IdP Config CRUD
db *gorm.DB // For tenant existence checks, etc. in CRUD db *gorm.DB // For tenant existence checks, etc. in CRUD
} }
// NewFederationHandler creates a new FederationHandler. // NewFederationHandler creates a new FederationHandler.
func NewFederationHandler(fedSvc *service.FederationService, repo repository.FederationRepository, db *gorm.DB) *FederationHandler { func NewFederationHandler(fedSvc *service.FederationService, repo repository.FederationRepository, db *gorm.DB) *FederationHandler {
return &FederationHandler{ return &FederationHandler{
fedSvc: fedSvc, fedSvc: fedSvc,
repo: repo, repo: repo,
db: db, db: db,
} }
} }
@@ -98,7 +98,7 @@ func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
if req.DisplayName == "" || req.ProviderType == "" { if req.DisplayName == "" || req.ProviderType == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "display_name and provider_type are required"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "display_name and provider_type are required"})
} }
// TODO: Optionally, validate if the clientID exists in Hydra // TODO: Optionally, validate if the clientID exists in Hydra
// Create in DB // Create in DB
@@ -108,8 +108,6 @@ func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(req) return c.Status(fiber.StatusCreated).JSON(req)
} }
// --- Deprecated Tenant-based IdP Config Methods --- // --- Deprecated Tenant-based IdP Config Methods ---
// ListIdpConfigsForTenant handles listing all IdP configurations for a tenant. // ListIdpConfigsForTenant handles listing all IdP configurations for a tenant.
@@ -150,7 +148,7 @@ func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error {
} }
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
// Create in DB // Create in DB
if err := h.db.Create(&req).Error; err != nil { if err := h.db.Create(&req).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -158,4 +156,5 @@ func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(req) return c.Status(fiber.StatusCreated).JSON(req)
} }
// TODO: Re-implement Update, Delete handlers for IdP Configs for Clients // TODO: Re-implement Update, Delete handlers for IdP Configs for Clients

View File

@@ -3,8 +3,9 @@ package handler
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"github.com/gofiber/fiber/v2"
"log/slog" "log/slog"
"github.com/gofiber/fiber/v2"
) )
type RelyingPartyHandler struct { type RelyingPartyHandler struct {

View File

@@ -139,8 +139,8 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
} }
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
Domains []string `json:"domains"` Domains []string `json:"domains"`
@@ -204,8 +204,8 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
} }
var req struct { var req struct {
Name *string `json:"name"` Name *string `json:"name"`
Slug *string `json:"slug"` Slug *string `json:"slug"`
Description *string `json:"description"` Description *string `json:"description"`
Status *string `json:"status"` Status *string `json:"status"`
Domains []string `json:"domains"` Domains []string `json:"domains"`

View File

@@ -74,13 +74,13 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)
// CreateTenant checks h.DB != nil // CreateTenant checks h.DB != nil
h := &TenantHandler{Service: mockSvc, DB: &gorm.DB{}} h := &TenantHandler{Service: mockSvc, DB: &gorm.DB{}}
app.Post("/tenants", h.CreateTenant) app.Post("/tenants", h.CreateTenant)
input := map[string]interface{}{ input := map[string]interface{}{
"name": "Test Tenant", "name": "Test Tenant",
"slug": "test-tenant", "slug": "test-tenant",
"domains": []string{"test.com"}, "domains": []string{"test.com"},
} }
body, _ := json.Marshal(input) body, _ := json.Marshal(input)
@@ -102,7 +102,7 @@ func TestTenantHandler_ApproveTenant(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc} h := &TenantHandler{Service: mockSvc}
app.Post("/tenants/:id/approve", h.ApproveTenant) app.Post("/tenants/:id/approve", h.ApproveTenant)
mockSvc.On("ApproveTenant", mock.Anything, "t1").Return(nil) mockSvc.On("ApproveTenant", mock.Anything, "t1").Return(nil)

View File

@@ -3,6 +3,7 @@ package handler
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )

View File

@@ -548,7 +548,7 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
ctx := context.Background() ctx := context.Background()
// Fetch user from DB before cleanup if needed, but here we cleanup common namespaces // Fetch user from DB before cleanup if needed, but here we cleanup common namespaces
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID) _ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID)
// If we had more complex relations, we would query Keto first or use user metadata // If we had more complex relations, we would query Keto first or use user metadata
slog.Info("Keto relations cleaned up for user", "userID", uID) slog.Info("Keto relations cleaned up for user", "userID", uID)
}(userID) }(userID)

View File

@@ -99,7 +99,7 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
// 4. Gather Metrics & Context // 4. Gather Metrics & Context
latency := time.Since(start) latency := time.Since(start)
status := c.Response().StatusCode() status := c.Response().StatusCode()
// If Fiber handler returned an error, status might default to 500 or be in the error // If Fiber handler returned an error, status might default to 500 or be in the error
if err != nil { if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok { if fiberErr, ok := err.(*fiber.Error); ok {
@@ -120,7 +120,7 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
tenantID, _ := c.Locals("tenant_id").(string) tenantID, _ := c.Locals("tenant_id").(string)
sessionID, _ := c.Locals("session_id").(string) sessionID, _ := c.Locals("session_id").(string)
clientIP := extractClientIP(c) clientIP := extractClientIP(c)
// 6. Capture & Mask Body // 6. Capture & Mask Body
var maskedBody string var maskedBody string
if config.BodyDump { if config.BodyDump {
@@ -187,7 +187,7 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
// 9. Store Log (Policy Enforcement) // 9. Store Log (Policy Enforcement)
_, isWrite := writeMethods[c.Method()] _, isWrite := writeMethods[c.Method()]
if isNil(config.Repo) { if isNil(config.Repo) {
if isWrite { if isWrite {
slog.Error("Audit repository missing for command", "req_id", reqID) slog.Error("Audit repository missing for command", "req_id", reqID)

View File

@@ -43,7 +43,7 @@ func TestAuditMiddleware(t *testing.T) {
t.Run("POST request - Sync Success", func(t *testing.T) { t.Run("POST request - Sync Success", func(t *testing.T) {
app := fiber.New() app := fiber.New()
mockRepo := new(MockAuditRepository) mockRepo := new(MockAuditRepository)
app.Use(AuditMiddleware(AuditConfig{ app.Use(AuditMiddleware(AuditConfig{
Repo: mockRepo, Repo: mockRepo,
BodyDump: true, BodyDump: true,
@@ -56,14 +56,14 @@ func TestAuditMiddleware(t *testing.T) {
mockRepo.On("Create", mock.MatchedBy(func(log *domain.AuditLog) bool { mockRepo.On("Create", mock.MatchedBy(func(log *domain.AuditLog) bool {
var details map[string]any var details map[string]any
json.Unmarshal([]byte(log.Details), &details) json.Unmarshal([]byte(log.Details), &details)
return log.Status == "success" && return log.Status == "success" &&
details["method"] == "POST" && details["method"] == "POST" &&
details["request_body"] == `{"password":"*****","user":"test"}` details["request_body"] == `{"password":"*****","user":"test"}`
})).Return(nil) })).Return(nil)
req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"user": "test", "password": "mypassword"}`)) req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"user": "test", "password": "mypassword"}`))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req) resp, _ := app.Test(req)
assert.Equal(t, fiber.StatusOK, resp.StatusCode) assert.Equal(t, fiber.StatusOK, resp.StatusCode)
mockRepo.AssertExpectations(t) mockRepo.AssertExpectations(t)
@@ -72,7 +72,7 @@ func TestAuditMiddleware(t *testing.T) {
t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) { t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) {
app := fiber.New() app := fiber.New()
mockRepo := new(MockAuditRepository) mockRepo := new(MockAuditRepository)
app.Use(AuditMiddleware(AuditConfig{ app.Use(AuditMiddleware(AuditConfig{
Repo: mockRepo, Repo: mockRepo,
})) }))
@@ -85,7 +85,7 @@ func TestAuditMiddleware(t *testing.T) {
req := httptest.NewRequest("POST", "/test", nil) req := httptest.NewRequest("POST", "/test", nil)
resp, _ := app.Test(req) resp, _ := app.Test(req)
// Should return 503 because Audit failed on a Write method // Should return 503 because Audit failed on a Write method
assert.Equal(t, fiber.StatusServiceUnavailable, resp.StatusCode) assert.Equal(t, fiber.StatusServiceUnavailable, resp.StatusCode)
}) })
@@ -93,7 +93,7 @@ func TestAuditMiddleware(t *testing.T) {
t.Run("GET request - Async Load Shedding", func(t *testing.T) { t.Run("GET request - Async Load Shedding", func(t *testing.T) {
app := fiber.New() app := fiber.New()
mockRepo := new(MockAuditRepository) mockRepo := new(MockAuditRepository)
// Set very small queue and no workers to force load shedding // Set very small queue and no workers to force load shedding
app.Use(AuditMiddleware(AuditConfig{ app.Use(AuditMiddleware(AuditConfig{
Repo: mockRepo, Repo: mockRepo,
@@ -107,16 +107,16 @@ func TestAuditMiddleware(t *testing.T) {
// 1. First request fills the queue // 1. First request fills the queue
mockRepo.On("Create", mock.Anything).Return(nil) mockRepo.On("Create", mock.Anything).Return(nil)
req1 := httptest.NewRequest("GET", "/test", nil) req1 := httptest.NewRequest("GET", "/test", nil)
resp1, _ := app.Test(req1) resp1, _ := app.Test(req1)
assert.Equal(t, fiber.StatusOK, resp1.StatusCode) assert.Equal(t, fiber.StatusOK, resp1.StatusCode)
// 2. Second request should be dropped (load shedding) if workers are slow // 2. Second request should be dropped (load shedding) if workers are slow
// Since we can't easily pause workers without modifying code, // Since we can't easily pause workers without modifying code,
// this test mostly ensures the non-blocking send doesn't hang. // this test mostly ensures the non-blocking send doesn't hang.
req2 := httptest.NewRequest("GET", "/test", nil) req2 := httptest.NewRequest("GET", "/test", nil)
resp2, _ := app.Test(req2) resp2, _ := app.Test(req2)
assert.Equal(t, fiber.StatusOK, resp2.StatusCode) assert.Equal(t, fiber.StatusOK, resp2.StatusCode)
}) })
} }

View File

@@ -3,8 +3,9 @@ package middleware
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"github.com/gofiber/fiber/v2"
"log/slog" "log/slog"
"github.com/gofiber/fiber/v2"
) )
// RBACConfig defines the configuration for RBAC middleware // RBACConfig defines the configuration for RBAC middleware
@@ -89,9 +90,9 @@ func RequireRole(config RBACConfig) fiber.Handler {
} }
if !roleAllowed { if !roleAllowed {
slog.Warn("RBAC access denied", slog.Warn("RBAC access denied",
"userID", profile.ID, "userID", profile.ID,
"userRole", profile.Role, "userRole", profile.Role,
"allowedRoles", config.AllowedRoles, "allowedRoles", config.AllowedRoles,
"path", c.Path(), "path", c.Path(),
) )

View File

@@ -7,4 +7,4 @@ import (
type FederationRepository interface { type FederationRepository interface {
FindProviderByID(ctx context.Context, providerID string) (*domain.IdentityProviderConfig, error) FindProviderByID(ctx context.Context, providerID string) (*domain.IdentityProviderConfig, error)
} }

View File

@@ -3,6 +3,7 @@ package repository
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"context" "context"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -62,7 +62,7 @@ func (r *tenantRepository) FindByDomain(ctx context.Context, domainName string)
if err := r.db.WithContext(ctx).Where("domain = ?", domainName).First(&tenantDomain).Error; err != nil { if err := r.db.WithContext(ctx).Where("domain = ?", domainName).First(&tenantDomain).Error; err != nil {
return nil, err return nil, err
} }
var tenant domain.Tenant var tenant domain.Tenant
if err := r.db.WithContext(ctx).Preload("Domains").First(&tenant, "id = ?", tenantDomain.TenantID).Error; err != nil { if err := r.db.WithContext(ctx).Preload("Domains").First(&tenant, "id = ?", tenantDomain.TenantID).Error; err != nil {
return nil, err return nil, err

View File

@@ -50,4 +50,3 @@ func (r *userGroupRepository) ListByTenantID(ctx context.Context, tenantID strin
} }
return groups, nil return groups, nil
} }

View File

@@ -60,7 +60,6 @@ func (r *userRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.
return users, nil return users, nil
} }
func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
var users []domain.User var users []domain.User
if err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&users).Error; err != nil { if err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&users).Error; err != nil {

View File

@@ -8,14 +8,15 @@ import (
"fmt" "fmt"
"time" "time"
"golang.org/x/oauth2"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
) )
type FederationService struct { type FederationService struct {
repo repository.FederationRepository repo repository.FederationRepository
hydraSvc *HydraAdminService hydraSvc *HydraAdminService
redisSvc *RedisService redisSvc *RedisService
} }
func NewFederationService(repo repository.FederationRepository, hydraSvc *HydraAdminService, redisSvc *RedisService) *FederationService { func NewFederationService(repo repository.FederationRepository, hydraSvc *HydraAdminService, redisSvc *RedisService) *FederationService {
@@ -80,7 +81,6 @@ func (s *FederationService) HandleOIDCCallback(ctx context.Context, code, state
return "http://localhost:3000/login?login_successful=true", nil // Placeholder return "http://localhost:3000/login?login_successful=true", nil // Placeholder
} }
func generateState() (string, error) { func generateState() (string, error) {
b := make([]byte, 32) b := make([]byte, 32)
_, err := rand.Read(b) _, err := rand.Read(b)

View File

@@ -191,4 +191,4 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel
slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject) slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
return nil return nil
} }

View File

@@ -75,8 +75,8 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
} }
traits := map[string]interface{}{ traits := map[string]interface{}{
"email": user.Email, "email": user.Email,
"name": user.Name, "name": user.Name,
} }
if user.PhoneNumber != "" { if user.PhoneNumber != "" {
traits["phone_number"] = user.PhoneNumber traits["phone_number"] = user.PhoneNumber
@@ -521,10 +521,10 @@ type kratosRecoveryAddress struct {
} }
type kratosIdentityFull struct { type kratosIdentityFull struct {
SchemaID string `json:"schema_id"` SchemaID string `json:"schema_id"`
Traits map[string]interface{} `json:"traits"` Traits map[string]interface{} `json:"traits"`
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"` VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"` RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"`
} }
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error { func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error {

View File

@@ -38,8 +38,8 @@ func (s *relyingPartyService) Create(ctx context.Context, tenantID string, clien
client.Metadata = make(map[string]interface{}) client.Metadata = make(map[string]interface{})
} }
client.Metadata["tenant_id"] = tenantID client.Metadata["tenant_id"] = tenantID
// Ensure description is in metadata if provided in some other way? // Ensure description is in metadata if provided in some other way?
// The input 'client' is domain.HydraClient. It doesn't have a separate description field. // The input 'client' is domain.HydraClient. It doesn't have a separate description field.
// Assuming caller puts description in metadata. // Assuming caller puts description in metadata.
createdClient, err := s.hydraService.CreateClient(ctx, client) createdClient, err := s.hydraService.CreateClient(ctx, client)
@@ -72,7 +72,7 @@ func (s *relyingPartyService) Get(ctx context.Context, clientID string) (*domain
func (s *relyingPartyService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) { func (s *relyingPartyService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) {
// 1. Fetch ClientIDs from Keto // 1. Fetch ClientIDs from Keto
// Subject: Tenant:<tenantID>, Relation: parent_tenant, Namespace: RelyingParty // Subject: Tenant:<tenantID>, Relation: parent_tenant, Namespace: RelyingParty
// Note: ListRelations checks "who has relation to subject". // Note: ListRelations checks "who has relation to subject".
// Relation tuple: RelyingParty:cid # parent_tenant @ Tenant:tid // Relation tuple: RelyingParty:cid # parent_tenant @ Tenant:tid
// We want to find objects where subject=Tenant:tid. // We want to find objects where subject=Tenant:tid.
tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", "", "parent_tenant", "Tenant:"+tenantID) tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", "", "parent_tenant", "Tenant:"+tenantID)
@@ -105,12 +105,12 @@ func (s *relyingPartyService) ListAll(ctx context.Context) ([]domain.RelyingPart
// Assuming HydraAdminService has ListClients or similar? // Assuming HydraAdminService has ListClients or similar?
// The interface wasn't shown, but assuming it's available or we skip implementation. // The interface wasn't shown, but assuming it's available or we skip implementation.
// For now, let's return empty or error? // For now, let's return empty or error?
// Wait, repo.ListAll was used. // Wait, repo.ListAll was used.
// Let's assume we can't implement efficient ListAll without DB, // Let's assume we can't implement efficient ListAll without DB,
// UNLESS we use Keto to list all RelyingParties (if Keto supports listing all objects in namespace). // UNLESS we use Keto to list all RelyingParties (if Keto supports listing all objects in namespace).
// Keto doesn't support listing all objects easily. // Keto doesn't support listing all objects easily.
// But `hydraService` likely has `ListClients`. // But `hydraService` likely has `ListClients`.
return nil, fmt.Errorf("ListAll not implemented in SSOT mode yet") return nil, fmt.Errorf("ListAll not implemented in SSOT mode yet")
} }
func (s *relyingPartyService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) { func (s *relyingPartyService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) {
@@ -176,4 +176,3 @@ func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *doma
} }
return rp return rp
} }

View File

@@ -109,7 +109,6 @@ func TestRelyingPartyService_Create_Success(t *testing.T) {
svc := NewRelyingPartyService(hydraSvc, mockKeto) svc := NewRelyingPartyService(hydraSvc, mockKeto)
rp, err := svc.Create(context.Background(), tenantID, inputClient) rp, err := svc.Create(context.Background(), tenantID, inputClient)
if err != nil { if err != nil {
t.Fatalf("Create failed: %v", err) t.Fatalf("Create failed: %v", err)
} }
@@ -200,7 +199,6 @@ func TestRelyingPartyService_Get_Success(t *testing.T) {
svc := NewRelyingPartyService(hydraSvc, mockKeto) svc := NewRelyingPartyService(hydraSvc, mockKeto)
rp, hc, err := svc.Get(context.Background(), clientID) rp, hc, err := svc.Get(context.Background(), clientID)
if err != nil { if err != nil {
t.Fatalf("Get failed: %v", err) t.Fatalf("Get failed: %v", err)
} }
@@ -233,7 +231,6 @@ func TestRelyingPartyService_Update_Success(t *testing.T) {
updateReq := domain.HydraClient{ClientName: "New Name"} updateReq := domain.HydraClient{ClientName: "New Name"}
rp, err := svc.Update(context.Background(), clientID, updateReq) rp, err := svc.Update(context.Background(), clientID, updateReq)
if err != nil { if err != nil {
t.Fatalf("Update failed: %v", err) t.Fatalf("Update failed: %v", err)
} }
@@ -272,7 +269,6 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) {
svc := NewRelyingPartyService(hydraSvc, mockKeto) svc := NewRelyingPartyService(hydraSvc, mockKeto)
err := svc.Delete(context.Background(), clientID) err := svc.Delete(context.Background(), clientID)
if err != nil { if err != nil {
t.Fatalf("Delete failed: %v", err) t.Fatalf("Delete failed: %v", err)
} }

View File

@@ -141,12 +141,12 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Only return ACTIVE tenants for auto-assignment // Only return ACTIVE tenants for auto-assignment
if tenant.Status != domain.TenantStatusActive { if tenant.Status != domain.TenantStatusActive {
return nil, errors.New("tenant is not active") return nil, errors.New("tenant is not active")
} }
return tenant, nil return tenant, nil
} }

View File

@@ -13,7 +13,7 @@ type UserGroupService interface {
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
Get(ctx context.Context, id string) (*domain.UserGroup, error) Get(ctx context.Context, id string) (*domain.UserGroup, error)
List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error)
// Member Management with Keto Sync // Member Management with Keto Sync
AddMember(ctx context.Context, groupID, userID string) error AddMember(ctx context.Context, groupID, userID string) error
RemoveMember(ctx context.Context, groupID, userID string) error RemoveMember(ctx context.Context, groupID, userID string) error

View File

@@ -6,7 +6,7 @@ import (
) )
var ( var (
slugRegex = regexp.MustCompile(`^[a-z0-9-]+$`) slugRegex = regexp.MustCompile(`^[a-z0-9-]+$`)
reservedSlugs = map[string]bool{ reservedSlugs = map[string]bool{
"admin": true, "admin": true,
"api": true, "api": true,
@@ -31,7 +31,7 @@ var (
// ValidateSlug checks if a slug meets requirements and is not reserved. // ValidateSlug checks if a slug meets requirements and is not reserved.
func ValidateSlug(slug string) (bool, string) { func ValidateSlug(slug string) (bool, string) {
s := strings.ToLower(strings.TrimSpace(slug)) s := strings.ToLower(strings.TrimSpace(slug))
if len(s) < 3 || len(s) > 32 { if len(s) < 3 || len(s) > 32 {
return false, "slug must be between 3 and 32 characters" return false, "slug must be between 3 and 32 characters"
} }

Binary file not shown.

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { AlertCircle, Copy, Eye, EyeOff, Link2, Shield, Workflow, Save } from "lucide-react"; import { AlertCircle, Copy, Eye, EyeOff, Link2, Shield, Workflow, Save, RefreshCw } from "lucide-react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
@@ -15,7 +15,7 @@ import {
} from "../../components/ui/table"; } from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea"; import { Textarea } from "../../components/ui/textarea";
import { Label } from "../../components/ui/label"; import { Label } from "../../components/ui/label";
import { fetchClient, updateClient } from "../../lib/devApi"; import { fetchClient, updateClient, rotateClientSecret } from "../../lib/devApi";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { CopyButton } from "../../components/ui/copy-button"; import { CopyButton } from "../../components/ui/copy-button";
import { toast } from "../../components/ui/use-toast"; import { toast } from "../../components/ui/use-toast";
@@ -57,6 +57,24 @@ function ClientDetailsPage() {
}, },
}); });
const rotateMutation = useMutation({
mutationFn: () => rotateClientSecret(clientId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
toast("Client Secret이 재발급되었습니다.");
setShowSecret(true); // 재발급 후 바로 보여줌
},
onError: (err) => {
toast(`재발급 실패: ${(err as Error).message}`, "error");
},
});
const handleRotateSecret = () => {
if (window.confirm("경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?")) {
rotateMutation.mutate();
}
};
if (!clientId) { if (!clientId) {
return <div className="p-8 text-center">Client ID가 .</div>; return <div className="p-8 text-center">Client ID가 .</div>;
} }
@@ -176,14 +194,20 @@ function ClientDetailsPage() {
> >
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button> </Button>
<Button
variant="secondary"
size="icon"
onClick={handleRotateSecret}
disabled={rotateMutation.isPending}
title="비밀키 재발급 (Rotate)"
>
<RefreshCw className={cn("h-4 w-4", rotateMutation.isPending && "animate-spin")} />
</Button>
<CopyButton <CopyButton
value={clientSecret} value={clientSecret}
disabled={!showSecret && clientSecret === "SECRET_NOT_AVAILABLE"} disabled={!showSecret && clientSecret === "SECRET_NOT_AVAILABLE"}
onCopy={() => toast("Client Secret이 복사되었습니다.")} onCopy={() => toast("Client Secret이 복사되었습니다.")}
/> />
<Button variant="outline" size="icon" className="border-amber-500/50 text-amber-500">
<AlertCircle className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -130,9 +130,6 @@ function ClientsPage() {
</CardDescription> </CardDescription>
</div> </div>
<div className="hidden items-center gap-2 md:flex"> <div className="hidden items-center gap-2 md:flex">
<Button variant="outline" size="sm">
</Button>
<Button <Button
size="sm" size="sm"
className="shadow-lg shadow-primary/30" className="shadow-lg shadow-primary/30"
@@ -196,9 +193,6 @@ function ClientsPage() {
</CardTitle> </CardTitle>
<div className="flex items-center gap-2 md:hidden"> <div className="flex items-center gap-2 md:hidden">
<Button variant="outline" size="sm">
</Button>
<Button size="sm" onClick={() => navigate("/clients/new")}> <Button size="sm" onClick={() => navigate("/clients/new")}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>

View File

@@ -136,6 +136,13 @@ export async function updateClient(
return data; return data;
} }
export async function rotateClientSecret(clientId: string) {
const { data } = await apiClient.post<ClientDetailResponse>(
`/dev/clients/${clientId}/secret/rotate`
);
return data;
}
export async function deleteClient(clientId: string) { export async function deleteClient(clientId: string) {
await apiClient.delete(`/dev/clients/${clientId}`); await apiClient.delete(`/dev/clients/${clientId}`);
} }