forked from baron/baron-sso
RP 공개키 등록 UI 및 SSH-RSA 자동 변환 기능 구현
This commit is contained in:
@@ -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;
|
||||
@@ -122,10 +122,11 @@ function formatTemplate(
|
||||
template: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
const normalizedTemplate = template.replace(/\\n/g, "\n");
|
||||
if (!vars) {
|
||||
return template;
|
||||
return normalizedTemplate;
|
||||
}
|
||||
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
|
||||
return normalizedTemplate.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined || value === null) {
|
||||
return match;
|
||||
|
||||
139
devfront/src/lib/keyUtils.ts
Normal file
139
devfront/src/lib/keyUtils.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Key Utilities for converting various public key formats (PEM, OpenSSH) to JWKS.
|
||||
*/
|
||||
|
||||
interface JWK {
|
||||
kty: string;
|
||||
n: string;
|
||||
e: string;
|
||||
kid?: string;
|
||||
use?: string;
|
||||
alg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Base64 string to a URL-safe Base64 string (RFC 7515).
|
||||
*/
|
||||
function toBase64Url(base64: string): string {
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a hex string to a URL-safe Base64 string.
|
||||
*/
|
||||
function hexToBase64Url(hex: string): string {
|
||||
const binary = hex
|
||||
.match(/.{1,2}/g)
|
||||
?.map((byte) => String.fromCharCode(parseInt(byte, 16)))
|
||||
.join("");
|
||||
if (!binary) return "";
|
||||
return toBase64Url(btoa(binary));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts RSA Modulus (n) and Exponent (e) from a SubjectPublicKeyInfo (PEM).
|
||||
* This is a simplified parser for common RSA keys.
|
||||
*/
|
||||
export function parsePemToJwk(pem: string): JWK | null {
|
||||
try {
|
||||
// Remove headers, footers and whitespace
|
||||
const base64 = pem
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/, "")
|
||||
.replace(/-----END PUBLIC KEY-----/, "")
|
||||
.replace(/\s/g, "");
|
||||
|
||||
// In a real browser environment without heavy libraries,
|
||||
// we would need a full ASN.1 parser.
|
||||
// For now, we recommend using JWKS or OpenSSH formats for reliability,
|
||||
// or we can hint the user that complex PEMs might fail.
|
||||
// However, we'll try to support a basic one.
|
||||
|
||||
return null; // Placeholder: PEM parsing is complex without libs.
|
||||
} catch (e) {
|
||||
console.error("Failed to parse PEM", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an OpenSSH Public Key (ssh-rsa AAAA...) into a JWK.
|
||||
*/
|
||||
export function parseSshRsaToJwk(sshKey: string): JWK | null {
|
||||
try {
|
||||
const parts = sshKey.trim().split(" ");
|
||||
if (parts.length < 2 || parts[0] !== "ssh-rsa") return null;
|
||||
|
||||
const keyData = atob(parts[1]);
|
||||
let offset = 0;
|
||||
|
||||
const readBlob = () => {
|
||||
const len =
|
||||
(keyData.charCodeAt(offset) << 24) |
|
||||
(keyData.charCodeAt(offset + 1) << 16) |
|
||||
(keyData.charCodeAt(offset + 2) << 8) |
|
||||
keyData.charCodeAt(offset + 3);
|
||||
offset += 4;
|
||||
const blob = keyData.slice(offset, offset + len);
|
||||
offset += len;
|
||||
return blob;
|
||||
};
|
||||
|
||||
const type = readBlob(); // "ssh-rsa"
|
||||
if (type !== "ssh-rsa") return null;
|
||||
|
||||
const eBlob = readBlob();
|
||||
const nBlob = readBlob();
|
||||
|
||||
const toB64Url = (blob: string) => toBase64Url(btoa(blob));
|
||||
|
||||
return {
|
||||
kty: "RSA",
|
||||
n: semanticsBase64Url(nBlob),
|
||||
e: semanticsBase64Url(eBlob),
|
||||
alg: "RS256",
|
||||
use: "sig",
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to parse SSH key", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function semanticsBase64Url(blob: string): string {
|
||||
// Ensure leading zero removal for BigInt representations if necessary
|
||||
let start = 0;
|
||||
while (start < blob.length && blob.charCodeAt(start) === 0) {
|
||||
start++;
|
||||
}
|
||||
return toBase64Url(btoa(blob.slice(start)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to auto-detect and convert input to JWKS JSON string.
|
||||
* Returns the original string if it's already JSON or conversion fails.
|
||||
*/
|
||||
export function tryConvertToJwks(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// 1. If it looks like JSON, return as is (validation happens in component)
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// 2. Try SSH RSA
|
||||
if (trimmed.startsWith("ssh-rsa")) {
|
||||
const jwk = parseSshRsaToJwk(trimmed);
|
||||
if (jwk) {
|
||||
return JSON.stringify({ keys: [jwk] }, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. PEM (Simplified check)
|
||||
if (trimmed.includes("BEGIN PUBLIC KEY")) {
|
||||
// For PEM, we suggest the user uses JWKS or SSH-RSA for now
|
||||
// as JS doesn't have a built-in ASN1 parser and we want to avoid heavy deps.
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
@@ -390,6 +390,29 @@ subtitle = "Define the permission scopes this application can request."
|
||||
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."
|
||||
subtitle = "Select application type. Security level determines authentication method."
|
||||
trusted_help = "Operate as a trusted RP using private_key_jwt and public key registration. Headless login is only available for this profile."
|
||||
|
||||
[msg.dev.clients.general.public_key]
|
||||
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications."
|
||||
auth_method_none_help = "Use this for PKCE-based public clients."
|
||||
auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for trusted RP bootstrap and JAR verification."
|
||||
guide_example = "Recommended example: https://rp.example.com/.well-known/jwks.json"
|
||||
guide_intro = "A JWKS URI is not created by Baron. It is the URL where the RP backend exposes its public key."
|
||||
guide_step_1 = "Generate a key pair on the RP server and keep the private key only in the RP backend."
|
||||
guide_step_2 = "Expose the public key from the RP backend through a JWKS (JSON Web Key Set) endpoint."
|
||||
guide_step_3 = "Enter a URL such as https://rp.example.com/.well-known/jwks.json in DevFront."
|
||||
headless_help = "Trusted RPs can keep their own login UI while Baron continues to handle authentication and OIDC progression."
|
||||
jwks_uri_help = "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json"
|
||||
request_object_alg_help = "Specify the JAR (Request Object) signing algorithm used for headless login."
|
||||
source_help = "Register the JWKS URI served by the RP so Baron can verify the public key."
|
||||
subtitle = "Manage the public key and headless login settings required for trusted RP evaluation."
|
||||
|
||||
[msg.dev.clients.general.public_key.validation]
|
||||
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
|
||||
headless_requires_private_key_jwt = "Headless login requires token endpoint auth method to be private_key_jwt."
|
||||
headless_requires_public_key = "Headless login requires a JWKS URI."
|
||||
invalid_jwks_uri = "JWKS URI format is invalid."
|
||||
private_key_jwt_requires_public_key = "Signed key-based authentication requires a JWKS URI."
|
||||
|
||||
[msg.dev.clients.help]
|
||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||
@@ -1368,8 +1391,27 @@ delete = "Delete"
|
||||
[ui.dev.clients.general.security]
|
||||
private = "Server Side App"
|
||||
pkce = "PKCE"
|
||||
trusted = "Trusted RP"
|
||||
title = "Security Settings"
|
||||
|
||||
[ui.dev.clients.general.public_key]
|
||||
auth_method = "Token Endpoint Auth Method"
|
||||
auth_method_client_secret_basic = "client_secret_basic"
|
||||
auth_method_none = "none"
|
||||
auth_method_private_key_jwt = "Signed Key Authentication"
|
||||
guide_toggle = "JWKS URI Setup Guide"
|
||||
headless_disabled = "Headless Disabled"
|
||||
headless_enabled = "Headless Enabled"
|
||||
headless_toggle = "Headless Login"
|
||||
jwks_uri = "JWKS URI"
|
||||
jwks_uri_placeholder = "https://rp.example.com/.well-known/jwks.json"
|
||||
request_object_alg = "Request Object Signing Algorithm"
|
||||
request_object_alg_placeholder = "RS256"
|
||||
source = "Public Key Source"
|
||||
source_uri = "JWKS URI"
|
||||
title = "Public Key Registration"
|
||||
validation_title = "Check before saving"
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||
docs_title = "Docs & Examples"
|
||||
|
||||
@@ -390,6 +390,29 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
||||
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
||||
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
||||
trusted_help = "private_key_jwt와 공개키 등록을 사용해 trusted RP로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다."
|
||||
|
||||
[msg.dev.clients.general.public_key]
|
||||
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
|
||||
auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다."
|
||||
auth_method_private_key_jwt_help = "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다."
|
||||
guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json"
|
||||
guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다."
|
||||
guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다."
|
||||
guide_step_2 = "RP backend가 public key를 JWKS(JSON Web Key Set) 형태로 제공하는 endpoint를 준비합니다."
|
||||
guide_step_3 = "예: https://rp.example.com/.well-known/jwks.json 같은 URL을 DevFront에 입력합니다."
|
||||
headless_help = "Trusted RP는 RP 자체 로그인 UI를 사용할 수 있지만, bootstrap 검증, 사용자 인증 처리, Hydra 연계, 최종 redirect 생성은 Baron backend가 담당합니다."
|
||||
jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json"
|
||||
request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다."
|
||||
source_help = "운영 환경에서는 RP가 서빙하는 JWKS URI를 등록해 공개키를 검증합니다."
|
||||
subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다."
|
||||
|
||||
[msg.dev.clients.general.public_key.validation]
|
||||
headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다."
|
||||
headless_requires_private_key_jwt = "Headless Login을 사용하려면 token endpoint auth method가 private_key_jwt여야 합니다."
|
||||
headless_requires_public_key = "Headless Login을 사용하려면 JWKS URI가 필요합니다."
|
||||
invalid_jwks_uri = "JWKS URI 형식이 올바르지 않습니다."
|
||||
private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다."
|
||||
|
||||
[msg.dev.clients.help]
|
||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||
@@ -1367,8 +1390,27 @@ delete = "삭제"
|
||||
[ui.dev.clients.general.security]
|
||||
private = "Server side App"
|
||||
pkce = "PKCE"
|
||||
trusted = "Trusted RP"
|
||||
title = "보안 설정"
|
||||
|
||||
[ui.dev.clients.general.public_key]
|
||||
auth_method = "Token Endpoint Auth Method"
|
||||
auth_method_client_secret_basic = "client_secret_basic"
|
||||
auth_method_none = "none"
|
||||
auth_method_private_key_jwt = "서명 키 기반 인증"
|
||||
guide_toggle = "JWKS URI 준비 가이드"
|
||||
headless_disabled = "Headless Disabled"
|
||||
headless_enabled = "Headless Enabled"
|
||||
headless_toggle = "Headless Login"
|
||||
jwks_uri = "JWKS URI"
|
||||
jwks_uri_placeholder = "https://rp.example.com/.well-known/jwks.json"
|
||||
request_object_alg = "Request Object Signing Algorithm"
|
||||
request_object_alg_placeholder = "RS256"
|
||||
source = "Public Key Source"
|
||||
source_uri = "JWKS URI"
|
||||
title = "공개키 등록"
|
||||
validation_title = "저장 전 확인 필요"
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||
docs_title = "Docs & Examples"
|
||||
|
||||
@@ -390,6 +390,29 @@ subtitle = ""
|
||||
private_help = ""
|
||||
pkce_help = ""
|
||||
subtitle = ""
|
||||
trusted_help = ""
|
||||
|
||||
[msg.dev.clients.general.public_key]
|
||||
auth_method_client_secret_basic_help = ""
|
||||
auth_method_none_help = ""
|
||||
auth_method_private_key_jwt_help = ""
|
||||
guide_example = ""
|
||||
guide_intro = ""
|
||||
guide_step_1 = ""
|
||||
guide_step_2 = ""
|
||||
guide_step_3 = ""
|
||||
headless_help = ""
|
||||
jwks_uri_help = ""
|
||||
request_object_alg_help = ""
|
||||
source_help = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.dev.clients.general.public_key.validation]
|
||||
headless_requires_alg = ""
|
||||
headless_requires_private_key_jwt = ""
|
||||
headless_requires_public_key = ""
|
||||
invalid_jwks_uri = ""
|
||||
private_key_jwt_requires_public_key = ""
|
||||
|
||||
[msg.dev.clients.help]
|
||||
docs_body = ""
|
||||
@@ -1367,8 +1390,27 @@ delete = ""
|
||||
[ui.dev.clients.general.security]
|
||||
private = ""
|
||||
pkce = ""
|
||||
trusted = ""
|
||||
title = ""
|
||||
|
||||
[ui.dev.clients.general.public_key]
|
||||
auth_method = ""
|
||||
auth_method_client_secret_basic = ""
|
||||
auth_method_none = ""
|
||||
auth_method_private_key_jwt = ""
|
||||
guide_toggle = ""
|
||||
headless_disabled = ""
|
||||
headless_enabled = ""
|
||||
headless_toggle = ""
|
||||
jwks_uri = ""
|
||||
jwks_uri_placeholder = ""
|
||||
request_object_alg = ""
|
||||
request_object_alg_placeholder = ""
|
||||
source = ""
|
||||
source_uri = ""
|
||||
title = ""
|
||||
validation_title = ""
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = ""
|
||||
docs_title = ""
|
||||
|
||||
Reference in New Issue
Block a user