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 ( +
+
+
+
+ + + Tenants + + / + Detail +
+

+ {tenantQuery.data?.name ?? "Loading Tenant..."} +

+

+ Edit tenant information or manage federation settings. +

+
+ Admin only +
+ + {/* Tabs */} +
+ + Profile + + + Federation + +
+ + {/* Outlet for nested routes */} + +
+ ); +} + +export default TenantDetailPage; diff --git a/adminfront/src/features/tenants/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx similarity index 95% rename from adminfront/src/features/tenants/TenantListPage.tsx rename to adminfront/src/features/tenants/routes/TenantListPage.tsx index 04c3c95c..92438a11 100644 --- a/adminfront/src/features/tenants/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -2,15 +2,15 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Link, 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"; +} from "../../../components/ui/card"; import { Table, TableBody, @@ -18,8 +18,8 @@ import { TableHead, TableHeader, TableRow, -} from "../../components/ui/table"; -import { deleteTenant, fetchTenants } from "../../lib/adminApi"; +} from "../../../components/ui/table"; +import { deleteTenant, fetchTenants } from "../../../lib/adminApi"; function TenantListPage() { const navigate = useNavigate(); diff --git a/adminfront/src/features/tenants/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx similarity index 65% rename from adminfront/src/features/tenants/TenantDetailPage.tsx rename to adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 1e85864b..44c0aa9a 100644 --- a/adminfront/src/features/tenants/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -1,31 +1,36 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { ArrowLeft, Save, Trash2 } from "lucide-react"; +import { Save, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useParams } from "react-router-dom"; -import { Badge } from "../../components/ui/badge"; -import { Button } from "../../components/ui/button"; +import { useNavigate, useParams } from "react-router-dom"; +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 { deleteTenant, fetchTenant, updateTenant } 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 { + deleteTenant, + fetchTenant, + updateTenant, +} from "../../../lib/adminApi"; -function TenantDetailPage() { - const { id } = useParams(); +export function TenantProfilePage() { + const { tenantId } = useParams<{ tenantId: string }>(); const navigate = useNavigate(); - const tenantId = useMemo(() => id ?? "", [id]); + + if (!tenantId) { + return
Tenant ID is missing
; + } const tenantQuery = useQuery({ queryKey: ["tenant", tenantId], queryFn: () => fetchTenant(tenantId), - enabled: tenantId !== "", }); const [name, setName] = useState(""); @@ -34,13 +39,12 @@ function TenantDetailPage() { const [status, setStatus] = useState("active"); useEffect(() => { - if (!tenantQuery.data) { - return; + if (tenantQuery.data) { + setName(tenantQuery.data.name); + setSlug(tenantQuery.data.slug); + setDescription(tenantQuery.data.description ?? ""); + setStatus(tenantQuery.data.status); } - setName(tenantQuery.data.name); - setSlug(tenantQuery.data.slug); - setDescription(tenantQuery.data.description ?? ""); - setStatus(tenantQuery.data.status); }, [tenantQuery.data]); const updateMutation = useMutation({ @@ -69,36 +73,19 @@ function TenantDetailPage() { ?.response?.data?.error; const handleDelete = () => { - if (!window.confirm("이 테넌트를 삭제할까요?")) { - return; + if (window.confirm("Are you sure you want to delete this tenant?")) { + deleteMutation.mutate(); } - deleteMutation.mutate(); }; return ( -
-
-
-
- - - Tenants - - / - Detail -
-

테넌트 상세

-

- 테넌트 정보를 수정하거나 삭제할 수 있습니다. -

-
- Admin only -
- - + <> + Tenant profile - Slug와 상태 변경은 바로 적용됩니다. + + Changes to slug and status are applied immediately. + {loadError && ( @@ -143,7 +130,6 @@ function TenantDetailPage() {
- {errorMsg && (
{errorMsg} @@ -152,18 +138,18 @@ function TenantDetailPage() { -
+
-
+ ); } - -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 (
@@ -81,7 +116,7 @@ function ClientDetailsPage() { to={`/clients/${clientId}`} className="border-b-2 border-primary pb-3 text-sm font-bold text-primary" > - Overview + Connection Settings + + Federation +
-
-

클라이언트 자격 증명

- - -
-

- Client ID -

-

{data.client.id}

-
- -
-
- - - -
-

- Client Secret -

-

- •••••••••••••••• -

-
-
- - - -
-
-
-
- -
-
-

OIDC 엔드포인트

- - - 읽기 전용 - -
- - - - {endpoints.map((endpoint) => ( - - -

- {endpoint.label} -

-
- - - {endpoint.value} - - - -
- ))} -
-
-
-
+ + -
-
-
-
- -
-
-

보안 메모

-

- 엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 - 로그와 연계하세요. -

-
+ + +
+

+ Client Secret +

+
+

+ {showSecret ? clientSecret : "••••••••••••••••"} +

+
+ + + +
+
+
+ +
-
- - - 감사 이벤트 필요 - + +
+
+

OIDC 엔드포인트

+ + + 읽기 전용 + +
+ + + + {endpoints.map((endpoint) => ( + + +

+ {endpoint.label} +

+
+ + + {endpoint.value} + + + +
+ ))} +
+
+
+
+
+ +
+
+

리디렉션 URI 설정

+ + + Redirect URIs + + 인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다. + + + +
+ +