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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# General
|
||||
.env
|
||||
.temp
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
71
adminfront/src/features/tenants/routes/TenantDetailPage.tsx
Normal file
71
adminfront/src/features/tenants/routes/TenantDetailPage.tsx
Normal 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;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
50
backend/internal/domain/federation_models.go
Normal file
50
backend/internal/domain/federation_models.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
161
backend/internal/handler/federation_handler.go
Normal file
161
backend/internal/handler/federation_handler.go
Normal 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
|
||||
10
backend/internal/repository/federation_repository.go
Normal file
10
backend/internal/repository/federation_repository.go
Normal 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)
|
||||
}
|
||||
23
backend/internal/repository/gorm_federation_repository.go
Normal file
23
backend/internal/repository/gorm_federation_repository.go
Normal 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
|
||||
}
|
||||
91
backend/internal/service/federation_service.go
Normal file
91
backend/internal/service/federation_service.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
263
devfront/src/features/clients/routes/ClientFederationPage.tsx
Normal file
263
devfront/src/features/clients/routes/ClientFederationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
154
docs/compose-ory.md
Normal 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)
|
||||
```
|
||||
135
docs/descope_inbound_apps.md
Normal file
135
docs/descope_inbound_apps.md
Normal 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 기반으로 우리가 직접 재구현하는 중이다.**
|
||||
Reference in New Issue
Block a user