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:
@@ -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 && (
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") || "";
|
||||
|
||||
Reference in New Issue
Block a user