From cf3d049367bfbae63df9f06436da4e6f1d2d4465 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 27 Mar 2026 12:33:05 +0900 Subject: [PATCH] =?UTF-8?q?RP=20=EA=B3=B5=EA=B0=9C=ED=82=A4=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20UI=20=EB=B0=8F=20SSH-RSA=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientGeneralPage.tsx | 417 ++++++++++++++++-- devfront/src/lib/i18n.ts | 5 +- devfront/src/lib/keyUtils.ts | 139 ++++++ devfront/src/locales/en.toml | 42 ++ devfront/src/locales/ko.toml | 42 ++ devfront/src/locales/template.toml | 42 ++ 6 files changed, 660 insertions(+), 27 deletions(-) create mode 100644 devfront/src/lib/keyUtils.ts diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 6aeedfc0..a5c60c16 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -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, + 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("active"); const [initialStatus, setInitialStatus] = useState("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(() => [ { 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() { - {/* 2. Scopes (Moved up and upgraded) */} + {/* 2. Scopes */}
@@ -497,7 +656,6 @@ function ClientGeneralPage() { - {/* Create 모드일 때만 Redirect URIs 입력 필드 표시 */} {isCreate && (