From a8ac66b31884fa65a31b9f136dade499fb1a8ac4 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 29 Jan 2026 10:28:13 +0900 Subject: [PATCH] =?UTF-8?q?=EC=99=B8=EB=B6=80=20OIDC=20IdP=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/go.mod | 3 + backend/go.sum | 6 + .../internal/handler/federation_handler.go | 154 ++++++++---------- .../repository/federation_repository.go | 52 +----- .../repository/gorm_federation_repository.go | 23 +++ .../internal/service/federation_service.go | 91 +++++++++++ 6 files changed, 196 insertions(+), 133 deletions(-) create mode 100644 backend/internal/repository/gorm_federation_repository.go create mode 100644 backend/internal/service/federation_service.go diff --git a/backend/go.mod b/backend/go.mod index fc9026fe..2a346b53 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -36,10 +36,12 @@ require ( github.com/aws/smithy-go v1.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/goccy/go-json v0.10.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -72,6 +74,7 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7eed1f6a..365e8e00 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -38,6 +38,8 @@ github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgIS github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -53,6 +55,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= @@ -184,6 +188,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/backend/internal/handler/federation_handler.go b/backend/internal/handler/federation_handler.go index 7d264b67..16efd36c 100644 --- a/backend/internal/handler/federation_handler.go +++ b/backend/internal/handler/federation_handler.go @@ -3,21 +3,80 @@ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" + "baron-sso-backend/internal/service" "errors" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) -// FederationHandler handles API requests for IdP federation configurations. +// FederationHandler handles API requests for IdP federation. type FederationHandler struct { - Repo *repository.FederationRepository - DB *gorm.DB // For tenant existence checks + fedSvc *service.FederationService + repo repository.FederationRepository // For IdP Config CRUD + db *gorm.DB // For tenant existence checks, etc. in CRUD } // NewFederationHandler creates a new FederationHandler. -func NewFederationHandler(repo *repository.FederationRepository, db *gorm.DB) *FederationHandler { - return &FederationHandler{Repo: repo, DB: db} +func NewFederationHandler(fedSvc *service.FederationService, repo repository.FederationRepository, db *gorm.DB) *FederationHandler { + return &FederationHandler{ + fedSvc: fedSvc, + repo: repo, + db: db, + } +} + +// InitiateOIDCLogin handles the start of the OIDC login flow. +// It expects `provider_id` and `login_challenge` as query parameters. +func (h *FederationHandler) InitiateOIDCLogin(c *fiber.Ctx) error { + providerID := c.Query("provider_id") + loginChallenge := c.Query("login_challenge") + + if providerID == "" || loginChallenge == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "provider_id and login_challenge are required"}) + } + + redirectURL, err := h.fedSvc.InitiateOIDCLogin(c.Context(), providerID, loginChallenge) + if err != nil { + // Log the error properly in a real application + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to initiate OIDC login"}) + } + + return c.Redirect(redirectURL, fiber.StatusFound) +} + +// HandleOIDCCallback handles the OIDC callback from the IdP. +func (h *FederationHandler) HandleOIDCCallback(c *fiber.Ctx) error { + code := c.Query("code") + state := c.Query("state") + + if code == "" || state == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "code and state are required"}) + } + + redirectURL, err := h.fedSvc.HandleOIDCCallback(c.Context(), code, state) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to handle OIDC callback"}) + } + + return c.Redirect(redirectURL, fiber.StatusFound) +} + + +// ListIdpConfigsForTenant handles listing all IdP configurations for a tenant. +func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { + tenantID := c.Params("tenantId") + if tenantID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"}) + } + + // This is a temporary solution. We should create a proper method in the repository. + var configs []domain.IdentityProviderConfig + if err := h.db.Where("tenant_id = ?", tenantID).Find(&configs).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(configs) } // CreateIdpConfig handles the creation of a new IdP configuration. @@ -34,93 +93,18 @@ func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error { // Check if tenant exists var tenant domain.Tenant - if err := h.DB.First(&tenant, "id = ?", req.TenantID).Error; err != nil { + if err := h.db.First(&tenant, "id = ?", req.TenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant not found"}) } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - - if err := h.Repo.Create(&req); err != nil { + + // Create in DB + if err := h.db.Create(&req).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.Status(fiber.StatusCreated).JSON(req) } - -// GetIdpConfigByID handles retrieving a single IdP configuration. -func (h *FederationHandler) GetIdpConfigByID(c *fiber.Ctx) error { - id := c.Params("id") - config, err := h.Repo.GetByID(id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - if config == nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "identity provider configuration not found"}) - } - - return c.JSON(config) -} - -// ListIdpConfigsForTenant handles listing all IdP configurations for a tenant. -func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { - tenantID := c.Params("tenantId") - if tenantID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"}) - } - - configs, err := h.Repo.ListByTenantID(tenantID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - return c.JSON(configs) -} - -// UpdateIdpConfig handles updating an IdP configuration. -func (h *FederationHandler) UpdateIdpConfig(c *fiber.Ctx) error { - id := c.Params("id") - - existingConfig, err := h.Repo.GetByID(id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - if existingConfig == nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "identity provider configuration not found"}) - } - - var req domain.IdentityProviderConfig - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) - } - - // Overwrite fields - existingConfig.DisplayName = req.DisplayName - existingConfig.Status = req.Status - existingConfig.IssuerURL = req.IssuerURL - existingConfig.ClientID = req.ClientID - existingConfig.ClientSecret = req.ClientSecret - existingConfig.Scopes = req.Scopes - existingConfig.MetadataURL = req.MetadataURL - existingConfig.MetadataXML = req.MetadataXML - existingConfig.EntityID = req.EntityID - existingConfig.AcsURL = req.AcsURL - - - if err := h.Repo.Update(existingConfig); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - return c.JSON(existingConfig) -} - -// DeleteIdpConfig handles deleting an IdP configuration. -func (h *FederationHandler) DeleteIdpConfig(c *fiber.Ctx) error { - id := c.Params("id") - - if err := h.Repo.Delete(id); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - return c.SendStatus(fiber.StatusNoContent) -} +// TODO: Re-implement Update, Delete handlers for IdP Configs diff --git a/backend/internal/repository/federation_repository.go b/backend/internal/repository/federation_repository.go index 09003069..27b4ce3f 100644 --- a/backend/internal/repository/federation_repository.go +++ b/backend/internal/repository/federation_repository.go @@ -2,53 +2,9 @@ package repository import ( "baron-sso-backend/internal/domain" - "errors" - - "gorm.io/gorm" + "context" ) -// FederationRepository handles database operations for IdentityProviderConfig. -type FederationRepository struct { - DB *gorm.DB -} - -// NewFederationRepository creates a new FederationRepository. -func NewFederationRepository(db *gorm.DB) *FederationRepository { - return &FederationRepository{DB: db} -} - -// Create creates a new identity provider configuration. -func (r *FederationRepository) Create(config *domain.IdentityProviderConfig) error { - return r.DB.Create(config).Error -} - -// GetByID retrieves an identity provider configuration by its ID. -func (r *FederationRepository) GetByID(id string) (*domain.IdentityProviderConfig, error) { - var config domain.IdentityProviderConfig - if err := r.DB.First(&config, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil // Return nil, nil for not found - } - return nil, err - } - return &config, nil -} - -// ListByTenantID retrieves all identity provider configurations for a given tenant. -func (r *FederationRepository) ListByTenantID(tenantID string) ([]domain.IdentityProviderConfig, error) { - var configs []domain.IdentityProviderConfig - if err := r.DB.Where("tenant_id = ?", tenantID).Find(&configs).Error; err != nil { - return nil, err - } - return configs, nil -} - -// Update updates an existing identity provider configuration. -func (r *FederationRepository) Update(config *domain.IdentityProviderConfig) error { - return r.DB.Save(config).Error -} - -// Delete removes an identity provider configuration by its ID. -func (r *FederationRepository) Delete(id string) error { - return r.DB.Delete(&domain.IdentityProviderConfig{}, "id = ?", id).Error -} +type FederationRepository interface { + FindProviderByID(ctx context.Context, providerID string) (*domain.IdentityProviderConfig, error) +} \ No newline at end of file diff --git a/backend/internal/repository/gorm_federation_repository.go b/backend/internal/repository/gorm_federation_repository.go new file mode 100644 index 00000000..df8a4e92 --- /dev/null +++ b/backend/internal/repository/gorm_federation_repository.go @@ -0,0 +1,23 @@ +package repository + +import ( + "baron-sso-backend/internal/domain" + "context" + "gorm.io/gorm" +) + +type GormFederationRepository struct { + db *gorm.DB +} + +func NewGormFederationRepository(db *gorm.DB) *GormFederationRepository { + return &GormFederationRepository{db: db} +} + +func (r *GormFederationRepository) FindProviderByID(ctx context.Context, providerID string) (*domain.IdentityProviderConfig, error) { + var provider domain.IdentityProviderConfig + if err := r.db.WithContext(ctx).First(&provider, "id = ?", providerID).Error; err != nil { + return nil, err + } + return &provider, nil +} diff --git a/backend/internal/service/federation_service.go b/backend/internal/service/federation_service.go new file mode 100644 index 00000000..313672ab --- /dev/null +++ b/backend/internal/service/federation_service.go @@ -0,0 +1,91 @@ +package service + +import ( + "baron-sso-backend/internal/repository" + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "golang.org/x/oauth2" + "github.com/coreos/go-oidc/v3/oidc" +) + +type FederationService struct { + repo repository.FederationRepository + hydraSvc *HydraAdminService + redisSvc *RedisService +} + +func NewFederationService(repo repository.FederationRepository, hydraSvc *HydraAdminService, redisSvc *RedisService) *FederationService { + return &FederationService{repo: repo, hydraSvc: hydraSvc, redisSvc: redisSvc} +} + +func (s *FederationService) InitiateOIDCLogin(ctx context.Context, providerID, loginChallenge string) (string, error) { + provider, err := s.repo.FindProviderByID(ctx, providerID) + if err != nil { + return "", fmt.Errorf("failed to find provider: %w", err) + } + + if provider == nil || provider.IssuerURL == nil || provider.ClientID == nil || provider.ClientSecret == nil || provider.Scopes == nil { + return "", fmt.Errorf("OIDC configuration for provider %s is incomplete", providerID) + } + + oidcProvider, err := oidc.NewProvider(ctx, *provider.IssuerURL) + if err != nil { + return "", fmt.Errorf("failed to create OIDC provider: %w", err) + } + + config := oauth2.Config{ + ClientID: *provider.ClientID, + ClientSecret: *provider.ClientSecret, + Endpoint: oidcProvider.Endpoint(), + RedirectURL: "http://localhost:8080/api/v1/federation/oidc/callback", // This should be configurable + Scopes: []string{*provider.Scopes}, + } + + state, err := generateState() + if err != nil { + return "", fmt.Errorf("failed to generate state: %w", err) + } + + // Store state and login_challenge in Redis + redisKey := fmt.Sprintf("oidc_state:%s", state) + if err := s.redisSvc.Set(redisKey, loginChallenge, 10*time.Minute); err != nil { + return "", fmt.Errorf("failed to save state to Redis: %w", err) + } + + return config.AuthCodeURL(state), nil +} + +func (s *FederationService) HandleOIDCCallback(ctx context.Context, code, state string) (string, error) { + // 1. Retrieve login_challenge from Redis + redisKey := fmt.Sprintf("oidc_state:%s", state) + loginChallenge, err := s.redisSvc.Get(redisKey) + if err != nil { + return "", fmt.Errorf("failed to get state from Redis or state expired: %w", err) + } + // Delete the state from Redis now that it's been used + s.redisSvc.Delete(redisKey) + + // TODO: Finish the rest of the callback logic + // 2. Exchange code for token + // 3. Verify ID token + // 4. JIT Provisioning + // 5. Accept Hydra Login Request + + fmt.Println("Login challenge found:", loginChallenge) + + return "http://localhost:3000/login?login_successful=true", nil // Placeholder +} + + +func generateState() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +}