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 && (

View File

@@ -12,7 +12,6 @@ export type ClientSummary = {
clientSecret?: string;
tokenEndpointAuthMethod?: string;
jwksUri?: string;
jwks?: string | Record<string, unknown>;
redirectUris: string[];
scopes: string[];
metadata?: Record<string, unknown>;
@@ -63,6 +62,27 @@ export type ClientDetailResponse = {
metadata?: Record<string, unknown>;
};
endpoints: ClientEndpoints;
headlessJwksCache?: {
clientId: string;
jwksUri: string;
cachedAt: string;
expiresAt: string;
lastCheckedAt?: string;
lastSuccessfulVerificationAt?: string;
lastRefreshStatus?: "success" | "failure" | "pending";
lastError?: string;
consecutiveFailures?: number;
cachedKids?: string[];
etag?: string;
lastModified?: string;
parsedKeys?: Array<{
kid?: string;
kty?: string;
use?: string;
alg?: string;
n?: string;
}>;
};
};
export type ClientUpsertRequest = {
@@ -76,7 +96,6 @@ export type ClientUpsertRequest = {
responseTypes?: string[];
tokenEndpointAuthMethod?: string;
jwksUri?: string;
jwks?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
@@ -182,6 +201,17 @@ export async function rotateClientSecret(clientId: string) {
return data;
}
export async function refreshHeadlessJwksCache(clientId: string) {
const { data } = await apiClient.post<ClientDetailResponse>(
`/dev/clients/${clientId}/headless-jwks/refresh`,
);
return data;
}
export async function revokeHeadlessJwksCache(clientId: string) {
await apiClient.delete(`/dev/clients/${clientId}/headless-jwks/cache`);
}
export async function deleteClient(clientId: string) {
await apiClient.delete(`/dev/clients/${clientId}`);
}

View File

@@ -1,125 +0,0 @@
/**
* 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, "");
}
/**
* 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
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();
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;
}

View File

@@ -402,11 +402,19 @@ guide_step_1 = "Generate a key pair on the RP server and keep the private key on
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 = "You can design your own login UI within the application. While the UI is yours, the actual identity verification and security checks are handled in the background via Baron's API."
jwks_inline_help = "Prefer the SSH-RSA public key format first. If you paste an 'ssh-rsa AAA...' key, Baron converts it to OIDC-standard JWKS (JSON) before saving."
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."
allowed_algorithms_help = "Headless login JAR verification accepts only the algorithms listed below."
subtitle = "Manage the public key and headless login settings required for Headless Login evaluation."
cache_empty = "No cached JWKS exists yet. Use Refresh to ask the backend to verify and cache the key."
cache_help = "Shows the last JWKS verification state stored by the backend."
cache_parsed_keys_help = "Raw JWKS stays hidden. Only parsed key metadata is shown here."
cache_parsed_keys_empty = "No parsed JWKS keys are available yet."
cache_refresh_failed = "Failed to refresh the JWKS cache: {{error}}"
cache_refreshed = "JWKS cache refreshed."
cache_revoke_confirm = "Deleting the JWKS cache means the backend must fetch and verify it again before the next use. Continue?"
cache_revoke_failed = "Failed to delete the JWKS cache: {{error}}"
cache_revoked = "JWKS cache deleted."
[msg.dev.clients.general.public_key.validation]
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
@@ -1407,16 +1415,25 @@ guide_toggle = "JWKS URI Setup Guide"
headless_disabled = "Headless Disabled"
headless_enabled = "Headless Enabled"
headless_toggle = "Headless Login"
jwks_inline = "SSH-RSA or JWKS Public Key"
jwks_inline_placeholder = "Paste an 'ssh-rsa AAA...' public key first. JWKS (JSON) is also accepted if needed."
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"
allowed_algorithms = "Allowed Algorithms"
title = "Public Key Registration"
validation_title = "Check before saving"
cache_error = "Last Error"
cache_cached_at = "Cached At"
cache_expires_at = "Expires At"
cache_failures = "Consecutive Failures"
cache_kids = "Cached KIDs"
cache_last_checked_at = "Last Checked"
cache_last_success = "Last Successful Verification"
cache_parsed_keys = "Parsed Keys"
cache_parsed_key_n = "n Preview"
cache_status = "Status"
cache_uri = "JWKS URI"
revoke_cache = "Revoke Cache"
[ui.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."

View File

@@ -402,18 +402,25 @@ guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backe
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 = "애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다."
jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa AAA...' 형식으로 입력하면 Baron이 OIDC 표준인 JWKS(JSON)로 자동 변환하여 저장합니다."
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 = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다."
allowed_algorithms_help = "Headless Login JAR 검증은 아래 알고리즘만 허용합니다."
subtitle = "Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다."
cache_empty = "아직 캐시된 JWKS가 없습니다. Refresh를 눌러 백엔드가 공개키를 검증하고 캐시하도록 요청하세요."
cache_help = "백엔드가 저장한 마지막 JWKS 검증 상태를 보여줍니다."
cache_parsed_keys_help = "원본 JWKS 전체는 숨기고, 파싱된 키 메타데이터만 보여줍니다."
cache_parsed_keys_empty = "아직 파싱된 JWKS 키가 없습니다."
cache_refresh_failed = "JWKS 캐시 새로고침에 실패했습니다: {{error}}"
cache_refreshed = "JWKS 캐시를 새로 고쳤습니다."
cache_revoke_confirm = "JWKS 캐시를 삭제하면 다음 사용 전에 백엔드가 다시 가져와 검증해야 합니다. 계속할까요?"
cache_revoke_failed = "JWKS 캐시 삭제에 실패했습니다: {{error}}"
cache_revoked = "JWKS 캐시를 삭제했습니다."
[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 형식이 올바르지 않습니다."
missing_jwks_inline = "공개키(SSH-RSA 또는 JWKS)를 입력해야 합니다."
private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다."
[msg.dev.clients.help]
@@ -1407,16 +1414,25 @@ guide_toggle = "JWKS URI 준비 가이드"
headless_disabled = "Headless Disabled"
headless_enabled = "Headless Enabled"
headless_toggle = "Headless Login"
jwks_inline = "SSH-RSA 또는 JWKS 공개키"
jwks_inline_placeholder = "'ssh-rsa AAA...' 형식의 공개키를 먼저 붙여넣으세요. 필요하면 JWKS (JSON)도 입력할 수 있습니다."
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"
allowed_algorithms = "허용 알고리즘"
title = "공개키 등록"
validation_title = "저장 전 확인 필요"
cache_error = "마지막 오류"
cache_cached_at = "캐시 시각"
cache_expires_at = "만료 시각"
cache_failures = "연속 실패 횟수"
cache_kids = "캐시된 KID"
cache_last_checked_at = "마지막 확인"
cache_last_success = "마지막 성공 검증"
cache_parsed_keys = "파싱된 키"
cache_parsed_key_n = "n 미리보기"
cache_status = "상태"
cache_uri = "JWKS URI"
revoke_cache = "캐시 삭제"
[ui.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."

View File

@@ -402,11 +402,19 @@ guide_step_1 = ""
guide_step_2 = ""
guide_step_3 = ""
headless_help = ""
jwks_inline_help = ""
jwks_uri_help = ""
request_object_alg_help = ""
source_help = ""
allowed_algorithms_help = ""
subtitle = ""
cache_empty = ""
cache_help = ""
cache_parsed_keys_help = ""
cache_parsed_keys_empty = ""
cache_refresh_failed = ""
cache_refreshed = ""
cache_revoke_confirm = ""
cache_revoke_failed = ""
cache_revoked = ""
[msg.dev.clients.general.public_key.validation]
headless_requires_alg = ""
@@ -1406,16 +1414,25 @@ guide_toggle = ""
headless_disabled = ""
headless_enabled = ""
headless_toggle = ""
jwks_inline = ""
jwks_inline_placeholder = ""
jwks_uri = ""
jwks_uri_placeholder = ""
request_object_alg = ""
request_object_alg_placeholder = ""
source = ""
source_uri = ""
allowed_algorithms = ""
title = ""
validation_title = ""
cache_error = ""
cache_cached_at = ""
cache_expires_at = ""
cache_failures = ""
cache_kids = ""
cache_last_checked_at = ""
cache_last_success = ""
cache_parsed_keys = ""
cache_parsed_key_n = ""
cache_status = ""
cache_uri = ""
revoke_cache = ""
[ui.dev.clients.help]
docs_body = ""

View File

@@ -8,8 +8,7 @@ import {
} from "./helpers/devfront-fixtures";
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
const sshRsaPublicKey =
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAABwECAwQFBgc= test@example";
const jwksUri = "https://rp.example.com/.well-known/jwks.json";
test.describe("DevFront clients lifecycle", () => {
test.beforeEach(async ({ page }) => {
@@ -123,7 +122,7 @@ test.describe("DevFront clients lifecycle", () => {
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
});
test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({
test("pkce headless login uses jwks uri only and shows cache actions", async ({
page,
}) => {
const state = {
@@ -131,10 +130,44 @@ test.describe("DevFront clients lifecycle", () => {
makeClient("client-headless-login", {
name: "Headless Login App",
type: "pkce",
headlessJwksCache: {
clientId: "client-headless-login",
jwksUri,
cachedAt: "2026-03-31T00:00:00.000Z",
expiresAt: "2026-04-01T00:00:00.000Z",
lastCheckedAt: "2026-03-31T12:00:00.000Z",
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
lastRefreshStatus: "success",
lastError: "",
consecutiveFailures: 0,
cachedKids: ["kid-1"],
etag: 'W/"cache-etag"',
lastModified: "Tue, 31 Mar 2026 00:00:00 GMT",
parsedKeys: [
{
kid: "kid-1",
kty: "RSA",
use: "sig",
alg: "RS256",
n: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
},
],
},
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
onRefreshHeadlessJwks(clientId: string) {
this.clients[0].headlessJwksCache = {
...this.clients[0].headlessJwksCache!,
lastRefreshStatus: "success",
lastCheckedAt: "2026-04-01T00:00:00.000Z",
};
expect(clientId).toBe("client-headless-login");
},
onRevokeHeadlessJwksCache(clientId: string) {
expect(clientId).toBe("client-headless-login");
},
};
await installDevApiMock(page, state);
@@ -147,16 +180,15 @@ test.describe("DevFront clients lifecycle", () => {
.click();
await expect(
page.getByRole("heading", {
name: /공개키 등록|Public Key Registration/i,
}),
page.getByRole("heading", { name: /공개키 등록|Public Key Registration/i }),
).toBeVisible();
await expect(
page.getByRole("radio", { name: /Inline Public Key|Inline/i }),
).toHaveCount(0);
await page
.getByPlaceholder(
/ssh-rsa AAA\.\.\.|Paste an 'ssh-rsa AAA\.\.\.' public key first/i,
)
.fill(sshRsaPublicKey);
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
.fill(jwksUri);
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
@@ -171,25 +203,57 @@ test.describe("DevFront clients lifecycle", () => {
)
.toBe("private_key_jwt");
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.headless_jwks as {
keys?: Array<{ kty?: string; alg?: string }>;
}
)?.keys?.[0]?.kty,
)
.toBe("RSA");
.poll(() => state.clients[0]?.metadata?.headless_jwks_uri)
.toBe(jwksUri);
await expect(
page.getByText(/cached at|캐시됨|last refresh|마지막 갱신/i),
).toBeVisible();
await expect(page.getByText(/Parsed Keys|파싱된 키/i)).toBeVisible();
await expect(page.getByText("kid-1", { exact: true }).last()).toBeVisible();
await expect(
page.getByText(/Allowed algorithms|허용 알고리즘/i),
).toBeVisible();
for (const algorithm of [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"EdDSA",
]) {
await expect(page.getByText(algorithm, { exact: true }).last()).toBeVisible();
}
await expect(
page.getByText("abcdefghij...0123456789", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /refresh|새로고침/i }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i }),
).toBeVisible();
await page.getByRole("button", { name: /refresh|새로고침/i }).click();
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.headless_jwks as {
keys?: Array<{ kty?: string; alg?: string }>;
}
)?.keys?.[0]?.alg,
)
.toBe("RS256");
.poll(() => state.clients[0]?.headlessJwksCache?.lastCheckedAt)
.toBe("2026-04-01T00:00:00.000Z");
page.removeAllListeners("dialog");
page.once("dialog", async (dialog) => {
expect(dialog.message()).toMatch(/revoke|삭제|cache/i);
await dialog.accept();
});
await page
.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i })
.click();
await expect
.poll(() => state.clients[0]?.headlessJwksCache)
.toBeUndefined();
await page.reload();
await expect(
@@ -197,10 +261,6 @@ test.describe("DevFront clients lifecycle", () => {
name: /공개키 등록|Public Key Registration/i,
}),
).toBeVisible();
await expect(
page.getByPlaceholder(
/ssh-rsa AAA\.\.\.|Paste an 'ssh-rsa AAA\.\.\.' public key first/i,
),
).toHaveValue(/"kty": "RSA"/);
await expect(page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i })).toHaveValue(jwksUri);
});
});

View File

@@ -15,6 +15,27 @@ export type Client = {
tokenEndpointAuthMethod?: string;
jwksUri?: string;
jwks?: Record<string, unknown> | string;
headlessJwksCache?: {
clientId: string;
jwksUri: string;
cachedAt: string;
expiresAt: string;
lastCheckedAt?: string;
lastSuccessfulVerificationAt?: string;
lastRefreshStatus?: "success" | "failure" | "pending";
lastError?: string;
consecutiveFailures?: number;
cachedKids?: string[];
etag?: string;
lastModified?: string;
parsedKeys?: Array<{
kid?: string;
kty?: string;
use?: string;
alg?: string;
n?: string;
}>;
};
metadata?: Record<string, unknown>;
};
@@ -53,6 +74,8 @@ export type DevApiMockState = {
auditLogs?: AuditLog[];
onUpdateStatus?: (status: ClientStatus) => void;
onRotateSecret?: (newSecret: string) => void;
onRefreshHeadlessJwks?: (clientId: string) => void;
onRevokeHeadlessJwksCache?: (clientId: string) => void;
};
export function makeClient(
@@ -337,13 +360,6 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
});
}
if (pathname.startsWith("/api/v1/dev/clients/") && method === "DELETE") {
const clientId = parseClientId(pathname);
state.clients = state.clients.filter((client) => client.id !== clientId);
appendAuditLog("CLIENT_DELETE", "DELETE_CLIENT", clientId);
return route.fulfill({ status: 204 });
}
if (pathname.startsWith("/api/v1/dev/clients/") && method === "GET") {
const clientId = parseClientId(pathname);
const found = state.clients.find((client) => client.id === clientId);
@@ -357,9 +373,56 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
token: "https://issuer/oauth2/token",
userinfo: "https://issuer/userinfo",
},
headlessJwksCache: found.headlessJwksCache,
});
}
if (
pathname.startsWith("/api/v1/dev/clients/") &&
pathname.endsWith("/headless-jwks/cache") &&
method === "DELETE"
) {
const clientId = pathname.split("/")[5] ?? "";
const found = state.clients.find((client) => client.id === clientId);
if (!found) return json(route, { error: "not found" }, 404);
found.headlessJwksCache = undefined;
state.onRevokeHeadlessJwksCache?.(clientId);
return route.fulfill({ status: 204 });
}
if (
pathname.startsWith("/api/v1/dev/clients/") &&
pathname.endsWith("/headless-jwks/refresh") &&
method === "POST"
) {
const clientId = pathname.split("/")[5] ?? "";
const found = state.clients.find((client) => client.id === clientId);
if (!found) return json(route, { error: "not found" }, 404);
state.onRefreshHeadlessJwks?.(clientId);
return json(route, {
client: found,
endpoints: {
discovery: "https://issuer/.well-known/openid-configuration",
issuer: "https://issuer",
authorization: "https://issuer/oauth2/auth",
token: "https://issuer/oauth2/token",
userinfo: "https://issuer/userinfo",
},
headlessJwksCache: found.headlessJwksCache,
});
}
if (
pathname.startsWith("/api/v1/dev/clients/") &&
!pathname.endsWith("/headless-jwks/cache") &&
method === "DELETE"
) {
const clientId = parseClientId(pathname);
state.clients = state.clients.filter((client) => client.id !== clientId);
appendAuditLog("CLIENT_DELETE", "DELETE_CLIENT", clientId);
return route.fulfill({ status: 204 });
}
if (pathname === "/api/v1/dev/consents" && method === "GET") {
const subject = searchParams.get("subject") || "";
const clientId = searchParams.get("client_id") || "";