1
0
forked from baron/baron-sso

RP 공개키 등록 UI 및 SSH-RSA 자동 변환 기능 구현

This commit is contained in:
2026-03-27 12:33:05 +09:00
parent 2a162f0efe
commit cf3d049367
6 changed files with 660 additions and 27 deletions

View File

@@ -39,6 +39,7 @@ import type {
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { tryConvertToJwks } from "../../lib/keyUtils";
interface ScopeItem {
id: string;
@@ -47,6 +48,35 @@ interface ScopeItem {
mandatory: boolean;
}
type SecurityProfile = "private" | "pkce" | "private_key_jwt";
function readMetadataString(
metadata: Record<string, unknown>,
key: string,
): string {
const value = metadata[key];
return typeof value === "string" ? value : "";
}
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:";
} catch {
return false;
}
}
function isValidJson(value: string): boolean {
if (!value.trim()) return false;
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}
function ClientGeneralPage() {
const params = useParams();
const navigate = useNavigate();
@@ -66,6 +96,16 @@ function ClientGeneralPage() {
const [status, setStatus] = useState<ClientStatus>("active");
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
const [redirectUris, setRedirectUris] = useState("");
// Public Key Registration States
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] = useState<
"none" | "client_secret_basic" | "private_key_jwt"
>("client_secret_basic");
const [jwksSource, setJwksSource] = useState<"uri" | "inline">("uri");
const [jwksUri, setJwksUri] = useState("");
const [jwksText, setJwksText] = useState("");
const [requestObjectSigningAlg, setRequestObjectSigningAlg] = useState("RS256");
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
{
id: "1",
@@ -95,12 +135,44 @@ function ClientGeneralPage() {
setStatus(client.status);
setInitialStatus(client.status);
const metadata = client.metadata ?? {};
if (typeof metadata.description === "string")
setDescription(metadata.description);
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
const savedAuthMethod = client.tokenEndpointAuthMethod || (client.type === "pkce" ? "none" : "client_secret_basic");
setTokenEndpointAuthMethod(savedAuthMethod as any);
if (client.jwksUri) {
setJwksUri(client.jwksUri);
setJwksSource("uri");
} else if (client.jwks) {
setJwksText(typeof client.jwks === 'string' ? client.jwks : JSON.stringify(client.jwks, null, 2));
setJwksSource("inline");
}
const metadata = client.metadata ?? {};
if (typeof metadata.description === "string") setDescription(metadata.description);
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
// Fallbacks from metadata if top-level fields are empty
if (!client.tokenEndpointAuthMethod) {
const metaAuth = readMetadataString(metadata, "token_endpoint_auth_method");
if (metaAuth === "none" || metaAuth === "client_secret_basic" || metaAuth === "private_key_jwt") {
setTokenEndpointAuthMethod(metaAuth);
}
}
if (!client.jwksUri && !client.jwks) {
const metaJwksUri = readMetadataString(metadata, "jwks_uri");
if (metaJwksUri) {
setJwksUri(metaJwksUri);
setJwksSource("uri");
}
}
const savedRequestObjectSigningAlg = readMetadataString(metadata, "request_object_signing_alg");
if (savedRequestObjectSigningAlg) {
setRequestObjectSigningAlg(savedRequestObjectSigningAlg);
} else if (savedAuthMethod === "private_key_jwt") {
setRequestObjectSigningAlg("RS256");
}
// Metadata에 저장된 구조화된 scope 정보가 있으면 사용, 없으면 기본 scopes 문자열에서 생성
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
if (savedScopes && Array.isArray(savedScopes)) {
setScopes(savedScopes);
@@ -116,6 +188,40 @@ function ClientGeneralPage() {
}
}, [data]);
const securityProfile: SecurityProfile =
tokenEndpointAuthMethod === "private_key_jwt"
? "private_key_jwt"
: clientType === "pkce"
? "pkce"
: "private";
const headlessLoginEnabled = securityProfile === "private_key_jwt";
const handleSecurityProfileChange = (profile: SecurityProfile) => {
if (profile === "pkce") {
setClientType("pkce");
setTokenEndpointAuthMethod("none");
setJwksUri("");
setJwksText("");
setRequestObjectSigningAlg("");
return;
}
setClientType("private");
if (profile === "private_key_jwt") {
setTokenEndpointAuthMethod("private_key_jwt");
if (requestObjectSigningAlg.trim() === "") {
setRequestObjectSigningAlg("RS256");
}
return;
}
setTokenEndpointAuthMethod("client_secret_basic");
setJwksUri("");
setJwksText("");
setRequestObjectSigningAlg("");
};
const addScope = () => {
const newId = String(Date.now());
setScopes([
@@ -155,21 +261,70 @@ function ClientGeneralPage() {
);
};
// Convert on blur or change if desired, here we try to convert before validation
const finalJwksText = tryConvertToJwks(jwksText);
const validationErrors: string[] = [];
const trimmedJwksUri = jwksUri.trim();
const trimmedJwksText = finalJwksText.trim();
const trimmedRequestObjectSigningAlg = requestObjectSigningAlg.trim();
if (headlessLoginEnabled) {
if (jwksSource === "uri") {
if (!trimmedJwksUri) {
validationErrors.push(t("msg.dev.clients.general.public_key.validation.missing_jwks_uri", "JWKS URI를 입력해야 합니다."));
} else if (!isValidUrl(trimmedJwksUri)) {
validationErrors.push(t("msg.dev.clients.general.public_key.validation.invalid_jwks_uri", "JWKS URI 형식이 올바르지 않습니다."));
}
} else if (jwksSource === "inline") {
if (!trimmedJwksText) {
validationErrors.push(t("msg.dev.clients.general.public_key.validation.missing_jwks_inline", "공개키(JWKS 또는 SSH-RSA)를 입력해야 합니다."));
} else if (!isValidJson(trimmedJwksText)) {
validationErrors.push(t("msg.dev.clients.general.public_key.validation.invalid_jwks_inline", "입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다."));
}
}
if (trimmedRequestObjectSigningAlg === "") {
validationErrors.push(
t(
"msg.dev.clients.general.public_key.validation.headless_requires_alg",
"Request Object Signing Algorithm (예: RS256)을 입력해야 합니다.",
),
);
}
}
const hasValidationErrors = validationErrors.length > 0;
const mutation = useMutation({
mutationFn: async () => {
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
let finalJwks: any = undefined;
if (tokenEndpointAuthMethod === "private_key_jwt" && jwksSource === "inline" && trimmedJwksText) {
try {
finalJwks = JSON.parse(trimmedJwksText);
} catch (e) {
throw new Error("Invalid Public Key Format");
}
}
const payload: ClientUpsertRequest = {
name,
type: clientType,
scopes: scopeNames,
tokenEndpointAuthMethod,
jwksUri: tokenEndpointAuthMethod === "private_key_jwt" && jwksSource === "uri" ? trimmedJwksUri : undefined,
jwks: finalJwks,
metadata: {
description,
logo_url: logoUrl,
structured_scopes: scopes, // 향후 보존을 위해 metadata에 저장
structured_scopes: scopes,
token_endpoint_auth_method: tokenEndpointAuthMethod,
request_object_signing_alg: trimmedRequestObjectSigningAlg,
headless_login_enabled: headlessLoginEnabled,
},
};
// 생성 시에는 Redirect URIs를 포함해서 전송
if (isCreate) {
payload.status = status;
payload.redirectUris = redirectUris
@@ -179,8 +334,6 @@ function ClientGeneralPage() {
return createClient(payload);
}
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하고,
// status는 전용 PATCH API로 처리해서 감사로그 액션을 분리한다.
const updated = await updateClient(clientId as string, payload);
if (status !== initialStatus) {
await updateClientStatus(clientId as string, status);
@@ -271,6 +424,12 @@ function ClientGeneralPage() {
);
}
const publicKeyStatusTone = headlessLoginEnabled
? hasValidationErrors
? "border-destructive/40 bg-destructive/5"
: "border-primary/30 bg-primary/5"
: "border-border bg-muted/20";
const displayName = isCreate
? t("ui.dev.clients.general.display_new", "새 클라이언트")
: data?.client?.name || data?.client?.id;
@@ -472,7 +631,7 @@ function ClientGeneralPage() {
</div>
</div>
{/* 2. Scopes (Moved up and upgraded) */}
{/* 2. Scopes */}
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div>
@@ -497,7 +656,6 @@ function ClientGeneralPage() {
</Button>
</CardHeader>
<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">
@@ -622,7 +780,7 @@ function ClientGeneralPage() {
</CardContent>
</Card>
{/* 3. Security Settings (Moved down) */}
{/* 3. Security Settings */}
<Card className="glass-panel">
<CardHeader className="pb-3">
<CardTitle className="text-xl font-bold">
@@ -636,11 +794,11 @@ function ClientGeneralPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-3">
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
clientType === "private"
securityProfile === "private"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
@@ -648,9 +806,9 @@ function ClientGeneralPage() {
<input
className="sr-only"
type="radio"
name="client-type"
checked={clientType === "private"}
onChange={() => setClientType("private")}
name="security-profile"
checked={securityProfile === "private"}
onChange={() => handleSecurityProfileChange("private")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Shield className="h-4 w-4 text-primary" />
@@ -666,14 +824,14 @@ function ClientGeneralPage() {
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{clientType === "private" ? "✓" : ""}
{securityProfile === "private" ? "✓" : ""}
</span>
</label>
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
clientType === "pkce"
securityProfile === "pkce"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
@@ -681,9 +839,9 @@ function ClientGeneralPage() {
<input
className="sr-only"
type="radio"
name="client-type"
checked={clientType === "pkce"}
onChange={() => setClientType("pkce")}
name="security-profile"
checked={securityProfile === "pkce"}
onChange={() => handleSecurityProfileChange("pkce")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Sparkles className="h-4 w-4" />
@@ -696,13 +854,221 @@ function ClientGeneralPage() {
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{clientType === "pkce" ? "✓" : ""}
{securityProfile === "pkce" ? "✓" : ""}
</span>
</label>
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
securityProfile === "private_key_jwt"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
>
<input
className="sr-only"
type="radio"
name="security-profile"
checked={securityProfile === "private_key_jwt"}
onChange={() => handleSecurityProfileChange("private_key_jwt")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Shield className="h-4 w-4 text-primary" />
{t("ui.dev.clients.general.security.trusted", "Trusted RP")}
</span>
<span className="whitespace-pre-line text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.security.trusted_help",
"private_key_jwt와 공개키 등록을 사용해 trusted RP로 운영합니다. Headless Login은 이 프로필에서만 사용할 수 있습니다.",
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{securityProfile === "private_key_jwt" ? "✓" : ""}
</span>
</label>
</div>
</CardContent>
</Card>
{/* 4. Public Key Registration (Trusted RP) */}
{securityProfile === "private_key_jwt" && (
<Card className="glass-panel border-primary/20">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
{t(
"ui.dev.clients.general.public_key.title",
"Public Key Registration",
)}
<Badge variant="info" className="px-2 py-0.5 text-[10px] uppercase">
Trusted RP
</Badge>
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.public_key.subtitle",
"Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다.",
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className={cn("rounded-xl border p-4", publicKeyStatusTone)}>
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-bold text-foreground">
{t(
"ui.dev.clients.general.public_key.headless_toggle",
"Headless Login 허용 여부",
)}
</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.headless_help",
"RP 자체 로그인 UI를 사용하고, 실제 인증 처리는 Baron Backend API를 통해 백그라운드에서 수행합니다.",
)}
</p>
</div>
<Badge variant="default" className="bg-primary/20 text-primary border-primary/30">
{t("ui.common.enabled", "Enabled")}
</Badge>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-3">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.public_key.request_object_alg",
"Request Object Signing Algorithm",
)}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
value={requestObjectSigningAlg}
onChange={(e) => setRequestObjectSigningAlg(e.target.value)}
placeholder={t(
"ui.dev.clients.general.public_key.request_object_alg_placeholder",
"예: RS256",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.request_object_alg_help",
"Headless Login을 사용할 때 JAR(Request Object) 서명 검증에 사용할 알고리즘을 명시합니다.",
)}
</p>
</div>
</div>
<div className="space-y-4 rounded-xl border border-border bg-muted/5 p-4">
<div className="space-y-1 pb-2 border-b border-border/50">
<Label className="text-sm font-bold">
{t(
"ui.dev.clients.general.public_key.source",
"Public Key Source",
)}
</Label>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.source_help",
"OIDC 검증을 위한 공개키 제공 방식을 선택합니다. (운영 환경에서는 JWKS URI 사용을 권장합니다)",
)}
</p>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="jwksSource"
checked={jwksSource === "uri"}
onChange={() => setJwksSource("uri")}
className="accent-primary"
/>
<span>JWKS URI ()</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="jwksSource"
checked={jwksSource === "inline"}
onChange={() => setJwksSource("inline")}
className="accent-primary"
/>
<span>Inline Public Key ( )</span>
</label>
</div>
{jwksSource === "uri" && (
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
<Label className="text-sm font-semibold">
{t("ui.dev.clients.general.public_key.jwks_uri", "JWKS URI")}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
value={jwksUri}
onChange={(e) => setJwksUri(e.target.value)}
placeholder={t(
"ui.dev.clients.general.public_key.jwks_uri_placeholder",
"https://rp.example.com/.well-known/jwks.json",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.jwks_uri_help",
"RP backend가 제공하는 공개키 endpoint URL을 입력하세요.",
)}
</p>
</div>
)}
{jwksSource === "inline" && (
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
<Label className="text-sm font-semibold">
{t("ui.dev.clients.general.public_key.jwks_inline", "JWKS 또는 OpenSSH 공개키")}
<span className="text-destructive ml-1">*</span>
</Label>
<Textarea
rows={8}
value={jwksText}
onChange={(e) => setJwksText(e.target.value)}
placeholder={t("ui.dev.clients.general.public_key.jwks_inline_placeholder", "JWKS (JSON) 또는 'ssh-rsa AAA...' 형식의 공개키를 붙여넣으세요.")}
className="font-mono text-xs leading-tight"
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.jwks_inline_help",
"OIDC 표준인 JWKS(JSON) 형식을 권장하지만, SSH-RSA 공개키를 입력하면 자동으로 변환하여 저장합니다.",
)}
</p>
</div>
)}
</div>
{hasValidationErrors && (
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-4 animate-in fade-in">
<p className="text-sm font-semibold text-destructive flex items-center gap-2">
<span></span>
{t(
"ui.dev.clients.general.public_key.validation_title",
"저장 전 확인 필요",
)}
</p>
<ul className="mt-2 list-disc space-y-1 pl-6 text-sm text-destructive">
{validationErrors.map((errorMessage) => (
<li key={errorMessage}>{errorMessage}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)}
<div className="flex items-center justify-between border-t border-border pt-4">
<div>
{!isCreate && (
@@ -729,7 +1095,8 @@ function ClientGeneralPage() {
mutation.isPending ||
isLoading ||
name.trim() === "" ||
(isCreate && redirectUris.trim() === "")
(isCreate && redirectUris.trim() === "") ||
hasValidationErrors
}
className="shadow-lg shadow-primary/20"
>
@@ -750,4 +1117,4 @@ function ClientGeneralPage() {
);
}
export default ClientGeneralPage;
export default ClientGeneralPage;