diff --git a/.gitignore b/.gitignore
index 276b51e6..a4d2df8d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# General
.env
+.temp
.DS_Store
.idea/
.vscode/
diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx
index 262320a1..df1d0c21 100644
--- a/adminfront/src/app/routes.tsx
+++ b/adminfront/src/app/routes.tsx
@@ -6,9 +6,9 @@ import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
-import TenantCreatePage from "../features/tenants/TenantCreatePage";
-import TenantDetailPage from "../features/tenants/TenantDetailPage";
-import TenantListPage from "../features/tenants/TenantListPage";
+import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
+import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
+import TenantListPage from "../features/tenants/routes/TenantListPage";
import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
diff --git a/adminfront/src/features/tenants/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx
similarity index 92%
rename from adminfront/src/features/tenants/TenantCreatePage.tsx
rename to adminfront/src/features/tenants/routes/TenantCreatePage.tsx
index f48b67e4..6d67d81d 100644
--- a/adminfront/src/features/tenants/TenantCreatePage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx
@@ -3,19 +3,19 @@ import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
-import { Badge } from "../../components/ui/badge";
-import { Button } from "../../components/ui/button";
+import { Badge } from "../../../components/ui/badge";
+import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
-} from "../../components/ui/card";
-import { Input } from "../../components/ui/input";
-import { Label } from "../../components/ui/label";
-import { Textarea } from "../../components/ui/textarea";
-import { createTenant } from "../../lib/adminApi";
+} from "../../../components/ui/card";
+import { Input } from "../../../components/ui/input";
+import { Label } from "../../../components/ui/label";
+import { Textarea } from "../../../components/ui/textarea";
+import { createTenant } from "../../../lib/adminApi";
function TenantCreatePage() {
const navigate = useNavigate();
diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx
new file mode 100644
index 00000000..14080889
--- /dev/null
+++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx
@@ -0,0 +1,71 @@
+import { useQuery } from "@tanstack/react-query";
+import { ArrowLeft } from "lucide-react";
+import { Link, Outlet, useLocation, useParams } from "react-router-dom";
+import { Badge } from "../../../components/ui/badge";
+import { fetchTenant } from "../../../lib/adminApi";
+
+function TenantDetailPage() {
+ const { tenantId } = useParams<{ tenantId: string }>();
+ const location = useLocation();
+
+ const tenantQuery = useQuery({
+ queryKey: ["tenant", tenantId],
+ queryFn: () => fetchTenant(tenantId!),
+ enabled: !!tenantId,
+ });
+
+ const isFederationTab = location.pathname.includes("/federation");
+
+ return (
+
{errorMsg}
@@ -152,18 +138,18 @@ function TenantDetailPage() {
-
+
- 삭제
+ Delete
navigate("/tenants")}>
- 취소
+ Cancel
updateMutation.mutate()}
@@ -174,12 +160,10 @@ function TenantDetailPage() {
}
>
- 저장
+ Save
-
+ >
);
}
-
-export default TenantDetailPage;
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 5ac0d999..de360afa 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -475,7 +475,7 @@ func main() {
admin.Post("/api-keys", apiKeyHandler.CreateApiKey)
admin.Delete("/api-keys/:id", apiKeyHandler.DeleteApiKey)
- // 개발자 포털 라우트 (RP/Consent 관리)
+ // 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
dev := api.Group("/dev")
dev.Get("/clients", devHandler.ListClients)
dev.Post("/clients", devHandler.CreateClient)
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/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go
index c6f6993a..35b83e87 100644
--- a/backend/internal/bootstrap/bootstrap.go
+++ b/backend/internal/bootstrap/bootstrap.go
@@ -36,6 +36,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.User{},
&domain.Tenant{},
&domain.ApiKey{},
+ &domain.IdentityProviderConfig{},
// &domain.RelyingParty{}, // TODO: Uncomment when model is ready
// &domain.UserConsent{}, // TODO: Uncomment when model is ready
)
diff --git a/backend/internal/domain/federation_models.go b/backend/internal/domain/federation_models.go
new file mode 100644
index 00000000..4f29b9db
--- /dev/null
+++ b/backend/internal/domain/federation_models.go
@@ -0,0 +1,50 @@
+package domain
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// ProviderType defines the type of the identity provider.
+type ProviderType string
+
+const (
+ ProviderTypeOIDC ProviderType = "oidc"
+ ProviderTypeSAML ProviderType = "saml"
+)
+
+// IdentityProviderConfig stores the configuration for an external Identity Provider.
+type IdentityProviderConfig struct {
+ 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
+ ProviderType ProviderType `gorm:"type:varchar(10);not null" json:"provider_type"`
+ DisplayName string `gorm:"not null" json:"display_name"`
+ Status string `gorm:"default:'active'" json:"status"`
+
+ // OIDC Specific Fields
+ IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"`
+ OIDCClientID *string `gorm:"null" json:"oidc_client_id,omitempty"` // Renamed from ClientID
+ OIDCClientSecret *string `gorm:"null" json:"oidc_client_secret,omitempty"` // Renamed from ClientSecret
+ // Scopes are space-separated
+ Scopes *string `gorm:"null" json:"scopes,omitempty"`
+
+ // SAML Specific Fields
+ MetadataURL *string `gorm:"null" json:"metadata_url,omitempty"`
+ MetadataXML *string `gorm:"type:text;null" json:"metadata_xml,omitempty"`
+ EntityID *string `gorm:"null" json:"entity_id,omitempty"`
+ AcsURL *string `gorm:"null" json:"acs_url,omitempty"`
+
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
+
+// BeforeCreate hook to generate UUID if not present.
+func (idc *IdentityProviderConfig) BeforeCreate(tx *gorm.DB) (err error) {
+ if idc.ID == "" {
+ idc.ID = uuid.NewString()
+ }
+ return
+}
diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go
index cce0d541..1f9ba05a 100644
--- a/backend/internal/handler/dev_handler.go
+++ b/backend/internal/handler/dev_handler.go
@@ -28,6 +28,7 @@ type clientSummary struct {
CreatedAt *time.Time `json:"createdAt,omitempty"`
RedirectURIs []string `json:"redirectUris"`
Scopes []string `json:"scopes"`
+ ClientSecret string `json:"clientSecret,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
@@ -227,7 +228,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
}
}
- client := service.HydraClient{
+ clientReq := service.HydraClient{
ClientID: clientID,
ClientName: name,
RedirectURIs: redirectURIs,
@@ -238,11 +239,20 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
Metadata: metadata,
}
- created, err := h.Hydra.CreateClient(c.Context(), client)
+ created, err := h.Hydra.CreateClient(c.Context(), clientReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
+ // Store secret in metadata for later retrieval
+ if created.ClientSecret != "" {
+ if created.Metadata == nil {
+ created.Metadata = map[string]interface{}{}
+ }
+ created.Metadata["client_secret"] = created.ClientSecret
+ _, _ = h.Hydra.UpdateClient(c.Context(), created.ClientID, *created)
+ }
+
summary := mapClientSummary(*created)
return c.Status(fiber.StatusCreated).JSON(clientDetailResponse{
Client: summary,
@@ -433,6 +443,7 @@ func mapClientSummary(client service.HydraClient) clientSummary {
Status: status,
RedirectURIs: client.RedirectURIs,
Scopes: scopes,
+ ClientSecret: client.ClientSecret,
Metadata: client.Metadata,
}
}
diff --git a/backend/internal/handler/federation_handler.go b/backend/internal/handler/federation_handler.go
new file mode 100644
index 00000000..e4258a16
--- /dev/null
+++ b/backend/internal/handler/federation_handler.go
@@ -0,0 +1,161 @@
+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.
+type FederationHandler struct {
+ 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(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)
+}
+
+// --- New Client-based IdP Config Methods ---
+
+// ListIdpConfigsForClient handles listing all IdP configurations for a client.
+func (h *FederationHandler) ListIdpConfigsForClient(c *fiber.Ctx) error {
+ clientID := c.Params("clientId")
+ if clientID == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "clientId is required"})
+ }
+
+ var configs []domain.IdentityProviderConfig
+ if err := h.db.Where("client_id = ?", clientID).Find(&configs).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ return c.JSON(configs)
+}
+
+// CreateIdpConfigForClient handles the creation of a new IdP configuration for a client.
+func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
+ clientID := c.Params("clientId")
+ if clientID == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "clientId is required in path"})
+ }
+
+ var req domain.IdentityProviderConfig
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
+ }
+
+ // Assign clientID from path parameter
+ req.ClientID = clientID
+
+ // Basic validation
+ if req.DisplayName == "" || req.ProviderType == "" {
+ 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
+
+ // 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)
+}
+
+
+// --- Deprecated Tenant-based IdP Config Methods ---
+
+// 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
+ // Note: This now queries client_id, which is incorrect for tenants.
+ // This method is deprecated.
+ 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.
+func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error {
+ var req domain.IdentityProviderConfig
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
+ }
+
+ // Basic validation - This is the old validation logic
+ if req.ClientID == "" || req.DisplayName == "" || req.ProviderType == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id, display_name, and provider_type are required"})
+ }
+
+ // This check is now incorrect and deprecated.
+ var tenant domain.Tenant
+ if err := h.db.First(&tenant, "id = ?", req.ClientID).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()})
+ }
+
+ // 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)
+}
+// TODO: Re-implement Update, Delete handlers for IdP Configs for Clients
diff --git a/backend/internal/repository/federation_repository.go b/backend/internal/repository/federation_repository.go
new file mode 100644
index 00000000..27b4ce3f
--- /dev/null
+++ b/backend/internal/repository/federation_repository.go
@@ -0,0 +1,10 @@
+package repository
+
+import (
+ "baron-sso-backend/internal/domain"
+ "context"
+)
+
+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..fed14881
--- /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.OIDCClientID == nil || provider.OIDCClientSecret == 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.OIDCClientID,
+ ClientSecret: *provider.OIDCClientSecret,
+ 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
+}
diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go
index 42bf713c..6d77cebf 100644
--- a/backend/internal/service/hydra_admin_service.go
+++ b/backend/internal/service/hydra_admin_service.go
@@ -27,6 +27,7 @@ type HydraAdminService struct {
type HydraClient struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
+ ClientSecret string `json:"client_secret,omitempty"` // Added
RedirectURIs []string `json:"redirect_uris,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
diff --git a/compose.ory.yaml b/compose.ory.yaml
index 1743a1c5..600118d3 100644
--- a/compose.ory.yaml
+++ b/compose.ory.yaml
@@ -12,7 +12,11 @@ services:
networks:
- ory-net
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}"]
+ test:
+ [
+ "CMD-SHELL",
+ "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}",
+ ]
interval: 5s
timeout: 5s
retries: 5
@@ -91,7 +95,7 @@ services:
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20
- command: migrate sql -e --yes
+ command: migrate sql up -e --yes
depends_on:
postgres_ory:
condition: service_healthy
@@ -126,7 +130,7 @@ services:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20
volumes:
- ./docker/ory/keto:/etc/config/keto
- command: migrate up -c /etc/config/keto/keto.yml --yes
+ command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"]
depends_on:
postgres_ory:
condition: service_healthy
@@ -213,25 +217,25 @@ services:
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
environment:
- HYDRA_ADMIN_URL=http://hydra:4445
- command: >
- /bin/sh -c "
- hydra clients create
- --endpoint http://hydra:4445
- --id adminfront
- --secret admin-secret
- --grant-types authorization_code,refresh_token
- --response-types code
- --scope openid,offline_access,profile,email
- --callbacks http://localhost:5000/callback;
- hydra clients create
- --endpoint http://hydra:4445
- --id devfront
- --grant-types authorization_code,refresh_token
- --response-types code
- --scope openid,offline_access,profile,email
- --token-endpoint-auth-method none
- --callbacks http://localhost:5174/callback;
- "
+ command: |
+ hydra clients create \
+ --endpoint http://hydra:4445 \
+ --id adminfront \
+ --secret admin-secret \
+ --grant-types authorization_code,refresh_token \
+ --response-types code \
+ --scope openid,offline_access,profile,email \
+ --callbacks http://localhost:5000/callback;
+
+ hydra clients create \
+ --endpoint http://hydra:4445 \
+ --id devfront \
+ --grant-types authorization_code,refresh_token \
+ --response-types code \
+ --scope openid,offline_access,profile,email \
+ --token-endpoint-auth-method none \
+ --response-types code \
+ --callbacks http://localhost:5174/callback;
depends_on:
ory_stack_check:
condition: service_completed_successfully
diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx
index 0bbba406..b30cb04c 100644
--- a/devfront/src/app/routes.tsx
+++ b/devfront/src/app/routes.tsx
@@ -4,6 +4,7 @@ import ClientConsentsPage from "../features/clients/ClientConsentsPage";
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
import ClientsPage from "../features/clients/ClientsPage";
+import { ClientFederationPage } from "../features/clients/routes/ClientFederationPage";
export const router = createBrowserRouter(
[
@@ -17,6 +18,7 @@ export const router = createBrowserRouter(
{ path: "clients/:id", element:
},
{ path: "clients/:id/consents", element:
},
{ path: "clients/:id/settings", element:
},
+ { path: "clients/:id/federation", element:
},
],
},
],
diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx
index 77b7fae9..36aa843d 100644
--- a/devfront/src/features/clients/ClientConsentsPage.tsx
+++ b/devfront/src/features/clients/ClientConsentsPage.tsx
@@ -109,7 +109,7 @@ function ClientConsentsPage() {
to={`/clients/${clientId}`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
- Overview
+ Connection
Consent & Users
@@ -120,6 +120,12 @@ function ClientConsentsPage() {
>
Settings
+
+ Federation
+
diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx
index 1e1e2e49..96baf9f8 100644
--- a/devfront/src/features/clients/ClientDetailsPage.tsx
+++ b/devfront/src/features/clients/ClientDetailsPage.tsx
@@ -1,10 +1,11 @@
-import { useQuery } from "@tanstack/react-query";
+import React, { useState, useEffect } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
-import { AlertCircle, Copy, Eye, Link2, Shield, Workflow } from "lucide-react";
+import { AlertCircle, Copy, Eye, EyeOff, Link2, Shield, Workflow, Save } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
-import { Card, CardContent } from "../../components/ui/card";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../components/ui/card";
import { Separator } from "../../components/ui/separator";
import {
Table,
@@ -12,17 +13,48 @@ import {
TableCell,
TableRow,
} from "../../components/ui/table";
-import { fetchClient } from "../../lib/devApi";
+import { Textarea } from "../../components/ui/textarea";
+import { Label } from "../../components/ui/label";
+import { fetchClient, updateClient } from "../../lib/devApi";
+import { cn } from "../../lib/utils";
function ClientDetailsPage() {
const params = useParams();
+ const queryClient = useQueryClient();
const clientId = params.id ?? "";
+
const { data, isLoading, error } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId),
enabled: clientId.length > 0,
});
+ const [redirectUris, setRedirectUris] = useState("");
+ const [showSecret, setShowSecret] = useState(false);
+
+ useEffect(() => {
+ if (data?.client?.redirectUris) {
+ setRedirectUris(data.client.redirectUris.join(", "));
+ }
+ }, [data]);
+
+ const mutation = useMutation({
+ mutationFn: () => {
+ const uriList = redirectUris
+ .split(",")
+ .map((u) => u.trim())
+ .filter(Boolean);
+ return updateClient(clientId, { redirectUris: uriList });
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["client", clientId] });
+ alert("Redirect URIs가 저장되었습니다.");
+ },
+ onError: (err) => {
+ alert(`저장 실패: ${(err as Error).message}`);
+ },
+ });
+
if (!clientId) {
return Client ID가 필요합니다.
;
}
@@ -50,6 +82,9 @@ function ClientDetailsPage() {
{ label: "UserInfo Endpoint", value: data.endpoints.userinfo },
];
+ // Client Secret from API
+ const clientSecret = data.client.clientSecret || "SECRET_NOT_AVAILABLE";
+
return (