1
0
forked from baron/baron-sso

feat(headless-login): add jwks cache visibility and refresh flow

- replace inline headless jwks support with jwksUri-only validation
- add cached jwks refresh worker, manual refresh/revoke endpoints, and parsed key summaries
- expose allowed algorithms and key previews in DevFront with regression coverage
This commit is contained in:
Lectom C Han
2026-04-01 18:33:22 +09:00
parent f51cdba51a
commit 9facd24a00
20 changed files with 2393 additions and 499 deletions

View File

@@ -29,6 +29,8 @@ import {
createClient,
deleteClient,
fetchClient,
refreshHeadlessJwksCache,
revokeHeadlessJwksCache,
updateClient,
updateClientStatus,
} from "../../lib/devApi";
@@ -38,7 +40,6 @@ import type {
ClientUpsertRequest,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { tryConvertToJwks } from "../../lib/keyUtils";
import { cn } from "../../lib/utils";
interface ScopeItem {
@@ -54,6 +55,19 @@ type TokenEndpointAuthMethod =
| "client_secret_basic"
| "private_key_jwt";
const HEADLESS_LOGIN_ALLOWED_ALGORITHMS = [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"EdDSA",
] as const;
function isTokenEndpointAuthMethod(
value: string,
): value is TokenEndpointAuthMethod {
@@ -72,17 +86,6 @@ function readMetadataString(
return typeof value === "string" ? value : "";
}
function readMetadataObject(
metadata: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const value = metadata[key];
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
@@ -92,14 +95,17 @@ function isValidUrl(value: string): boolean {
}
}
function isValidJson(value: string): boolean {
if (!value.trim()) return false;
try {
JSON.parse(value);
return true;
} catch {
return false;
}
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function previewKeyMaterial(value?: string) {
if (!value) return "-";
if (value.length <= 23) return value;
return `${value.slice(0, 10)}...${value.slice(-10)}`;
}
function ClientGeneralPage() {
@@ -125,9 +131,7 @@ function ClientGeneralPage() {
// Public Key Registration States
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
useState<TokenEndpointAuthMethod>("client_secret_basic");
const [jwksSource, setJwksSource] = useState<"uri" | "inline">("inline");
const [jwksUri, setJwksUri] = useState("");
const [jwksText, setJwksText] = useState("");
const [requestObjectSigningAlg, setRequestObjectSigningAlg] =
useState("RS256");
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
@@ -185,31 +189,12 @@ function ClientGeneralPage() {
}
const headlessJwksUri = readMetadataString(metadata, "headless_jwks_uri");
const headlessJwks = readMetadataObject(metadata, "headless_jwks");
if (headlessJwksUri) {
setJwksUri(headlessJwksUri);
setJwksText("");
setJwksSource("uri");
} else if (headlessJwks) {
setJwksText(JSON.stringify(headlessJwks, null, 2));
setJwksUri("");
setJwksSource("inline");
} else if (client.jwksUri) {
setJwksUri(client.jwksUri);
setJwksText("");
setJwksSource("uri");
} else if (client.jwks) {
setJwksText(
typeof client.jwks === "string"
? client.jwks
: JSON.stringify(client.jwks, null, 2),
);
setJwksUri("");
setJwksSource("inline");
} else {
setJwksUri("");
setJwksText("");
setJwksSource("inline");
}
// Fallbacks from metadata if top-level fields are empty
@@ -223,11 +208,10 @@ function ClientGeneralPage() {
}
}
if (!client.jwksUri && !client.jwks && !headlessEnabled) {
if (!client.jwksUri && !headlessEnabled) {
const metaJwksUri = readMetadataString(metadata, "jwks_uri");
if (metaJwksUri) {
setJwksUri(metaJwksUri);
setJwksSource("uri");
}
}
@@ -319,46 +303,25 @@ 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 (!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 형식이 올바르지 않습니다.",
),
);
}
if (trimmedRequestObjectSigningAlg === "") {
@@ -372,20 +335,75 @@ function ClientGeneralPage() {
}
const hasValidationErrors = validationErrors.length > 0;
const currentHeadlessJwksCache = data?.headlessJwksCache;
const refreshHeadlessJwksCacheMutation = useMutation({
mutationFn: async () => {
if (!clientId) throw new Error("Missing client id");
return refreshHeadlessJwksCache(clientId);
},
onSuccess: (result) => {
if (clientId) {
queryClient.setQueryData(["client", clientId], result);
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
}
toast(
t(
"msg.dev.clients.general.public_key.cache_refreshed",
"JWKS 캐시를 새로 고쳤습니다.",
),
);
},
onError: (err) => {
const errorMessage =
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error)?.message ??
t("msg.common.unknown_error", "unknown error");
toast(
t(
"msg.dev.clients.general.public_key.cache_refresh_failed",
"JWKS 캐시 새로고침에 실패했습니다: {{error}}",
{ error: errorMessage },
),
);
},
});
const revokeHeadlessJwksCacheMutation = useMutation({
mutationFn: async () => {
if (!clientId) throw new Error("Missing client id");
return revokeHeadlessJwksCache(clientId);
},
onSuccess: () => {
if (clientId) {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
}
toast(
t(
"msg.dev.clients.general.public_key.cache_revoked",
"JWKS 캐시를 삭제했습니다.",
),
);
},
onError: (err) => {
const errorMessage =
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error)?.message ??
t("msg.common.unknown_error", "unknown error");
toast(
t(
"msg.dev.clients.general.public_key.cache_revoke_failed",
"JWKS 캐시 삭제에 실패했습니다: {{error}}",
{ error: errorMessage },
),
);
},
});
const mutation = useMutation({
mutationFn: async () => {
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
let finalJwks: ClientUpsertRequest["jwks"];
if (jwksSource === "inline" && trimmedJwksText) {
try {
finalJwks = JSON.parse(trimmedJwksText);
} catch (e) {
throw new Error("Invalid Public Key Format");
}
}
const effectiveTokenEndpointAuthMethod =
clientType === "pkce" && headlessLoginEnabled
? "none"
@@ -398,13 +416,9 @@ function ClientGeneralPage() {
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
jwksUri:
effectiveTokenEndpointAuthMethod === "private_key_jwt" &&
jwksSource === "uri"
trimmedJwksUri
? trimmedJwksUri
: undefined,
jwks:
effectiveTokenEndpointAuthMethod === "private_key_jwt"
? finalJwks
: undefined,
metadata: {
description,
logo_url: logoUrl,
@@ -418,16 +432,9 @@ function ClientGeneralPage() {
: undefined,
headless_jwks_uri:
clientType === "pkce" &&
headlessLoginEnabled &&
jwksSource === "uri"
headlessLoginEnabled
? trimmedJwksUri
: undefined,
headless_jwks:
clientType === "pkce" &&
headlessLoginEnabled &&
jwksSource === "inline"
? finalJwks
: undefined,
},
};
@@ -1045,74 +1052,60 @@ function ClientGeneralPage() {
</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 === "inline"}
onChange={() => setJwksSource("inline")}
className="accent-primary"
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-3 rounded-xl border border-border bg-muted/5 p-4">
<div className="space-y-2">
<Label className="text-sm font-semibold" htmlFor="request-object-signing-alg">
{t(
"ui.dev.clients.general.public_key.request_object_alg",
"Request Object Signing Algorithm",
)}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="request-object-signing-alg"
value={requestObjectSigningAlg}
onChange={(e) => setRequestObjectSigningAlg(e.target.value)}
placeholder={t(
"ui.dev.clients.general.public_key.request_object_alg_placeholder",
"예: RS256",
)}
/>
<span>Inline Public Key</span>
</label>
<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>
</div>
<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 className="space-y-2 rounded-lg border border-border bg-background/60 p-3">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.allowed_algorithms",
"Allowed Algorithms",
)}
</p>
<div className="flex flex-wrap gap-2">
{HEADLESS_LOGIN_ALLOWED_ALGORITHMS.map((algorithm) => (
<Badge
key={algorithm}
variant="outline"
className="font-mono text-[11px]"
>
{algorithm}
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.allowed_algorithms_help",
"Headless Login JAR 검증은 이 목록에 있는 서명 알고리즘만 허용합니다.",
)}
</p>
</div>
</div>
{jwksSource === "uri" && (
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
<Label className="text-sm font-semibold">
<div className="space-y-2 pt-2">
<Label className="text-sm font-semibold" htmlFor="jwks-uri">
{t(
"ui.dev.clients.general.public_key.jwks_uri",
"JWKS URI",
@@ -1120,6 +1113,7 @@ function ClientGeneralPage() {
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="jwks-uri"
value={jwksUri}
onChange={(e) => setJwksUri(e.target.value)}
placeholder={t(
@@ -1134,35 +1128,250 @@ function ClientGeneralPage() {
)}
</p>
</div>
)}
</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 className="space-y-3 rounded-xl border border-border bg-card p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<Label className="text-sm font-bold">
{t(
"ui.dev.clients.general.public_key.cache.title",
"JWKS Cache",
)}
</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache_help",
"백엔드가 마지막으로 검증한 공개키 캐시 상태입니다.",
)}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
refreshHeadlessJwksCacheMutation.mutate()
}
disabled={refreshHeadlessJwksCacheMutation.isPending}
>
{refreshHeadlessJwksCacheMutation.isPending
? t("msg.common.requesting", "요청 중...")
: t("ui.common.refresh", "Refresh")}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => {
if (
!currentHeadlessJwksCache ||
revokeHeadlessJwksCacheMutation.isPending
) {
return;
}
const confirmed = window.confirm(
t(
"msg.dev.clients.general.public_key.cache_revoke_confirm",
"JWKS 캐시를 삭제하면 다음 검증 전에 다시 갱신해야 합니다. 계속할까요?",
),
);
if (confirmed) {
revokeHeadlessJwksCacheMutation.mutate();
}
}}
disabled={
!currentHeadlessJwksCache ||
revokeHeadlessJwksCacheMutation.isPending
}
>
{revokeHeadlessJwksCacheMutation.isPending
? t("msg.common.requesting", "요청 중...")
: t(
"ui.dev.clients.general.public_key.revoke_cache",
"Revoke Cache",
)}
</Button>
</div>
</div>
)}
{currentHeadlessJwksCache ? (
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.status",
"Status",
)}
</p>
<Badge
variant="info"
className="w-fit capitalize"
>
{currentHeadlessJwksCache.lastRefreshStatus ||
t("ui.common.unknown", "Unknown")}
</Badge>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.uri",
"JWKS URI",
)}
</p>
<p className="break-all font-mono text-xs">
{currentHeadlessJwksCache.jwksUri}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.cached_at",
"Cached At",
)}
</p>
<p>{formatDateTime(currentHeadlessJwksCache.cachedAt)}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.expires_at",
"Expires At",
)}
</p>
<p>{formatDateTime(currentHeadlessJwksCache.expiresAt)}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.last_checked_at",
"Last Checked",
)}
</p>
<p>
{formatDateTime(
currentHeadlessJwksCache.lastCheckedAt,
)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.last_success",
"Last Successful Verification",
)}
</p>
<p>
{formatDateTime(
currentHeadlessJwksCache.lastSuccessfulVerificationAt,
)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.failures",
"Consecutive Failures",
)}
</p>
<p>
{currentHeadlessJwksCache.consecutiveFailures ?? 0}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.kids",
"Cached KIDs",
)}
</p>
<p className="font-mono text-xs">
{currentHeadlessJwksCache.cachedKids?.length
? currentHeadlessJwksCache.cachedKids.join(", ")
: "-"}
</p>
</div>
<div className="space-y-1 md:col-span-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.error",
"Last Error",
)}
</p>
<p className="break-words text-xs text-muted-foreground">
{currentHeadlessJwksCache.lastError || "-"}
</p>
</div>
<div className="space-y-3 md:col-span-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.parsed_keys",
"Parsed Keys",
)}
</p>
<p className="text-[11px] text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache.parsed_keys_help",
"Raw JWKS stays hidden. Only parsed key metadata is shown here.",
)}
</p>
</div>
{currentHeadlessJwksCache.parsedKeys?.length ? (
<div className="grid gap-3 lg:grid-cols-2">
{currentHeadlessJwksCache.parsedKeys.map((key, index) => (
<div
key={`${key.kid || "key"}-${index}`}
className="rounded-xl border border-border bg-muted/30 p-3"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="font-mono">
{key.kid || "-"}
</Badge>
<Badge variant="outline" className="font-mono">
{key.kty || "-"}
</Badge>
<Badge variant="outline" className="font-mono">
{key.use || "-"}
</Badge>
<Badge variant="outline" className="font-mono">
{key.alg || "-"}
</Badge>
</div>
<div className="mt-3 space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
{t(
"ui.dev.clients.general.public_key.cache.parsed_key_n",
"n Preview",
)}
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{previewKeyMaterial(key.n)}
</p>
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache.parsed_keys_empty",
"No parsed JWKS keys are available yet.",
)}
</div>
)}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
{t(
"msg.dev.clients.general.public_key.cache_empty",
"아직 캐시된 JWKS가 없습니다. Refresh를 눌러 백엔드 캐시 상태를 조회하세요.",
)}
</div>
)}
</div>
</div>
{hasValidationErrors && (