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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -31,4 +31,3 @@ func (ug *UserGroup) BeforeCreate(tx *gorm.DB) (err error) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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"}})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -50,4 +50,3 @@ func (r *userGroupRepository) ListByTenantID(ctx context.Context, tenantID strin
|
|||||||
}
|
}
|
||||||
return groups, nil
|
return groups, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
backend/server
BIN
backend/server
Binary file not shown.
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user