1
0
forked from baron/baron-sso

fix(headless-login): simplify jwks policy checks

This commit is contained in:
Lectom C Han
2026-04-01 19:24:26 +09:00
parent 51f09bf53c
commit c3ae316570
5 changed files with 409 additions and 146 deletions

View File

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowLeft,
Info,
Plus,
Save,
Shield,
@@ -68,6 +69,18 @@ const HEADLESS_LOGIN_ALLOWED_ALGORITHMS = [
"EdDSA",
] as const;
const HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET = new Set<string>(
HEADLESS_LOGIN_ALLOWED_ALGORITHMS,
);
function formatHeadlessParsedKeyLabel(kid: string | undefined, index: number): string {
const trimmedKid = kid?.trim();
if (trimmedKid) {
return trimmedKid;
}
return `key #${index + 1}`;
}
function isTokenEndpointAuthMethod(
value: string,
): value is TokenEndpointAuthMethod {
@@ -126,8 +139,6 @@ function ClientGeneralPage() {
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
useState<TokenEndpointAuthMethod>("client_secret_basic");
const [jwksUri, setJwksUri] = useState("");
const [requestObjectSigningAlg, setRequestObjectSigningAlg] =
useState("RS256");
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
@@ -209,16 +220,6 @@ function ClientGeneralPage() {
}
}
const savedRequestObjectSigningAlg = readMetadataString(
metadata,
"request_object_signing_alg",
);
if (savedRequestObjectSigningAlg) {
setRequestObjectSigningAlg(savedRequestObjectSigningAlg);
} else if (savedAuthMethod === "private_key_jwt") {
setRequestObjectSigningAlg("RS256");
}
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
if (savedScopes && Array.isArray(savedScopes)) {
setScopes(savedScopes);
@@ -252,9 +253,6 @@ function ClientGeneralPage() {
setHeadlessLoginEnabled(enabled);
if (clientType === "pkce") {
setTokenEndpointAuthMethod(enabled ? "private_key_jwt" : "none");
if (enabled && requestObjectSigningAlg.trim() === "") {
setRequestObjectSigningAlg("RS256");
}
}
};
@@ -299,7 +297,40 @@ function ClientGeneralPage() {
const validationErrors: string[] = [];
const trimmedJwksUri = jwksUri.trim();
const trimmedRequestObjectSigningAlg = requestObjectSigningAlg.trim();
const currentHeadlessJwksCache = data?.headlessJwksCache;
const parsedKeysForCurrentJwksUri =
headlessLoginEnabled &&
trimmedJwksUri !== "" &&
currentHeadlessJwksCache?.jwksUri === trimmedJwksUri
? currentHeadlessJwksCache.parsedKeys ?? []
: [];
const unsupportedParsedAlgorithms = parsedKeysForCurrentJwksUri
.map((key, index) => ({
alg: key.alg?.trim() ?? "",
label: formatHeadlessParsedKeyLabel(key.kid, index),
}))
.filter(
(entry) =>
entry.alg !== "" &&
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(entry.alg),
);
const missingParsedAlgorithms = parsedKeysForCurrentJwksUri
.map((key, index) => ({
alg: key.alg?.trim() ?? "",
label: formatHeadlessParsedKeyLabel(key.kid, index),
}))
.filter((entry) => entry.alg === "");
const unsupportedParsedAlgorithmSummary = unsupportedParsedAlgorithms
.map((entry) => `${entry.label}: ${entry.alg}`)
.join(", ");
const missingParsedAlgorithmSummary = missingParsedAlgorithms
.map((entry) => entry.label)
.join(", ");
const allowedHeadlessAlgorithmsTooltip = t(
"msg.dev.clients.general.public_key.allowed_algorithms_tooltip",
"허용 알고리즘: {{algorithms}}",
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
);
if (headlessLoginEnabled) {
if (!trimmedJwksUri) {
@@ -317,19 +348,27 @@ function ClientGeneralPage() {
),
);
}
if (trimmedRequestObjectSigningAlg === "") {
if (unsupportedParsedAlgorithms.length > 0) {
validationErrors.push(
t(
"msg.dev.clients.general.public_key.validation.headless_requires_alg",
"Request Object Signing Algorithm (예: RS256)을 입력해야 합니다.",
"msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms",
"JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}",
{ details: unsupportedParsedAlgorithmSummary },
),
);
}
if (missingParsedAlgorithms.length > 0) {
validationErrors.push(
t(
"msg.dev.clients.general.public_key.validation.missing_parsed_algorithms",
"JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}",
{ details: missingParsedAlgorithmSummary },
),
);
}
}
const hasValidationErrors = validationErrors.length > 0;
const currentHeadlessJwksCache = data?.headlessJwksCache;
const refreshHeadlessJwksCacheMutation = useMutation({
mutationFn: async () => {
@@ -418,7 +457,6 @@ function ClientGeneralPage() {
logo_url: logoUrl,
structured_scopes: scopes,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
request_object_signing_alg: trimmedRequestObjectSigningAlg,
headless_login_enabled: headlessLoginEnabled,
headless_token_endpoint_auth_method:
clientType === "pkce" && headlessLoginEnabled
@@ -1046,66 +1084,31 @@ function ClientGeneralPage() {
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-4">
<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",
)}
/>
<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">
<div className="flex items-center gap-2">
<Label className="text-sm font-semibold" htmlFor="jwks-uri">
{t(
"ui.dev.clients.general.public_key.allowed_algorithms",
"Allowed Algorithms",
"ui.dev.clients.general.public_key.jwks_uri",
"JWKS URI",
)}
</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 검증은 이 목록에 있는 서명 알고리즘만 허용합니다.",
<span className="text-destructive ml-1">*</span>
</Label>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
title={allowedHeadlessAlgorithmsTooltip}
aria-label={t(
"ui.dev.clients.general.public_key.allowed_algorithms_info",
"Headless Login 허용 알고리즘 정보",
)}
</p>
>
<Info className="h-4 w-4" />
</Button>
</div>
</div>
<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",
)}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="jwks-uri"
value={jwksUri}
@@ -1297,6 +1300,35 @@ function ClientGeneralPage() {
{currentHeadlessJwksCache.lastError || "-"}
</p>
</div>
{(unsupportedParsedAlgorithms.length > 0 ||
missingParsedAlgorithms.length > 0) && (
<div className="space-y-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 md:col-span-2">
<p className="text-sm font-semibold text-destructive">
{unsupportedParsedAlgorithms.length > 0
? t(
"msg.dev.clients.general.public_key.cache.unsupported_algorithms_title",
"지원하지 않는 알고리즘이 감지되었습니다.",
)
: t(
"msg.dev.clients.general.public_key.cache.missing_algorithms_title",
"알고리즘이 선언되지 않았습니다.",
)}
</p>
<p className="text-xs text-destructive">
{unsupportedParsedAlgorithms.length > 0
? t(
"msg.dev.clients.general.public_key.cache.unsupported_algorithms_help",
"저장 전 JWKS를 수정해 주세요: {{details}}",
{ details: unsupportedParsedAlgorithmSummary },
)
: t(
"msg.dev.clients.general.public_key.cache.missing_algorithms_help",
"저장 전 JWKS 각 키에 `alg`를 명시해 주세요: {{details}}",
{ details: missingParsedAlgorithmSummary },
)}
</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">
@@ -1314,58 +1346,101 @@ function ClientGeneralPage() {
</div>
{currentHeadlessJwksCache.parsedKeys?.length ? (
<div className="space-y-3">
{currentHeadlessJwksCache.parsedKeys.map((key, index) => (
<div
key={`${key.kid || "key"}-${index}`}
className="rounded-xl border border-border bg-muted/30 p-3"
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
KID
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.kid || "-"}
</p>
{currentHeadlessJwksCache.parsedKeys.map((key, index) => {
const normalizedAlgorithm = key.alg?.trim() ?? "";
const isMissingAlgorithm =
normalizedAlgorithm === "";
const isUnsupportedAlgorithm =
!isMissingAlgorithm &&
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(
normalizedAlgorithm,
);
return (
<div
key={`${key.kid || "key"}-${index}`}
className={cn(
"rounded-xl border bg-muted/30 p-3",
isUnsupportedAlgorithm || isMissingAlgorithm
? "border-destructive/50 bg-destructive/5"
: "border-border",
)}
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
KID
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.kid || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
KTY
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.kty || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
USE
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.use || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
ALG
</p>
<p
className={cn(
"break-all rounded-lg border bg-background px-3 py-2 font-mono text-[11px]",
isUnsupportedAlgorithm || isMissingAlgorithm
? "border-destructive/50 text-destructive"
: "border-border",
)}
>
{key.alg ||
t(
"msg.dev.clients.general.public_key.cache.missing_algorithm_badge",
"알고리즘 미선언",
)}
</p>
{isMissingAlgorithm && (
<p className="text-[11px] text-destructive">
{t(
"msg.dev.clients.general.public_key.cache.missing_algorithm_reason",
"이 키는 `alg`가 비어 있어서 저장할 수 없습니다.",
)}
</p>
)}
{isUnsupportedAlgorithm && (
<p className="text-[11px] text-destructive">
{t(
"msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason",
"이 알고리즘은 Headless Login에서 지원되지 않습니다.",
)}
</p>
)}
</div>
</div>
<div className="space-y-1">
<div className="mt-3 space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
KTY
{t(
"ui.dev.clients.general.public_key.cache.parsed_key_n",
"N",
)}
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.kty || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
USE
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.use || "-"}
</p>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
ALG
</p>
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
{key.alg || "-"}
<p className="min-h-16 break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px] leading-5">
{key.n || "-"}
</p>
</div>
</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",
)}
</p>
<p className="min-h-16 break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px] leading-5">
{key.n || "-"}
</p>
</div>
</div>
))}
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-muted-foreground">

View File

@@ -130,6 +130,9 @@ test.describe("DevFront clients lifecycle", () => {
makeClient("client-headless-login", {
name: "Headless Login App",
type: "pkce",
metadata: {
request_object_signing_alg: "RS256",
},
headlessJwksCache: {
clientId: "client-headless-login",
jwksUri,
@@ -185,8 +188,9 @@ test.describe("DevFront clients lifecycle", () => {
).toBeVisible();
await expect(
page.getByRole("radio", { name: /Inline Public Key|Inline/i }),
page.getByText(/Request Object Signing Algorithm/i),
).toHaveCount(0);
await expect(page.getByText(/Allowed algorithms|허용 알고리즘/i)).toHaveCount(0);
await page
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
.fill(jwksUri);
@@ -206,6 +210,9 @@ test.describe("DevFront clients lifecycle", () => {
await expect
.poll(() => state.clients[0]?.metadata?.headless_jwks_uri)
.toBe(jwksUri);
await expect
.poll(() => state.clients[0]?.metadata?.request_object_signing_alg)
.toBeUndefined();
await expect(
page.getByText(/cached at|캐시됨|last refresh|마지막 갱신/i),
@@ -213,23 +220,6 @@ test.describe("DevFront clients lifecycle", () => {
await expect(page.getByText(/Parsed Keys|파싱된 키/i)).toBeVisible();
await expect(page.getByText(/^KID$/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(
"voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ",
@@ -268,4 +258,104 @@ test.describe("DevFront clients lifecycle", () => {
).toBeVisible();
await expect(page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i })).toHaveValue(jwksUri);
});
test("pkce headless login blocks save when parsed jwks algorithm is unsupported", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-headless-unsupported", {
name: "Unsupported Headless Login App",
type: "pkce",
metadata: {
headless_login_enabled: true,
request_object_signing_alg: "RS256",
},
headlessJwksCache: {
clientId: "client-headless-unsupported",
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-unsupported"],
parsedKeys: [
{
kid: "kid-unsupported",
kty: "RSA",
use: "sig",
alg: "HS256",
n: "unsupported-n-value",
},
],
},
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("/clients/client-headless-unsupported/settings");
await page
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
.fill(jwksUri);
await expect(
page.getByText("지원하지 않는 알고리즘이 감지되었습니다.", { exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: /^저장$|^Save$/i })).toBeDisabled();
});
test("pkce headless login blocks save when parsed jwks algorithm is missing", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-headless-missing-alg", {
name: "Missing Alg Headless Login App",
type: "pkce",
metadata: {
headless_login_enabled: true,
headless_jwks_uri: jwksUri,
},
headlessJwksCache: {
clientId: "client-headless-missing-alg",
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-missing-alg"],
parsedKeys: [
{
kid: "kid-missing-alg",
kty: "RSA",
use: "sig",
alg: "",
n: "missing-alg-n-value",
},
],
},
}),
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("/clients/client-headless-missing-alg/settings");
await expect(
page.getByText(/알고리즘이 선언되지 않았습니다|algorithm is missing/i),
).toBeVisible();
await expect(page.getByRole("button", { name: /^저장$|^Save$/i })).toBeDisabled();
});
});