From 3e2ceff692f2f865787670a37d9421bc5f5f2f81 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 29 Jan 2026 14:54:38 +0900 Subject: [PATCH] =?UTF-8?q?IdP=20=EC=97=B0=EB=8F=99=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20devfront=20=EC=9D=B4=EC=A0=84=20=EB=B0=8F=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=A2=85=EC=86=8D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 17 +++-- backend/internal/domain/federation_models.go | 9 ++- .../internal/handler/federation_handler.go | 63 ++++++++++++++++-- .../internal/service/federation_service.go | 6 +- .../clients/routes/ClientFederationPage.tsx | 46 +++++++------ devfront/src/lib/devApi.ts | 64 +++++++++++++++++++ 6 files changed, 161 insertions(+), 44 deletions(-) rename adminfront/src/features/tenants/routes/TenantFederationPage.tsx => devfront/src/features/clients/routes/ClientFederationPage.tsx (89%) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5ac0d999..f69a1d6e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -476,15 +476,14 @@ func main() { admin.Delete("/api-keys/:id", apiKeyHandler.DeleteApiKey) // 개발자 포털 라우트 (RP/Consent 관리) - dev := api.Group("/dev") - dev.Get("/clients", devHandler.ListClients) - dev.Post("/clients", devHandler.CreateClient) - dev.Get("/clients/:id", devHandler.GetClient) - dev.Put("/clients/:id", devHandler.UpdateClient) - dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus) - dev.Delete("/clients/:id", devHandler.DeleteClient) - dev.Get("/consents", devHandler.ListConsents) - dev.Delete("/consents", devHandler.RevokeConsents) + api.Get("/clients", devHandler.ListClients) + api.Post("/clients", devHandler.CreateClient) + api.Get("/clients/:id", devHandler.GetClient) + api.Put("/clients/:id", devHandler.UpdateClient) + api.Patch("/clients/:id/status", devHandler.UpdateClientStatus) + api.Delete("/clients/:id", devHandler.DeleteClient) + api.Get("/consents", devHandler.ListConsents) + api.Delete("/consents", devHandler.RevokeConsents) // Webhook for Descope Generic SMS Gateway auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay) diff --git a/backend/internal/domain/federation_models.go b/backend/internal/domain/federation_models.go index 0dbcbe21..4f29b9db 100644 --- a/backend/internal/domain/federation_models.go +++ b/backend/internal/domain/federation_models.go @@ -18,16 +18,15 @@ const ( // IdentityProviderConfig stores the configuration for an external Identity Provider. type IdentityProviderConfig struct { ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` - TenantID string `gorm:"type:uuid;not null;index" json:"tenant_id"` - Tenant Tenant `gorm:"foreignKey:TenantID" json:"-"` // Belongs to Tenant + 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"` - ClientID *string `gorm:"null" json:"client_id,omitempty"` - ClientSecret *string `gorm:"null" json:"client_secret,omitempty"` + 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"` diff --git a/backend/internal/handler/federation_handler.go b/backend/internal/handler/federation_handler.go index 16efd36c..e4258a16 100644 --- a/backend/internal/handler/federation_handler.go +++ b/backend/internal/handler/federation_handler.go @@ -62,6 +62,55 @@ func (h *FederationHandler) HandleOIDCCallback(c *fiber.Ctx) error { 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 { @@ -72,6 +121,8 @@ func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { // 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()}) } @@ -86,14 +137,14 @@ func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } - // Basic validation - if req.TenantID == "" || req.DisplayName == "" || req.ProviderType == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id, display_name, and provider_type are required"}) + // 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"}) } - // Check if tenant exists + // This check is now incorrect and deprecated. var tenant domain.Tenant - if err := h.db.First(&tenant, "id = ?", req.TenantID).Error; err != nil { + 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"}) } @@ -107,4 +158,4 @@ func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(req) } -// TODO: Re-implement Update, Delete handlers for IdP Configs +// TODO: Re-implement Update, Delete handlers for IdP Configs for Clients diff --git a/backend/internal/service/federation_service.go b/backend/internal/service/federation_service.go index 313672ab..fed14881 100644 --- a/backend/internal/service/federation_service.go +++ b/backend/internal/service/federation_service.go @@ -28,7 +28,7 @@ func (s *FederationService) InitiateOIDCLogin(ctx context.Context, providerID, l return "", fmt.Errorf("failed to find provider: %w", err) } - if provider == nil || provider.IssuerURL == nil || provider.ClientID == nil || provider.ClientSecret == nil || provider.Scopes == nil { + 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) } @@ -38,8 +38,8 @@ func (s *FederationService) InitiateOIDCLogin(ctx context.Context, providerID, l } config := oauth2.Config{ - ClientID: *provider.ClientID, - ClientSecret: *provider.ClientSecret, + 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}, diff --git a/adminfront/src/features/tenants/routes/TenantFederationPage.tsx b/devfront/src/features/clients/routes/ClientFederationPage.tsx similarity index 89% rename from adminfront/src/features/tenants/routes/TenantFederationPage.tsx rename to devfront/src/features/clients/routes/ClientFederationPage.tsx index ca36003d..8a4ef063 100644 --- a/adminfront/src/features/tenants/routes/TenantFederationPage.tsx +++ b/devfront/src/features/clients/routes/ClientFederationPage.tsx @@ -1,35 +1,39 @@ import { useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { createIdpConfig, listIdpConfigsForTenant } from "../../../lib/adminApi"; -import type { IdpConfigCreateRequest, IdpConfig } from "../../../lib/adminApi"; +import { + createIdpConfigForClient, + listIdpConfigsForClient, +} from "../../../lib/devApi"; +import type { IdpConfigCreateRequest, IdpConfig } from "../../../lib/devApi"; import { useState } from "react"; // Proper Modal Component with Form const CreateIdpModal = ({ isOpen, onClose, - tenantId, + clientId, }: { isOpen: boolean; onClose: () => void; - tenantId: string; + clientId: string; }) => { const queryClient = useQueryClient(); const [formData, setFormData] = useState({ - tenant_id: tenantId, + client_id: clientId, provider_type: "oidc", display_name: "", status: "active", issuer_url: "", - client_id: "", - client_secret: "", + oidc_client_id: "", + oidc_client_secret: "", scopes: "openid email profile", }); const mutation = useMutation({ - mutationFn: (newData: IdpConfigCreateRequest) => createIdpConfig(newData), + mutationFn: (newData: IdpConfigCreateRequest) => + createIdpConfigForClient(newData), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["idpConfigs", tenantId] }); + queryClient.invalidateQueries({ queryKey: ["idpConfigs", clientId] }); onClose(); }, onError: (error) => { @@ -99,8 +103,8 @@ const CreateIdpModal = ({ (); +export function ClientFederationPage() { + const { id: clientId } = useParams<{ id: string }>(); const [isCreateModalOpen, setCreateModalOpen] = useState(false); - if (!tenantId) { - return
Tenant ID is missing
; + if (!clientId) { + return
Client ID is missing
; } const { data, isLoading, error } = useQuery({ - queryKey: ["idpConfigs", tenantId], - queryFn: () => listIdpConfigsForTenant(tenantId), + queryKey: ["idpConfigs", clientId], + queryFn: () => listIdpConfigsForClient(clientId), }); return (

Identity Federation Settings

- Manage external identity providers for this tenant. + Manage external identity providers for this application.

@@ -191,7 +195,7 @@ export function TenantFederationPage() { setCreateModalOpen(false)} - tenantId={tenantId} + clientId={clientId} /> {isLoading &&
Loading configurations...
} diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index 2e95ae7b..794809f8 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -59,6 +59,37 @@ export type ConsentListResponse = { items: ConsentSummary[]; }; +// --- Federation / IdP Config Types --- +export type ProviderType = "oidc" | "saml"; + +export type IdpConfig = { + id: string; + client_id: string; // Changed from tenant_id + provider_type: ProviderType; + display_name: string; + status: "active" | "inactive"; + issuer_url?: string; + // OIDC specific fields + oidc_client_id?: string; + oidc_client_secret?: string; + scopes?: string; + // SAML specific fields + metadata_url?: string; + metadata_xml?: string; + entity_id?: string; + acs_url?: string; + createdAt: string; + updatedAt: string; +}; + +export type IdpConfigCreateRequest = Omit< + IdpConfig, + "id" | "createdAt" | "updatedAt" +>; +export type IdpConfigUpdateRequest = Partial; +// --- End Federation Types --- + + export async function fetchClients() { const { data } = await apiClient.get("/clients"); return data; @@ -123,3 +154,36 @@ export async function revokeConsent(subject: string, clientId?: string) { } await apiClient.delete("/consents", { params }); } + +// --- Federation / IdP Config API Calls --- + +export async function listIdpConfigsForClient(clientId: string) { + const { data } = await apiClient.get( + `/clients/${clientId}/idps`, + ); + return data; +} + +export async function createIdpConfigForClient(payload: IdpConfigCreateRequest) { + const { data } = await apiClient.post( + `/clients/${payload.client_id}/idps`, + payload, + ); + return data; +} + +export async function updateIdpConfig( + clientId: string, + idpId: string, + payload: IdpConfigUpdateRequest, +) { + const { data } = await apiClient.put( + `/clients/${clientId}/idps/${idpId}`, + payload, + ); + return data; +} + +export async function deleteIdpConfig(clientId: string, idpId: string) { + await apiClient.delete(`/clients/${clientId}/idps/${idpId}`); +}