1
0
forked from baron/baron-sso

커스텀 클레임 ui/ux 추가

This commit is contained in:
2026-04-28 17:47:25 +09:00
parent 3dcdd97882
commit 20afede89c
4 changed files with 553 additions and 0 deletions

View File

@@ -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<string, unknown>,
): 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<string, unknown>;
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<string, unknown> {
const preview: Record<string, unknown> = {};
const rpClaims: Record<string, unknown> = {};
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<IdTokenClaimItem[]>([]);
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 = <K extends keyof IdTokenClaimItem>(
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<string>();
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<TenantSummary | MyTenantSummary> =
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() {
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<CardTitle className="text-xl font-bold">
{t(
"ui.dev.clients.general.id_token_claims.title",
"ID Token Claims",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.general.id_token_claims.subtitle",
"공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.",
)}
</CardDescription>
</div>
<Button onClick={addIdTokenClaim} className="gap-2">
<Plus className="h-4 w-4" />
{t("ui.dev.clients.general.id_token_claims.add", "Claim 추가")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 xl:grid-cols-[1.3fr_0.7fr]">
<div className="space-y-3">
<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">
{t(
"ui.dev.clients.general.id_token_claims.table.key",
"Claim Key",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.namespace",
"Namespace",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.value_type",
"Value Type",
)}
</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.value",
"Value",
)}
</th>
<th className="px-4 py-3 text-right font-bold">
{t(
"ui.dev.clients.general.id_token_claims.table.delete",
"Delete",
)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{idTokenClaims.map((claim) => (
<tr key={claim.id} className="hover:bg-muted/20">
<td className="px-4 py-3 align-top">
<Input
value={claim.key}
onChange={(e) =>
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",
)}
/>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.namespace}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"namespace",
e.target.value as ClaimNamespace,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.namespace_label",
"Claim namespace",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="top_level">
{t(
"ui.dev.clients.general.id_token_claims.namespace_top_level",
"top-level",
)}
</option>
<option value="rp_claims">
{t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)}
</option>
</select>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.valueType}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"valueType",
e.target.value as ClaimValueType,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.value_type_label",
"Claim value type",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="text">
{t(
"ui.dev.clients.general.id_token_claims.value_type_text",
"Text",
)}
</option>
<option value="number">
{t(
"ui.dev.clients.general.id_token_claims.value_type_number",
"Number",
)}
</option>
<option value="boolean">
{t(
"ui.dev.clients.general.id_token_claims.value_type_boolean",
"Boolean",
)}
</option>
<option value="array">
{t(
"ui.dev.clients.general.id_token_claims.value_type_array",
"Array",
)}
</option>
<option value="object">
{t(
"ui.dev.clients.general.id_token_claims.value_type_object",
"Object",
)}
</option>
</select>
</td>
<td className="px-4 py-3 align-top">
<Input
value={claim.value}
onChange={(e) =>
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",
)}
/>
</td>
<td className="px-4 py-3 text-right align-top">
<Button
variant="ghost"
size="icon"
onClick={() => removeIdTokenClaim(claim.id)}
className="h-9 w-9 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
{idTokenClaims.length === 0 && (
<tr>
<td
colSpan={5}
className="px-4 py-8 text-center text-muted-foreground"
>
{t(
"msg.dev.clients.general.id_token_claims.empty",
"아직 추가된 ID Token claim이 없습니다.",
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
<p className="text-xs leading-6 text-muted-foreground">
{t(
"msg.dev.clients.general.id_token_claims.hint",
"top-level은 일반 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
)}
</p>
</div>
<div className="space-y-3">
<div className="rounded-xl border border-border bg-muted/20 p-4">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-primary" />
<div>
<p className="text-sm font-semibold">
{t(
"ui.dev.clients.general.id_token_claims.preview_title",
"Saved JSON Preview",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.id_token_claims.preview_hint",
"저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다.",
)}
</p>
</div>
</div>
<Textarea
readOnly
value={idTokenClaimPreviewJson}
className="mt-4 min-h-72 font-mono text-xs"
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 3. Security Settings */}
<Card className="glass-panel">
<CardHeader className="pb-3">

View File

@@ -421,6 +421,15 @@ empty = "No scopes registered."
subtitle = "Define the permission scopes this application can request."
tenant = "Tenant access claim"
[msg.dev.clients.general.id_token_claims]
subtitle = "Separate shared claims from RP-specific extension claims."
empty = "No ID Token claims have been added yet."
hint = "Use top-level for shared claims and rp_claims for RP-specific extension claims. Arrays accept JSON or comma-separated values, and objects accept JSON."
preview_hint = "Preview the metadata.id_token_claims structure that will be saved."
key_required = "Enter a claim key."
reserved_key = "`rp_claims` is a reserved namespace key."
duplicate_key = "Duplicate claim key: {{namespace}}.{{key}}"
[msg.dev.clients.general.security]
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
@@ -1452,6 +1461,22 @@ hint = "Turning this on adds the tenant scope automatically and requires at leas
autocomplete_hint = "Type a tenant name to see autocomplete suggestions. Click one to add it to the allowed list."
validation_required = "Select at least one allowed tenant when tenant access restriction is enabled."
[ui.dev.clients.general.id_token_claims]
title = "ID Token Claims"
add = "Add Claim"
preview_title = "Saved JSON Preview"
namespace_label = "Claim namespace"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
value_type_label = "Claim value type"
value_type_text = "Text"
value_type_number = "Number"
value_type_boolean = "Boolean"
value_type_array = "Array"
value_type_object = "Object"
key_placeholder = "e.g. locale"
value_placeholder = "Enter the claim value"
[ui.dev.clients.general.security]
private = "Server Side App"
pkce = "PKCE"

View File

@@ -421,6 +421,15 @@ empty = "등록된 스코프가 없습니다."
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
tenant = "소속 테넌트 정보 접근"
[msg.dev.clients.general.id_token_claims]
subtitle = "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다."
empty = "아직 추가된 ID Token claim이 없습니다."
hint = "top-level은 공통 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다."
preview_hint = "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다."
key_required = "Claim key를 입력해야 합니다."
reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}"
[msg.dev.clients.general.security]
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
@@ -1451,6 +1460,22 @@ hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
[ui.dev.clients.general.id_token_claims]
title = "ID Token Claims"
add = "Claim 추가"
preview_title = "저장 JSON 미리보기"
namespace_label = "Claim 네임스페이스"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
value_type_label = "Claim 값 타입"
value_type_text = "텍스트"
value_type_number = "숫자"
value_type_boolean = "불리언"
value_type_array = "배열"
value_type_object = "객체"
key_placeholder = "예: locale"
value_placeholder = "Claim 값을 입력하세요"
[ui.dev.clients.general.security]
private = "Server side App"
pkce = "PKCE"

View File

@@ -426,6 +426,15 @@ save_error = ""
save_forbidden = ""
status_changed = ""
[msg.dev.clients.general.id_token_claims]
subtitle = ""
empty = ""
hint = ""
preview_hint = ""
key_required = ""
reserved_key = ""
duplicate_key = ""
[msg.dev.clients.relationships]
subtitle = ""
add_description = ""
@@ -1534,6 +1543,22 @@ hint = ""
autocomplete_hint = ""
validation_required = ""
[ui.dev.clients.general.id_token_claims]
title = ""
add = ""
preview_title = ""
namespace_label = ""
namespace_top_level = ""
namespace_rp_claims = ""
value_type_label = ""
value_type_text = ""
value_type_number = ""
value_type_boolean = ""
value_type_array = ""
value_type_object = ""
key_placeholder = ""
value_placeholder = ""
[ui.dev.clients.general.security]
private = ""
pkce = ""