1
0
forked from baron/baron-sso

IdP 연동 기능 devfront 이전 및 클라이언트 종속으로 개편

This commit is contained in:
2026-01-29 14:54:38 +09:00
parent 59a5f99fb9
commit 3e2ceff692
6 changed files with 161 additions and 44 deletions

View File

@@ -476,15 +476,14 @@ func main() {
admin.Delete("/api-keys/:id", apiKeyHandler.DeleteApiKey) admin.Delete("/api-keys/:id", apiKeyHandler.DeleteApiKey)
// 개발자 포털 라우트 (RP/Consent 관리) // 개발자 포털 라우트 (RP/Consent 관리)
dev := api.Group("/dev") api.Get("/clients", devHandler.ListClients)
dev.Get("/clients", devHandler.ListClients) api.Post("/clients", devHandler.CreateClient)
dev.Post("/clients", devHandler.CreateClient) api.Get("/clients/:id", devHandler.GetClient)
dev.Get("/clients/:id", devHandler.GetClient) api.Put("/clients/:id", devHandler.UpdateClient)
dev.Put("/clients/:id", devHandler.UpdateClient) api.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus) api.Delete("/clients/:id", devHandler.DeleteClient)
dev.Delete("/clients/:id", devHandler.DeleteClient) api.Get("/consents", devHandler.ListConsents)
dev.Get("/consents", devHandler.ListConsents) api.Delete("/consents", devHandler.RevokeConsents)
dev.Delete("/consents", devHandler.RevokeConsents)
// Webhook for Descope Generic SMS Gateway // Webhook for Descope Generic SMS Gateway
auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay) auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay)

View File

@@ -18,16 +18,15 @@ const (
// IdentityProviderConfig stores the configuration for an external Identity Provider. // IdentityProviderConfig stores the configuration for an external Identity Provider.
type IdentityProviderConfig struct { type IdentityProviderConfig struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
TenantID string `gorm:"type:uuid;not null;index" json:"tenant_id"` ClientID string `gorm:"type:uuid;not null;index" json:"client_id"` // Replaces TenantID
Tenant Tenant `gorm:"foreignKey:TenantID" json:"-"` // Belongs to Tenant
ProviderType ProviderType `gorm:"type:varchar(10);not null" json:"provider_type"` ProviderType ProviderType `gorm:"type:varchar(10);not null" json:"provider_type"`
DisplayName string `gorm:"not null" json:"display_name"` DisplayName string `gorm:"not null" json:"display_name"`
Status string `gorm:"default:'active'" json:"status"` Status string `gorm:"default:'active'" json:"status"`
// OIDC Specific Fields // OIDC Specific Fields
IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"` IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"`
ClientID *string `gorm:"null" json:"client_id,omitempty"` OIDCClientID *string `gorm:"null" json:"oidc_client_id,omitempty"` // Renamed from ClientID
ClientSecret *string `gorm:"null" json:"client_secret,omitempty"` OIDCClientSecret *string `gorm:"null" json:"oidc_client_secret,omitempty"` // Renamed from ClientSecret
// Scopes are space-separated // Scopes are space-separated
Scopes *string `gorm:"null" json:"scopes,omitempty"` Scopes *string `gorm:"null" json:"scopes,omitempty"`

View File

@@ -62,6 +62,55 @@ func (h *FederationHandler) HandleOIDCCallback(c *fiber.Ctx) error {
return c.Redirect(redirectURL, fiber.StatusFound) 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. // ListIdpConfigsForTenant handles listing all IdP configurations for a tenant.
func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { 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. // This is a temporary solution. We should create a proper method in the repository.
var configs []domain.IdentityProviderConfig 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 { 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.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"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
} }
// Basic validation // Basic validation - This is the old validation logic
if req.TenantID == "" || req.DisplayName == "" || req.ProviderType == "" { if req.ClientID == "" || req.DisplayName == "" || req.ProviderType == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id, display_name, and provider_type are required"}) 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 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant not found"}) 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) 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

View File

@@ -28,7 +28,7 @@ func (s *FederationService) InitiateOIDCLogin(ctx context.Context, providerID, l
return "", fmt.Errorf("failed to find provider: %w", err) 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) 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{ config := oauth2.Config{
ClientID: *provider.ClientID, ClientID: *provider.OIDCClientID,
ClientSecret: *provider.ClientSecret, ClientSecret: *provider.OIDCClientSecret,
Endpoint: oidcProvider.Endpoint(), Endpoint: oidcProvider.Endpoint(),
RedirectURL: "http://localhost:8080/api/v1/federation/oidc/callback", // This should be configurable RedirectURL: "http://localhost:8080/api/v1/federation/oidc/callback", // This should be configurable
Scopes: []string{*provider.Scopes}, Scopes: []string{*provider.Scopes},

View File

@@ -1,35 +1,39 @@
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createIdpConfig, listIdpConfigsForTenant } from "../../../lib/adminApi"; import {
import type { IdpConfigCreateRequest, IdpConfig } from "../../../lib/adminApi"; createIdpConfigForClient,
listIdpConfigsForClient,
} from "../../../lib/devApi";
import type { IdpConfigCreateRequest, IdpConfig } from "../../../lib/devApi";
import { useState } from "react"; import { useState } from "react";
// Proper Modal Component with Form // Proper Modal Component with Form
const CreateIdpModal = ({ const CreateIdpModal = ({
isOpen, isOpen,
onClose, onClose,
tenantId, clientId,
}: { }: {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
tenantId: string; clientId: string;
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [formData, setFormData] = useState<IdpConfigCreateRequest>({ const [formData, setFormData] = useState<IdpConfigCreateRequest>({
tenant_id: tenantId, client_id: clientId,
provider_type: "oidc", provider_type: "oidc",
display_name: "", display_name: "",
status: "active", status: "active",
issuer_url: "", issuer_url: "",
client_id: "", oidc_client_id: "",
client_secret: "", oidc_client_secret: "",
scopes: "openid email profile", scopes: "openid email profile",
}); });
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (newData: IdpConfigCreateRequest) => createIdpConfig(newData), mutationFn: (newData: IdpConfigCreateRequest) =>
createIdpConfigForClient(newData),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["idpConfigs", tenantId] }); queryClient.invalidateQueries({ queryKey: ["idpConfigs", clientId] });
onClose(); onClose();
}, },
onError: (error) => { onError: (error) => {
@@ -99,8 +103,8 @@ const CreateIdpModal = ({
</label> </label>
<input <input
type="text" type="text"
name="client_id" name="oidc_client_id"
value={formData.client_id} value={formData.oidc_client_id}
onChange={handleChange} onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
required required
@@ -114,8 +118,8 @@ const CreateIdpModal = ({
</label> </label>
<input <input
type="password" type="password"
name="client_secret" name="oidc_client_secret"
value={formData.client_secret} value={formData.oidc_client_secret}
onChange={handleChange} onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
required required
@@ -159,24 +163,24 @@ const CreateIdpModal = ({
); );
}; };
export function TenantFederationPage() { export function ClientFederationPage() {
const { tenantId } = useParams<{ tenantId: string }>(); const { id: clientId } = useParams<{ id: string }>();
const [isCreateModalOpen, setCreateModalOpen] = useState(false); const [isCreateModalOpen, setCreateModalOpen] = useState(false);
if (!tenantId) { if (!clientId) {
return <div>Tenant ID is missing</div>; return <div>Client ID is missing</div>;
} }
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["idpConfigs", tenantId], queryKey: ["idpConfigs", clientId],
queryFn: () => listIdpConfigsForTenant(tenantId), queryFn: () => listIdpConfigsForClient(clientId),
}); });
return ( return (
<div className="p-4"> <div className="p-4">
<h1 className="text-2xl font-bold mb-4">Identity Federation Settings</h1> <h1 className="text-2xl font-bold mb-4">Identity Federation Settings</h1>
<p className="mb-4 text-gray-600"> <p className="mb-4 text-gray-600">
Manage external identity providers for this tenant. Manage external identity providers for this application.
</p> </p>
<div className="mb-4"> <div className="mb-4">
@@ -191,7 +195,7 @@ export function TenantFederationPage() {
<CreateIdpModal <CreateIdpModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setCreateModalOpen(false)} onClose={() => setCreateModalOpen(false)}
tenantId={tenantId} clientId={clientId}
/> />
{isLoading && <div>Loading configurations...</div>} {isLoading && <div>Loading configurations...</div>}

View File

@@ -59,6 +59,37 @@ export type ConsentListResponse = {
items: ConsentSummary[]; 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<IdpConfigCreateRequest>;
// --- End Federation Types ---
export async function fetchClients() { export async function fetchClients() {
const { data } = await apiClient.get<ClientListResponse>("/clients"); const { data } = await apiClient.get<ClientListResponse>("/clients");
return data; return data;
@@ -123,3 +154,36 @@ export async function revokeConsent(subject: string, clientId?: string) {
} }
await apiClient.delete("/consents", { params }); await apiClient.delete("/consents", { params });
} }
// --- Federation / IdP Config API Calls ---
export async function listIdpConfigsForClient(clientId: string) {
const { data } = await apiClient.get<IdpConfig[]>(
`/clients/${clientId}/idps`,
);
return data;
}
export async function createIdpConfigForClient(payload: IdpConfigCreateRequest) {
const { data } = await apiClient.post<IdpConfig>(
`/clients/${payload.client_id}/idps`,
payload,
);
return data;
}
export async function updateIdpConfig(
clientId: string,
idpId: string,
payload: IdpConfigUpdateRequest,
) {
const { data } = await apiClient.put<IdpConfig>(
`/clients/${clientId}/idps/${idpId}`,
payload,
);
return data;
}
export async function deleteIdpConfig(clientId: string, idpId: string) {
await apiClient.delete(`/clients/${clientId}/idps/${idpId}`);
}