1
0
forked from baron/baron-sso

Merge pull request 'dev/ory-hydra2' (#138) from dev/ory-hydra2 into main

Reviewed-on: ai-team/baron-sso#138
This commit is contained in:
2026-01-30 12:27:20 +09:00
31 changed files with 1520 additions and 460 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# General
.env
.temp
.DS_Store
.idea/
.vscode/

View File

@@ -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";

View File

@@ -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();

View File

@@ -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 (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/tenants" className="inline-flex items-center gap-2">
<ArrowLeft size={14} />
Tenants
</Link>
<span>/</span>
<span className="text-foreground">Detail</span>
</div>
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ?? "Loading Tenant..."}
</h2>
<p className="text-sm text-[var(--color-muted)]">
Edit tenant information or manage federation settings.
</p>
</div>
<Badge variant="muted">Admin only</Badge>
</header>
{/* Tabs */}
<div className="flex border-b">
<Link
to={`/tenants/${tenantId}`}
className={`px-4 py-2 text-sm font-medium ${
!isFederationTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Profile
</Link>
<Link
to={`/tenants/${tenantId}/federation`}
className={`px-4 py-2 text-sm font-medium ${
isFederationTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Federation
</Link>
</div>
{/* Outlet for nested routes */}
<Outlet />
</div>
);
}
export default TenantDetailPage;

View File

@@ -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();

View File

@@ -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 <div>Tenant ID is missing</div>;
}
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 (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/tenants" className="inline-flex items-center gap-2">
<ArrowLeft size={14} />
Tenants
</Link>
<span>/</span>
<span className="text-foreground">Detail</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
.
</p>
</div>
<Badge variant="muted">Admin only</Badge>
</header>
<Card className="bg-[var(--color-panel)]">
<>
<Card className="bg-[var(--color-panel)] mt-6">
<CardHeader>
<CardTitle>Tenant profile</CardTitle>
<CardDescription>Slug와 .</CardDescription>
<CardDescription>
Changes to slug and status are applied immediately.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loadError && (
@@ -143,7 +130,6 @@ function TenantDetailPage() {
</Button>
</div>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
@@ -152,18 +138,18 @@ function TenantDetailPage() {
</CardContent>
</Card>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="mt-8 flex flex-wrap items-center justify-between gap-3">
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
Delete
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => navigate("/tenants")}>
Cancel
</Button>
<Button
onClick={() => updateMutation.mutate()}
@@ -174,12 +160,10 @@ function TenantDetailPage() {
}
>
<Save size={16} />
Save
</Button>
</div>
</div>
</div>
</>
);
}
export default TenantDetailPage;

View File

@@ -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)

View File

@@ -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

View File

@@ -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=

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"`

View File

@@ -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

View File

@@ -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: <ClientDetailsPage /> },
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
{ path: "clients/:id/federation", element: <ClientFederationPage /> },
],
},
],

View File

@@ -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
</Link>
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
Consent &amp; Users
@@ -120,6 +120,12 @@ function ClientConsentsPage() {
>
Settings
</Link>
<Link
to={`/clients/${clientId}/federation`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
Federation
</Link>
</div>
</header>

View File

@@ -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 <div className="p-8 text-center">Client ID가 .</div>;
}
@@ -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 (
<div className="space-y-8">
<div className="space-y-3">
@@ -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
</Link>
<Link
to={`/clients/${clientId}/consents`}
@@ -95,121 +130,168 @@ function ClientDetailsPage() {
>
Settings
</Link>
<Link
to={`/clients/${clientId}/federation`}
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
>
Federation
</Link>
</div>
</div>
<div className="space-y-4">
<h2 className="text-xl font-bold"> </h2>
<Card className="glass-panel">
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Client ID
</p>
<p className="font-mono text-lg">{data.client.id}</p>
</div>
<Button variant="secondary" className="gap-2">
<Copy className="h-4 w-4" />
ID
</Button>
</CardContent>
</Card>
<Card className="glass-panel">
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Client Secret
</p>
<p className="font-mono text-lg tracking-widest">
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" className="gap-2">
<Eye className="h-4 w-4" />
</Button>
<Button variant="secondary" className="gap-2">
<Copy className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="gap-2 border-amber-500/50 text-amber-500"
>
<AlertCircle className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">OIDC </h2>
<Badge variant="muted" className="gap-1">
<Link2 className="h-3 w-3" />
</Badge>
</div>
<Card className="glass-panel">
<Table>
<TableBody>
{endpoints.map((endpoint) => (
<TableRow key={endpoint.label} className="border-border/70">
<TableCell className="w-1/3">
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
{endpoint.label}
</p>
</TableCell>
<TableCell className="flex items-center justify-between gap-3">
<span className="break-all font-mono text-sm">
{endpoint.value}
</span>
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
aria-label={`${endpoint.label} 복사`}
>
<div className="grid gap-8 lg:grid-cols-2">
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-bold"> </h2>
<Card className="glass-panel">
<CardContent className="flex flex-col gap-4 p-6">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Client ID
</p>
<div className="flex items-center justify-between gap-2">
<p className="font-mono text-lg truncate">{data.client.id}</p>
<Button variant="secondary" size="icon" className="shrink-0" onClick={() => navigator.clipboard.writeText(data.client.id)}>
<Copy className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
</div>
</div>
<div className="glass-panel p-6 opacity-80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
<Shield className="h-6 w-6" />
</div>
<div>
<p className="text-lg font-semibold"> </p>
<p className="text-sm text-muted-foreground">
, /
.
</p>
</div>
<Separator />
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Client Secret
</p>
<div className="flex items-center justify-between gap-2">
<p className={cn(
"font-mono text-lg",
!showSecret && "tracking-widest"
)}>
{showSecret ? clientSecret : "••••••••••••••••"}
</p>
<div className="flex gap-2 shrink-0">
<Button
variant="secondary"
size="icon"
onClick={() => setShowSecret(!showSecret)}
aria-label={showSecret ? "비밀키 숨기기" : "비밀키 보기"}
>
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="secondary"
size="icon"
onClick={() => navigator.clipboard.writeText(clientSecret)}
disabled={!showSecret && clientSecret === "SECRET_NOT_AVAILABLE"}
>
<Copy className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="border-amber-500/50 text-amber-500">
<AlertCircle className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="hidden items-center gap-2 md:flex">
<Badge variant="outline" className="gap-1">
<Workflow className="h-4 w-4" />
</Badge>
<div className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">OIDC </h2>
<Badge variant="muted" className="gap-1">
<Link2 className="h-3 w-3" />
</Badge>
</div>
<Card className="glass-panel">
<Table>
<TableBody>
{endpoints.map((endpoint) => (
<TableRow key={endpoint.label} className="border-border/70">
<TableCell className="w-1/3">
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
{endpoint.label}
</p>
</TableCell>
<TableCell className="flex items-center justify-between gap-3">
<span className="break-all font-mono text-sm">
{endpoint.value}
</span>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => navigator.clipboard.writeText(endpoint.value)}
>
<Copy className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
</div>
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-bold"> URI </h2>
<Card className="glass-panel border-primary/20">
<CardHeader>
<CardTitle className="text-lg">Redirect URIs</CardTitle>
<CardDescription>
URL . (,) .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="redirect-uris" className="text-sm font-semibold"> URL</Label>
<Textarea
id="redirect-uris"
placeholder="https://your-app.com/callback, http://localhost:3000/auth/callback"
rows={5}
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
className="font-mono text-sm"
/>
</div>
<Button
className="w-full gap-2"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
<Save className="h-4 w-4" />
{mutation.isPending ? "저장 중..." : "Redirect URIs 저장"}
</Button>
</CardContent>
</Card>
</div>
<div className="glass-panel p-6 opacity-80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
<Shield className="h-6 w-6" />
</div>
<div>
<p className="text-lg font-semibold"> </p>
<p className="text-sm text-muted-foreground">
, /
.
</p>
</div>
</div>
</div>
<Separator className="my-4" />
<p className="text-sm text-muted-foreground">
TTL ,
.
</p>
</div>
</div>
<Separator className="my-4" />
<p className="text-sm text-muted-foreground">
TTL ,
.
</p>
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Info, Search, Shield, Sparkles, Upload } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Info, Search, Shield, Sparkles, Upload, Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
@@ -16,10 +16,18 @@ import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Separator } from "../../components/ui/separator";
import { Textarea } from "../../components/ui/textarea";
import { Switch } from "../../components/ui/switch";
import { createClient, fetchClient, updateClient } from "../../lib/devApi";
import type { ClientStatus, ClientType } from "../../lib/devApi";
import { cn } from "../../lib/utils";
interface ScopeItem {
id: string;
name: string;
description: string;
mandatory: boolean;
}
function ClientGeneralPage() {
const params = useParams();
const navigate = useNavigate();
@@ -38,59 +46,73 @@ function ClientGeneralPage() {
const [clientType, setClientType] = useState<ClientType>("confidential");
const [status, setStatus] = useState<ClientStatus>("active");
const [redirectUris, setRedirectUris] = useState("");
const [scopes, setScopes] = useState("openid profile email");
const [scopes, setScopes] = useState<ScopeItem[]>([
{ id: "1", name: "openid", description: "OIDC 인증 필수 스코프", mandatory: true },
{ id: "2", name: "profile", description: "기본 프로필 정보 접근", mandatory: false },
{ id: "3", name: "email", description: "이메일 주소 접근", mandatory: false },
]);
useEffect(() => {
if (!data) {
return;
if (!data) return;
const { client } = data;
setName(client.name || client.id);
setClientType(client.type);
setStatus(client.status);
const metadata = client.metadata ?? {};
if (typeof metadata.description === "string") setDescription(metadata.description);
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
// Metadata에 저장된 구조화된 scope 정보가 있으면 사용, 없으면 기본 scopes 문자열에서 생성
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
if (savedScopes && Array.isArray(savedScopes)) {
setScopes(savedScopes);
}
setName(data.client.name || data.client.id);
setClientType(data.client.type);
setStatus(data.client.status);
setRedirectUris(data.client.redirectUris.join(", "));
setScopes(data.client.scopes.join(" "));
const metadata = data.client.metadata ?? {};
if (typeof metadata.description === "string") {
setDescription(metadata.description);
}
if (typeof metadata.logo_url === "string") {
setLogoUrl(metadata.logo_url);
else {
setScopes(client.scopes.map((s, idx) => ({
id: String(idx + 1),
name: s,
description: "",
mandatory: s === "openid"
})));
}
}, [data]);
const redirectUriList = useMemo(
() =>
redirectUris
.split(",")
.map((value) => value.trim())
.filter(Boolean),
[redirectUris],
);
const scopeList = useMemo(
() =>
scopes
.split(/[,\s]+/)
.map((value) => value.trim())
.filter(Boolean),
[scopes],
);
const addScope = () => {
const newId = String(Date.now());
setScopes([...scopes, { id: newId, name: "", description: "", mandatory: false }]);
};
const updateScope = (id: string, field: keyof ScopeItem, value: any) => {
setScopes(scopes.map(s => s.id === id ? { ...s, [field]: value } : s));
};
const removeScope = (id: string) => {
setScopes(scopes.filter(s => s.id !== id));
};
const mutation = useMutation({
mutationFn: async () => {
const payload = {
const scopeNames = scopes.map(s => s.name).filter(Boolean);
const payload: any = {
name,
type: clientType,
status,
redirectUris: redirectUriList,
scopes: scopeList,
scopes: scopeNames,
metadata: {
description,
logo_url: logoUrl,
structured_scopes: scopes // 향후 보존을 위해 metadata에 저장
},
};
// 생성 시에는 Redirect URIs를 포함해서 전송
if (isCreate) {
payload.redirectUris = redirectUris.split(",").map(u => u.trim()).filter(Boolean);
return createClient(payload);
}
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하므로 제외 (빈 배열이나 undefined로 보내지 않음)
return updateClient(clientId as string, payload);
},
onSuccess: (result) => {
@@ -98,29 +120,17 @@ function ClientGeneralPage() {
if (result?.client?.id) {
navigate(`/clients/${result.client.id}/settings`);
}
alert("설정이 저장되었습니다.");
},
});
if (!isCreate && isLoading) {
return <div className="p-8 text-center">Loading client...</div>;
}
if (!isCreate && isLoading) return <div className="p-8 text-center">Loading client...</div>;
if (!isCreate && (error || !data)) {
const errMsg =
(error as AxiosError<{ error?: string }>).response?.data?.error ??
(error as Error)?.message;
return (
<div className="p-8 text-center text-red-500">
Error loading client: {errMsg || "unknown error"}
</div>
);
const errMsg = (error as AxiosError<{ error?: string }>).response?.data?.error ?? (error as Error)?.message;
return <div className="p-8 text-center text-red-500">Error loading client: {errMsg || "unknown error"}</div>;
}
const displayName = isCreate
? "새 클라이언트"
: data?.client?.name || data?.client?.id;
const createdAt = data?.client?.createdAt;
const updatedAt = undefined;
const displayName = isCreate ? "새 클라이언트" : data?.client?.name || data?.client?.id;
return (
<div className="space-y-8">
@@ -128,246 +138,171 @@ function ClientGeneralPage() {
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/clients" className="text-primary hover:underline">
Applications
</Link>
<Link to="/clients" className="text-primary hover:underline">Applications</Link>
<span>/</span>
<span className="text-foreground">{displayName}</span>
</div>
<div>
<p className="text-3xl font-black leading-tight">
{isCreate ? "Create Client" : "Client Details"}
</p>
<p className="text-muted-foreground">
RP .
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge
variant={status === "active" ? "success" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{status === "active" ? "Active" : "Inactive"}
</Badge>
<h1 className="text-3xl font-black leading-tight">{isCreate ? "Create Client" : "Client Settings"}</h1>
</div>
<Badge variant={status === "active" ? "success" : "muted"} className="px-3 py-1 text-xs uppercase">
{status === "active" ? "Active" : "Inactive"}
</Badge>
</div>
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
{!isCreate && (
<>
<Link
to={`/clients/${clientId}`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
Overview
</Link>
<Link
to={`/clients/${clientId}/consents`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
Consent &amp; Users
</Link>
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
Settings
</span>
<Link to={`/clients/${clientId}`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Connection</Link>
<Link to={`/clients/${clientId}/consents`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Consent &amp; Users</Link>
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">Settings</span>
<Link to={`/clients/${clientId}/federation`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Federation</Link>
</>
)}
</div>
</header>
{/* 1. Application Identity */}
<div className="glass-panel p-6">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div>
<CardTitle className="text-xl font-bold">
Application Identity
</CardTitle>
<CardDescription>
, . * .
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="flex h-10 items-center rounded-lg border border-input bg-secondary/50 px-3 text-sm text-muted-foreground">
<Search className="mr-2 h-4 w-4" />
Search
</div>
<div className="h-10 w-10 overflow-hidden rounded-full border border-border bg-muted/40">
<img
className="h-full w-full object-cover"
alt="앱 로고"
src="https://lh3.googleusercontent.com/aida-public/AB6AXuBFGWfyQ8ZzHXZmha91pG-09N58hcUap10-bU30aIf_CpfOqm8fPIv6j2v_BVGaJMF2gABxv_hnEXUCBvmjZeFpr-c76uC1QQkgMwsdkc2Im0gqS5X1c8sCWLZudDydZo5m7XW-QW1nRSZHYE5XzTqrW2ITgruSa7eC2Oe9RtxeVFCrqcHw3RO3h0WLtyJ8yhkkeZrAyBc4UQtpcL5bhBDSdlUNgw0odf12Mk6oNojf7Rcg4HPnywh6C-mUtJd-UfX7Y3Yv_W704T1a"
/>
</div>
</div>
</div>
<div className="grid gap-8 pt-6 md:grid-cols-2">
<CardTitle className="text-xl font-bold mb-2">Application Identity</CardTitle>
<CardDescription className="mb-6"> , .</CardDescription>
<div className="grid gap-8 md:grid-cols-2">
<div className="space-y-5">
<div className="space-y-2">
<Label className="flex items-center gap-1 text-sm font-semibold">
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Label className="text-sm font-semibold"> <span className="text-destructive">*</span></Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="My Awesome Application" />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Textarea rows={3} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="앱에 대한 간단한 설명을 입력하세요." />
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">App Logo URL</Label>
<div className="flex gap-4">
<div className="flex-1 space-y-2">
<Input
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
PNG/SVG URL을 .
</p>
<Input value={logoUrl} onChange={(e) => setLogoUrl(e.target.value)} placeholder="https://example.com/logo.png" />
<p className="text-xs text-muted-foreground"> PNG/SVG URL입니다.</p>
</div>
<div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-border bg-muted/40">
<Upload className="h-5 w-5 text-muted-foreground" />
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
{logoUrl ? <img src={logoUrl} alt="Logo Preview" className="h-full w-full object-contain" /> : <Upload className="h-5 w-5 text-muted-foreground" />}
</div>
</div>
</div>
</div>
</div>
{/* 2. Scopes (Moved up and upgraded) */}
<Card className="glass-panel">
<CardHeader className="pb-3">
<CardTitle className="text-xl font-bold"> </CardTitle>
<CardDescription>
.
Public을 .
</CardDescription>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div>
<CardTitle className="text-xl font-bold">Scopes</CardTitle>
<CardDescription> .</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={addScope} className="gap-2">
<Plus className="h-4 w-4" /> Scope
</Button>
</CardHeader>
<CardContent className="space-y-4">
<Label className="flex items-center gap-2 text-base font-semibold">
Client Type
<Info className="h-4 w-4 text-muted-foreground" />
</Label>
<div className="grid gap-4 md:grid-cols-2">
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
clientType === "confidential"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
>
<input
className="sr-only"
type="radio"
name="client-type"
checked={clientType === "confidential"}
onChange={() => setClientType("confidential")}
<CardContent className="space-y-6">
{/* Create 모드일 때만 Redirect URIs 입력 필드 표시 */}
{isCreate && (
<div className="space-y-2 border-b border-border pb-6 mb-6">
<Label className="text-sm font-semibold">Redirect URIs <span className="text-destructive">*</span></Label>
<Textarea
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
placeholder="https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)"
className="font-mono text-sm"
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Shield className="h-4 w-4 text-primary" />
Confidential
</span>
<span className="text-sm text-muted-foreground">
(: Node.js, Java)
.
</span>
<span className="absolute right-4 top-4 text-primary"></span>
</label>
<p className="text-xs text-muted-foreground"> URI를 . Connection .</p>
</div>
)}
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
clientType === "public"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
>
<input
className="sr-only"
type="radio"
name="client-type"
checked={clientType === "public"}
onChange={() => setClientType("public")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Sparkles className="h-4 w-4" />
Public
</span>
<span className="text-sm text-muted-foreground">
SPA/ . PKCE를
.
</span>
</label>
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-bold">Scope Name</th>
<th className="px-4 py-3 text-left font-bold">Description</th>
<th className="px-4 py-3 text-center font-bold">Mandatory</th>
<th className="px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{scopes.map((s) => (
<tr key={s.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<Input value={s.name} onChange={(e) => updateScope(s.id, "name", e.target.value)} className="h-8 font-mono text-xs" placeholder="e.g. profile" />
</td>
<td className="px-4 py-3">
<Input value={s.description} onChange={(e) => updateScope(s.id, "description", e.target.value)} className="h-8 text-xs" placeholder="권한에 대한 설명" />
</td>
<td className="px-4 py-3 text-center">
<div className="flex justify-center">
<Switch checked={s.mandatory} onCheckedChange={(checked) => updateScope(s.id, "mandatory", checked)} />
</div>
</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="icon" onClick={() => removeScope(s.id)} className="h-8 w-8 text-muted-foreground hover:text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
{scopes.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground"> .</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* 3. Security Settings (Moved down) */}
<Card className="glass-panel">
<CardHeader className="pb-3">
<CardTitle className="text-xl font-bold">
Redirect URIs & Scopes
</CardTitle>
<CardDescription>
URI는 .
.
</CardDescription>
<CardTitle className="text-xl font-bold"> </CardTitle>
<CardDescription> . .</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">Redirect URIs *</Label>
<Input
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
placeholder="https://app.example.com/callback, https://app.example.com/redirect"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Scopes</Label>
<Input
value={scopes}
onChange={(e) => setScopes(e.target.value)}
placeholder="openid profile email"
/>
<div className="grid gap-4 md:grid-cols-2">
<label className={cn("relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition", clientType === "confidential" ? "border-primary bg-primary/5" : "border-border bg-card hover:border-muted-foreground/40")}>
<input className="sr-only" type="radio" name="client-type" checked={clientType === "confidential"} onChange={() => setClientType("confidential")} />
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Shield className="h-4 w-4 text-primary" /> Confidential
</span>
<span className="text-xs text-muted-foreground"> (: Node.js, Java) .</span>
<span className="absolute right-4 top-4 text-primary">{clientType === "confidential" ? "✓" : ""}</span>
</label>
<label className={cn("relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition", clientType === "public" ? "border-primary bg-primary/5" : "border-border bg-card hover:border-muted-foreground/40")}>
<input className="sr-only" type="radio" name="client-type" checked={clientType === "public"} onChange={() => setClientType("public")} />
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Sparkles className="h-4 w-4" /> Public
</span>
<span className="text-xs text-muted-foreground">SPA/ . PKCE를 .</span>
<span className="absolute right-4 top-4 text-primary">{clientType === "public" ? "✓" : ""}</span>
</label>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3 border-t border-border pt-4">
<Button variant="outline" onClick={() => navigate("/clients")}>
</Button>
<Button onClick={() => mutation.mutate()} disabled={mutation.isLoading}>
{isCreate ? "생성" : "저장"}
<Button variant="outline" onClick={() => navigate("/clients")}></Button>
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending} className="px-8 shadow-lg shadow-primary/20">
{mutation.isPending ? "저장 중..." : (isCreate ? "클라이언트 생성" : "설정 저장")}
</Button>
</div>
{!isCreate && (
<div className="glass-panel flex flex-wrap gap-x-12 gap-y-4 p-4">
<div className="glass-panel flex flex-wrap gap-x-12 gap-y-4 p-4 opacity-70">
<div className="space-y-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Client ID
</span>
<span className="font-mono text-sm">{data?.client?.id}</span>
<span className="text-xs font-semibold uppercase text-muted-foreground">Client ID</span>
<span className="font-mono text-sm block">{data?.client?.id}</span>
</div>
<div className="space-y-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Created On
</span>
<span className="text-sm text-muted-foreground">
{createdAt ? new Date(createdAt).toLocaleString() : "-"}
</span>
</div>
<div className="space-y-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Last Updated
</span>
<span className="text-sm text-muted-foreground">
{updatedAt ? new Date(updatedAt).toLocaleString() : "-"}
</span>
<span className="text-xs font-semibold uppercase text-muted-foreground">Created On</span>
<span className="text-sm text-muted-foreground block">{data?.client?.created_at ? new Date(data.client.created_at).toLocaleString() : "-"}</span>
</div>
</div>
)}

View File

@@ -284,16 +284,15 @@ function ClientsPage() {
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to={`/clients/${client.id}`}></Link>
<Link to={`/clients/${client.id}`}>Edit</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
aria-label="Delete client"
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteMutation.mutate(client.id)}
>
<Activity className="h-4 w-4" />
Delete
</Button>
</div>
</TableCell>

View File

@@ -0,0 +1,263 @@
import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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,
clientId,
}: {
isOpen: boolean;
onClose: () => void;
clientId: string;
}) => {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<IdpConfigCreateRequest>({
client_id: clientId,
provider_type: "oidc",
display_name: "",
status: "active",
issuer_url: "",
oidc_client_id: "",
oidc_client_secret: "",
scopes: "openid email profile",
});
const mutation = useMutation({
mutationFn: (newData: IdpConfigCreateRequest) =>
createIdpConfigForClient(newData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["idpConfigs", clientId] });
onClose();
},
onError: (error) => {
// Basic error handling
alert(`Failed to create configuration: ${error.message}`);
},
});
// 이 내용으로 교체해주세요
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-xl w-full max-w-lg">
<h2 className="text-xl font-bold mb-4">Add New IdP Configuration</h2>
<form onSubmit={handleSubmit}>
{/* Display Name */}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Display Name
</label>
<input
type="text"
name="display_name"
value={formData.display_name}
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"
required
/>
</div>
{/* Issuer URL */}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Issuer URL
</label>
<input
type="url"
name="issuer_url"
value={formData.issuer_url}
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"
placeholder="https://accounts.google.com"
required
/>
</div>
{/* Client ID */}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Client ID
</label>
<input
type="text"
name="oidc_client_id"
value={formData.oidc_client_id}
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"
required
/>
</div>
{/* Client Secret */}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Client Secret
</label>
<input
type="password"
name="oidc_client_secret"
value={formData.oidc_client_secret}
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"
required
/>
</div>
{/* Scopes */}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Scopes
</label>
<input
type="text"
name="scopes"
value={formData.scopes}
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"
/>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end">
<button
type="button"
onClick={onClose}
className="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded mr-2"
>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50"
>
{mutation.isPending ? "Saving..." : "Save Configuration"}
</button>
</div>
</form>
</div>
</div>
);
};
export function ClientFederationPage() {
const { id: clientId } = useParams<{ id: string }>();
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
if (!clientId) {
return <div>Client ID is missing</div>;
}
const { data, isLoading, error } = useQuery({
queryKey: ["idpConfigs", clientId],
queryFn: () => listIdpConfigsForClient(clientId),
});
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Identity Federation Settings</h1>
<p className="mb-4 text-gray-600">
Manage external identity providers for this application.
</p>
<div className="mb-4">
<button
onClick={() => setCreateModalOpen(true)}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
+ Add IdP Configuration
</button>
</div>
<CreateIdpModal
isOpen={isCreateModalOpen}
onClose={() => setCreateModalOpen(false)}
clientId={clientId}
/>
{isLoading && <div>Loading configurations...</div>}
{error && (
<div className="text-red-500">
Failed to load configurations: {error.message}
</div>
)}
{data && (
<div className="overflow-x-auto">
<table className="min-w-full bg-white">
<thead className="bg-gray-200">
<tr>
<th className="py-2 px-4 border-b">Display Name</th>
<th className="py-2 px-4 border-b">Provider Type</th>
<th className="py-2 px-4 border-b">Status</th>
<th className="py-2 px-4 border-b">Actions</th>
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={4} className="text-center py-4">
No IdP configurations found.
</td>
</tr>
) : (
data.map((config: IdpConfig) => (
<tr key={config.id}>
<td className="py-2 px-4 border-b">
{config.display_name}
</td>
<td className="py-2 px-4 border-b">
{config.provider_type.toUpperCase()}
</td>
<td className="py-2 px-4 border-b">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
config.status === "active"
? "bg-green-200 text-green-800"
: "bg-gray-200 text-gray-800"
}`}
>
{config.status}
</span>
</td>
<td className="py-2 px-4 border-b">
<button className="text-blue-500 hover:underline mr-2">
Edit
</button>
<button className="text-red-500 hover:underline">
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -4,7 +4,7 @@ const apiClient = axios.create({
baseURL:
import.meta.env.VITE_DEV_API_BASE ??
import.meta.env.VITE_ADMIN_API_BASE ??
"/api/v1/dev",
"/api/v1",
});
apiClient.interceptors.request.use((config) => {

View File

@@ -59,14 +59,45 @@ 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<IdpConfigCreateRequest>;
// --- End Federation Types ---
export async function fetchClients() {
const { data } = await apiClient.get<ClientListResponse>("/clients");
const { data } = await apiClient.get<ClientListResponse>("/dev/clients");
return data;
}
export async function fetchClient(clientId: string) {
const { data } = await apiClient.get<ClientDetailResponse>(
`/clients/${clientId}`,
`/dev/clients/${clientId}`,
);
return data;
}
@@ -76,7 +107,7 @@ export async function updateClientStatus(
status: ClientStatus,
) {
const { data } = await apiClient.patch<ClientDetailResponse>(
`/clients/${clientId}/status`,
`/dev/clients/${clientId}/status`,
{ status },
);
return data;
@@ -84,7 +115,7 @@ export async function updateClientStatus(
export async function createClient(payload: ClientUpsertRequest) {
const { data } = await apiClient.post<ClientDetailResponse>(
"/clients",
"/dev/clients",
payload,
);
return data;
@@ -95,14 +126,14 @@ export async function updateClient(
payload: ClientUpsertRequest,
) {
const { data } = await apiClient.put<ClientDetailResponse>(
`/clients/${clientId}`,
`/dev/clients/${clientId}`,
payload,
);
return data;
}
export async function deleteClient(clientId: string) {
await apiClient.delete(`/clients/${clientId}`);
await apiClient.delete(`/dev/clients/${clientId}`);
}
export async function fetchConsents(subject: string, clientId?: string) {
@@ -110,7 +141,7 @@ export async function fetchConsents(subject: string, clientId?: string) {
if (clientId) {
params.client_id = clientId;
}
const { data } = await apiClient.get<ConsentListResponse>("/consents", {
const { data } = await apiClient.get<ConsentListResponse>("/dev/consents", {
params,
});
return data;
@@ -121,5 +152,38 @@ export async function revokeConsent(subject: string, clientId?: string) {
if (clientId) {
params.client_id = clientId;
}
await apiClient.delete("/consents", { params });
await apiClient.delete("/dev/consents", { params });
}
// --- Federation / IdP Config API Calls ---
export async function listIdpConfigsForClient(clientId: string) {
const { data } = await apiClient.get<IdpConfig[]>(
`/dev/clients/${clientId}/idps`,
);
return data;
}
export async function createIdpConfigForClient(payload: IdpConfigCreateRequest) {
const { data } = await apiClient.post<IdpConfig>(
`/dev/clients/${payload.client_id}/idps`,
payload,
);
return data;
}
export async function updateIdpConfig(
clientId: string,
idpId: string,
payload: IdpConfigUpdateRequest,
) {
const { data } = await apiClient.put<IdpConfig>(
`/dev/clients/${clientId}/idps/${idpId}`,
payload,
);
return data;
}
export async function deleteIdpConfig(clientId: string, idpId: string) {
await apiClient.delete(`/dev/clients/${clientId}/idps/${idpId}`);
}

View File

@@ -18,6 +18,7 @@ services:
- NAVER_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID}
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
- USERFRONT_URL=${USERFRONT_URL}
- REDIS_ADDR=${REDIS_ADDR}
- IDP_PROVIDER=${IDP_PROVIDER:-ory,descope}
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}

View File

@@ -1,4 +1,5 @@
version: v0.11.0
dsn: ${DSN}
serve:
read:
host: 0.0.0.0
@@ -6,7 +7,9 @@ serve:
write:
host: 0.0.0.0
port: 4467
namespaces:
location: file:///etc/config/keto/namespaces.yml
log:
level: debug

View File

@@ -1,7 +1,6 @@
namespaces:
- id: 0
name: default
- id: 1
name: roles
- id: 2
name: permissions
- id: 0
name: default
- id: 1
name: roles
- id: 2
name: permissions

154
docs/compose-ory.md Normal file
View File

@@ -0,0 +1,154 @@
## 서비스 역할
### 1) `postgres_ory`
- Ory 스택(Kratos/Hydra/Keto)이 공용으로 쓰는 PostgreSQL DB
- `healthcheck`로 DB 준비 상태를 다른 서비스들이 기다릴 수 있게 함
### 2) `kratos-migrate`
- Kratos DB 스키마 마이그레이션을 수행하는 1회성 컨테이너
- Postgres가 healthy가 된 뒤에 실행되고, 성공해야 Kratos가 뜸
### 3) `kratos`
- **인증/회원(Identity) 담당**: 로그인/회원가입/리커버리/검증 등 Self-service flow 제공
- 포트
- `4433`(public): 브라우저/클라이언트가 접근하는 API
- `4434`(admin): 관리용 API(내부에서만 쓰는 게 일반적)
- `--watch-courier`는 이메일/메시지 발송 관련(개발 모드) 흐름을 돕는 옵션
### 4) `kratos-mcp-server` (현재는 `profiles: mcp`)
- Kratos Admin API를 대신 호출해주는 “자동화/툴링(LLM 연동 포함) 브리지”
- 사람/브라우저가 직접 쓰는 서비스라기보다, 내부 도구가 붙어서 identity 관리 작업을 자동화할 때 사용
### 5) `kratos-ui`
- Kratos의 로그인/회원가입 등 Self-service 화면을 제공하는 UI 서버
- Kratos public/admin URL을 환경변수로 받아서 UI가 Kratos와 통신함
---
### 6) `hydra-migrate`
- Hydra DB 스키마 마이그레이션을 수행하는 1회성 컨테이너
- Postgres가 healthy가 된 뒤 실행되고, 성공해야 Hydra가 뜸
### 7) `hydra`
- **OAuth2 / OIDC Provider**: authorization code 발급, access/refresh token 발급 등
- 포트
- `4444`(public): authorization/token/jwks 등 외부 클라이언트가 접근
- `4445`(admin): 클라이언트 등록/관리 등 관리자 API
- `URLS_SELF_ISSUER`, `URLS_LOGIN`, `URLS_CONSENT`로 “로그인/동의 화면을 어디서 처리할지”를 외부(backend)로 위임
### 8) `hydra-mcp-server` (지금은 profiles 제거되어 항상 뜸)
- Hydra Admin/Public API를 대신 호출해주는 “자동화/툴링(LLM 연동 포함) 브리지”
- 주 용도는 OAuth 클라이언트 생성/수정/조회 자동화, 테스트 환경 세팅, 운영 자동화 등
- 브라우저로 접속하는 포트 서비스가 아닐 가능성이 높고(ports 없음), 내부 도구가 붙어서 사용
---
### 9) `keto-migrate`
- Keto(권한/관계 기반 접근제어) DB 마이그레이션 수행 1회성 컨테이너
- Postgres가 healthy가 된 뒤 실행되고, 성공해야 Keto가 뜸
### 10) `keto`
- **권한/정책(관계 튜플) 기반 접근제어** 담당(Ory Keto)
- 포트
- `4466` read API
- `4467` write API
- “누가 어떤 리소스에 어떤 관계/권한이 있는지”를 저장/조회하는 역할
---
### 11) `oathkeeper`
- **Reverse proxy + Access rule enforcement**(인증/인가 게이트웨이)
- 일반적으로 앞단에서 요청을 받아서 “인증 여부 확인 후” 백엔드로 프록시
- 포트
- `4456` API(관리/디버그용으로 쓰는 경우 많음)
- `4455` Proxy(외부 트래픽이 통과하는 포트로 쓰는 경우가 많음)
---
### 12) `ory_stack_check`
- 알파인에서 curl로 Kratos/Hydra/Keto의 `/health/ready`를 폴링해서 “스택 준비 완료”를 확인하는 헬퍼
- 준비가 끝나야 다음 단계(init-rp)가 안전하게 실행됨
### 13) `init-rp`
- Hydra Admin API로 **OAuth 클라이언트(Relying Party)를 자동 등록**하는 1회성 컨테이너
- 여기서는 `adminfront`, `devfront` 클라이언트를 만들어 둠
- 실제 서비스 시작 시 “클라이언트가 없어서 로그인 플로우가 안 되는” 문제를 방지
---
## 네트워크 역할
### `ory-net` (external)
- Postgres/Kratos/Hydra/Keto/Oathkeeper 등 Ory 스택 내부 서비스들이 서로 통신하는 공용 네트워크
- `http://hydra:4445`, `http://kratos:4434` 같은 서비스 디스커버리가 여기서 성립
### `hydranet` (external)
- Hydra가 붙는 별도 네트워크
- `init-rp``hydranet`에 붙어서 Hydra Admin API로 클라이언트 등록을 수행
### `kratosnet` (external)
- Kratos가 붙는 별도 네트워크
- 다른 애플리케이션(예: backend)이 Kratos와 통신할 때 분리된 네트워크로 구성하는 패턴
---
## 볼륨 역할
### `ory_postgres_data`
- Postgres 데이터 영속화(컨테이너 재시작/재생성해도 DB 유지)
---
## 확인할 서비스
### Kratos:
```
curl -i http://localhost:4433/health/ready
```
### Hydra:
```
curl -i http://localhost:4441/health/ready
```
### Keto:
```
curl -i http://localhost:4466/health/ready
```
### Oathkeeper:
```
curl -i http://localhost:4456/health/ready
```
### 화면이 떠야 하는 것 (UI)
```
http://localhost:4455/... : Kratos UI (이미 OK)
http://localhost:5000, http://localhost:5174 : 프론트들 (이미 OK)
```

View File

@@ -0,0 +1,135 @@
**Descope Inbound App은 “OIDC 클라이언트 + 사용자 동의(Consent) + 토큰/세션 정책”을 한 화면에 묶어 제공하는 콘솔**입니다.
---
## 1⃣ Inbound App 기본 정보 (OIDC Client 메타데이터)
**역할: OAuth/OIDC Client 정의**
- **Inbound App Name / ID**
- OIDC `client_id`에 대응
- **Description**
- **Status (Verified / Unverified)**
- 사용자에게 신뢰된 앱인지 표시용
👉 Hydra 기준으로 보면 `hydra clients create`**client 메타 정보 영역**
---
## 2⃣ Scopes 관리 (권한 정의)
**역할: “이 앱이 무엇을 요구할 수 있는가” 정의**
### Permission Scopes
- `full_access`
- `profile`
- `email`
- 각 scope별:
- 설명
- Role 연계 여부
- Mandatory 여부
### User Information Scopes
- 사용자 claims에 포함될 정보 정의
- “토큰에 항상 authorization 정보 포함” 옵션
👉 OIDC의 `scope` + `claims` 설계를 **UI로 추상화**
---
## 3⃣ Consents (사용자 동의) 탭
**역할: Descope 인바운드 앱의 핵심**
- 사용자가 로그인 중 보게 되는 화면
- “이 앱이 아래 권한을 요청합니다”
- Scope별 동의/거부
- Mandatory scope는 자동 포함
👉 이게 **Hydra의 `consent_challenge`를 처리하는 UI**에 해당
👉 김용연님이 **5174에 구현하라고 들은 바로 그 기능**
---
## 4⃣ Connection Information (OIDC Endpoint 묶음)
**역할: 외부 앱이 실제로 연동할 정보**
- **Flow Hosting URL**
- Descope가 제공하는 로그인 + consent orchestration URL
- **Approved Redirect URIs**
- OAuth redirect whitelist
- **Client ID / Client Secret**
- **Discovery URL**
- `/.well-known/openid-configuration`
- **Issuer**
- **Authorization URL**
- **Token URL**
- **Audience Whitelist**
- **Default Audience 설정**
👉 이 영역은 **OIDC 표준 설정을 전부 노출**
👉 Hydra로 치면:
- discovery
- issuer
- `/oauth2/auth`
- `/oauth2/token`
---
## 5⃣ Session Management (토큰/세션 정책)
**역할: 보안 정책 제어**
### Token Format
- User JWT 템플릿
- Access Key JWT 템플릿
### Token Expiration
- Refresh Token Timeout (예: 520주)
- Session Token Timeout (분 단위)
- Access Token Timeout
👉 Hydra + Kratos 설정을 **앱 단위로 override**하는 개념
---
## 6⃣ Descope Inbound App이 “한 번에 제공하는 것” 요약
한 문장으로 정리하면:
> **Descope Inbound App =
> OIDC Client 관리 + Scope 정의 + Consent UI + Token/Session 정책 + Login Flow Hosting**
---
## 7⃣ 김용연님 Baron SSO(5174)와의 1:1 대응표
| Descope Inbound App | Baron SSO(5174) |
| ---------------------- | ------------------------------- |
| Inbound App Details | Client 관리 화면 |
| Scopes | Client Scope 설정 |
| **Consents** | **/consent 페이지 (구현 대상)** |
| Connection Information | Hydra Client 설정 |
| Session Management | 토큰 정책 설정 |
👉 그래서 결론적으로,
- **지금 5174 `/clients` 화면은 Descope의 “Settings 탭 일부”**
- **Consents 탭이 빠져 있어서 아직 Descope의 절반만 구현된 상태**
---
## 최종 정리 한 줄
> **Descope Inbound App은 “OIDC Client + 사용자 동의 + 보안 정책”을 묶은 올인원 인바운드 애플리케이션 콘솔이고,
> 5174는 그걸 Hydra 기반으로 우리가 직접 재구현하는 중이다.**