diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 7c84acbe..abb9610e 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -59,6 +59,17 @@ interface ScopeItem { locked?: boolean; } +type ClaimNamespace = "top_level" | "rp_claims"; +type ClaimValueType = "text" | "number" | "boolean" | "array" | "object"; + +interface IdTokenClaimItem { + id: string; + namespace: ClaimNamespace; + key: string; + value: string; + valueType: ClaimValueType; +} + type SecurityProfile = "private" | "pkce"; type TokenEndpointAuthMethod = | "none" @@ -111,6 +122,142 @@ function readMetadataString( return typeof value === "string" ? value : ""; } +function isClaimNamespace(value: string): value is ClaimNamespace { + return value === "top_level" || value === "rp_claims"; +} + +function isClaimValueType(value: string): value is ClaimValueType { + return ( + value === "text" || + value === "number" || + value === "boolean" || + value === "array" || + value === "object" + ); +} + +function createIdTokenClaimItem(id: string): IdTokenClaimItem { + return { + id, + namespace: "top_level", + key: "", + value: "", + valueType: "text", + }; +} + +function readIdTokenClaimsMetadata( + metadata: Record, +): IdTokenClaimItem[] { + const rawClaims = metadata.id_token_claims; + if (!Array.isArray(rawClaims)) { + return []; + } + + return rawClaims + .map((item, index) => { + if (!item || typeof item !== "object") { + return null; + } + + const record = item as Record; + const namespaceValue = + typeof record.namespace === "string" && + isClaimNamespace(record.namespace) + ? record.namespace + : "top_level"; + const keyValue = typeof record.key === "string" ? record.key : ""; + const rawValue = record.value; + const valueValue = + typeof rawValue === "string" + ? rawValue + : rawValue == null + ? "" + : JSON.stringify(rawValue); + const valueTypeValue = + typeof record.valueType === "string" && + isClaimValueType(record.valueType) + ? record.valueType + : "text"; + + return { + id: `claim-${index + 1}`, + namespace: namespaceValue, + key: keyValue, + value: valueValue, + valueType: valueTypeValue, + }; + }) + .filter((item): item is IdTokenClaimItem => item !== null); +} + +function normalizeClaimPreviewValue( + value: string, + valueType: ClaimValueType, +): unknown { + const trimmed = value.trim(); + if (valueType === "number") { + if (trimmed === "") return ""; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : trimmed; + } + + if (valueType === "boolean") { + return ["true", "1", "yes", "on"].includes(trimmed.toLowerCase()); + } + + if (valueType === "array") { + if (trimmed === "") return []; + try { + if (trimmed.startsWith("[")) { + const parsed = JSON.parse(trimmed); + return Array.isArray(parsed) ? parsed : [parsed]; + } + } catch { + // Fall through to comma-separated parsing. + } + return trimmed + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + } + + if (valueType === "object") { + if (trimmed === "") return {}; + try { + const parsed = JSON.parse(trimmed); + return parsed; + } catch { + return trimmed; + } + } + + return trimmed; +} + +function buildIdTokenClaimsPreview( + items: IdTokenClaimItem[], +): Record { + const preview: Record = {}; + const rpClaims: Record = {}; + + for (const item of items) { + const key = item.key.trim(); + if (!key) { + continue; + } + + const target = item.namespace === "rp_claims" ? rpClaims : preview; + target[key] = normalizeClaimPreviewValue(item.value, item.valueType); + } + + if (Object.keys(rpClaims).length > 0) { + preview.rp_claims = rpClaims; + } + + return preview; +} + function isValidUrl(value: string): boolean { try { const url = new URL(value); @@ -192,6 +339,7 @@ function ClientGeneralPage() { mandatory: false, }, ]); + const [idTokenClaims, setIdTokenClaims] = useState([]); useEffect(() => { if (!data) return; @@ -287,6 +435,7 @@ function ClientGeneralPage() { ), ); } + setIdTokenClaims(readIdTokenClaimsMetadata(metadata)); }, [data]); const securityProfile: SecurityProfile = @@ -436,6 +585,32 @@ function ClientGeneralPage() { ); }; + const addIdTokenClaim = () => { + setIdTokenClaims((current) => [ + ...current, + createIdTokenClaimItem(`claim-${Date.now()}`), + ]); + }; + + const updateIdTokenClaim = ( + id: string, + field: K, + value: IdTokenClaimItem[K], + ) => { + setIdTokenClaims((current) => + current.map((claim) => { + if (claim.id !== id) { + return claim; + } + return { ...claim, [field]: value }; + }), + ); + }; + + const removeIdTokenClaim = (id: string) => { + setIdTokenClaims((current) => current.filter((claim) => claim.id !== id)); + }; + const handleStatusChange = (nextStatus: ClientStatus) => { setStatus(nextStatus); const statusLabel = @@ -487,6 +662,11 @@ function ClientGeneralPage() { "허용 알고리즘: {{algorithms}}", { algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") }, ); + const normalizedIdTokenClaims = idTokenClaims.map((claim) => ({ + ...claim, + key: claim.key.trim(), + value: claim.value.trim(), + })); if (headlessLoginEnabled) { if (!trimmedJwksUri) { @@ -541,7 +721,61 @@ function ClientGeneralPage() { ); } + const claimValidationErrors: string[] = []; + const seenClaimKeys = new Set(); + for (const claim of normalizedIdTokenClaims) { + if (!claim.key) { + claimValidationErrors.push( + t( + "msg.dev.clients.general.id_token_claims.key_required", + "Claim key를 입력해야 합니다.", + ), + ); + continue; + } + + if (claim.key === "rp_claims" && claim.namespace === "top_level") { + claimValidationErrors.push( + t( + "msg.dev.clients.general.id_token_claims.reserved_key", + "`rp_claims`는 예약된 namespace 키입니다.", + ), + ); + continue; + } + + const keySignature = `${claim.namespace}:${claim.key}`; + if (seenClaimKeys.has(keySignature)) { + claimValidationErrors.push( + t( + "msg.dev.clients.general.id_token_claims.duplicate_key", + "중복된 claim key가 있습니다: {{namespace}}.{{key}}", + { + namespace: + claim.namespace === "rp_claims" + ? t( + "ui.dev.clients.general.id_token_claims.namespace_rp_claims", + "rp_claims", + ) + : t( + "ui.dev.clients.general.id_token_claims.namespace_top_level", + "top-level", + ), + key: claim.key, + }, + ), + ); + continue; + } + seenClaimKeys.add(keySignature); + } + validationErrors.push(...claimValidationErrors); + const hasValidationErrors = validationErrors.length > 0; + const idTokenClaimPreview = buildIdTokenClaimsPreview( + normalizedIdTokenClaims, + ); + const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2); const normalizedTenantSearch = tenantSearch.trim().toLowerCase(); const tenantOptions: Array = tenantData ?? []; @@ -676,6 +910,7 @@ function ClientGeneralPage() { auto_login_supported: autoLoginSupported, auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined, structured_scopes: normalizedScopes, + id_token_claims: normalizedIdTokenClaims, token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, headless_login_enabled: headlessLoginEnabled, headless_token_endpoint_auth_method: @@ -1534,6 +1769,249 @@ function ClientGeneralPage() { + + +
+
+ + {t( + "ui.dev.clients.general.id_token_claims.title", + "ID Token Claims", + )} + + + {t( + "msg.dev.clients.general.id_token_claims.subtitle", + "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.", + )} + +
+ +
+
+ +
+
+
+ + + + + + + + + + + + {idTokenClaims.map((claim) => ( + + + + + + + + ))} + {idTokenClaims.length === 0 && ( + + + + )} + +
+ {t( + "ui.dev.clients.general.id_token_claims.table.key", + "Claim Key", + )} + + {t( + "ui.dev.clients.general.id_token_claims.table.namespace", + "Namespace", + )} + + {t( + "ui.dev.clients.general.id_token_claims.table.value_type", + "Value Type", + )} + + {t( + "ui.dev.clients.general.id_token_claims.table.value", + "Value", + )} + + {t( + "ui.dev.clients.general.id_token_claims.table.delete", + "Delete", + )} +
+ + updateIdTokenClaim( + claim.id, + "key", + e.target.value, + ) + } + className="h-9 font-mono text-xs" + placeholder={t( + "ui.dev.clients.general.id_token_claims.key_placeholder", + "e.g. locale", + )} + /> + + + + + + + updateIdTokenClaim( + claim.id, + "value", + e.target.value, + ) + } + className="h-9 font-mono text-xs" + placeholder={t( + "ui.dev.clients.general.id_token_claims.value_placeholder", + "Enter the claim value", + )} + /> + + +
+ {t( + "msg.dev.clients.general.id_token_claims.empty", + "아직 추가된 ID Token claim이 없습니다.", + )} +
+
+

+ {t( + "msg.dev.clients.general.id_token_claims.hint", + "top-level은 일반 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.", + )} +

+
+ +
+
+
+ +
+

+ {t( + "ui.dev.clients.general.id_token_claims.preview_title", + "Saved JSON Preview", + )} +

+

+ {t( + "msg.dev.clients.general.id_token_claims.preview_hint", + "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다.", + )} +

+
+
+