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

@@ -13,7 +13,7 @@ func main() {
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,9 +491,9 @@ 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)
@@ -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

@@ -26,7 +26,7 @@ func TestApiKeyHandler_CreateApiKey(t *testing.T) {
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)

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
} }
@@ -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))
@@ -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))

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,
} }
} }
@@ -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.
@@ -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

@@ -79,8 +79,8 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
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)

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

@@ -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

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

@@ -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

@@ -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

@@ -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

@@ -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,

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}`);
} }