diff --git a/backend/internal/domain/hydra_models.go b/backend/internal/domain/hydra_models.go index 53f9d37c..596523a1 100644 --- a/backend/internal/domain/hydra_models.go +++ b/backend/internal/domain/hydra_models.go @@ -10,6 +10,7 @@ const ( MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method" MetadataHeadlessJWKSURI = "headless_jwks_uri" MetadataHeadlessJWKS = "headless_jwks" + MetadataRequestObjectSigningAlg = "request_object_signing_alg" ) type HydraClient struct { diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index fa6be2a3..745f3f98 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -1105,7 +1105,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri) resolvedJWKS := req.Jwks if req.Jwks == nil { - resolvedJWKS = current.JWKS + if resolvedClientType == "pkce" && readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { + resolvedJWKS = nil + } else { + resolvedJWKS = current.JWKS + } } if err := validateHeadlessClientInput(resolvedClientType, resolvedJWKSURI, resolvedJWKS, metadata); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) @@ -1909,6 +1913,7 @@ func normalizeHeadlessClientConfig( if metadata == nil { metadata = map[string]interface{}{} } + delete(metadata, domain.MetadataRequestObjectSigningAlg) headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) if clientType == "pkce" && headlessEnabled { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index c694c35a..e9aa59eb 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -693,7 +693,8 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) { assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) assert.True(t, captured.IsHeadlessLoginEnabled()) assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) - assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"]) + _, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"] + assert.False(t, hasRequestObjectAlg) } func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) { @@ -773,10 +774,11 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) { "scope": "openid profile", "token_endpoint_auth_method": "none", "metadata": map[string]any{ - "status": "active", - "headless_jwks": map[string]any{"keys": []map[string]any{}}, - "headless_jwks_uri": "https://stale.example.com/old.json", - "headless_login_enabled": true, + "status": "active", + "headless_jwks": map[string]any{"keys": []map[string]any{}}, + "headless_jwks_uri": "https://stale.example.com/old.json", + "headless_login_enabled": true, + "request_object_signing_alg": "RS256", }, }), nil } @@ -838,10 +840,100 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) { assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) _, hasInlineJWKS := captured.Metadata["headless_jwks"] assert.False(t, hasInlineJWKS) + _, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"] + assert.False(t, hasRequestObjectAlg) assert.True(t, captured.IsHeadlessLoginEnabled()) assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) } +func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(t *testing.T) { + var captured domain.HydraClient + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": "client-headless-login", + "client_name": "Headless Login Before", + "redirect_uris": []string{"https://before.example.com/callback"}, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "scope": "openid profile", + "token_endpoint_auth_method": "none", + "jwks": map[string]any{ + "keys": []map[string]any{{ + "kty": "RSA", + "alg": "RS256", + "n": "AQIDBAUGBw", + "e": "AQAB", + }}, + }, + "metadata": map[string]any{ + "status": "active", + "headless_login_enabled": true, + "headless_jwks_uri": "https://stale.example.com/old.json", + "request_object_signing_alg": "RS256", + }, + }), nil + } + if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + err = json.Unmarshal(body, &captured) + assert.NoError(t, err) + + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": captured.ClientID, + "client_name": captured.ClientName, + "redirect_uris": captured.RedirectURIs, + "grant_types": captured.GrantTypes, + "response_types": captured.ResponseTypes, + "scope": captured.Scope, + "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, + "jwks_uri": captured.JWKSUri, + "metadata": captured.Metadata, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + PublicURL: "http://hydra.public", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: new(devMockKetoService), + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Put("/api/v1/dev/clients/:id", h.UpdateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "Headless Login After", + "type": "pkce", + "tokenEndpointAuthMethod": "private_key_jwt", + "jwksUri": "https://rp.example.com/.well-known/jwks.json", + "metadata": map[string]any{ + "headless_login_enabled": true, + "request_object_signing_alg": "RS256", + }, + }) + req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Nil(t, captured.JWKS) + assert.Equal(t, "", captured.JWKSUri) + assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) + _, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"] + assert.False(t, hasRequestObjectAlg) +} + func TestRefreshHeadlessJWKSCache_ReturnsUpdatedCacheState(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) _ = privateKey diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 76a93d35..dd16220c 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -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( + 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("client_secret_basic"); const [jwksUri, setJwksUri] = useState(""); - const [requestObjectSigningAlg, setRequestObjectSigningAlg] = - useState("RS256"); const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false); const [scopes, setScopes] = useState(() => [ @@ -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() { -
+
- - setRequestObjectSigningAlg(e.target.value)} - placeholder={t( - "ui.dev.clients.general.public_key.request_object_alg_placeholder", - "예: RS256", - )} - /> -

- {t( - "msg.dev.clients.general.public_key.request_object_alg_help", - "Headless Login을 사용할 때 JAR(Request Object) 서명 검증에 사용할 알고리즘을 명시합니다.", - )} -

-
-

+

+
-
- -
-
+ {(unsupportedParsedAlgorithms.length > 0 || + missingParsedAlgorithms.length > 0) && ( +
+

+ {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", + "알고리즘이 선언되지 않았습니다.", + )} +

+

+ {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 }, + )} +

+
+ )}

@@ -1314,58 +1346,101 @@ function ClientGeneralPage() {

{currentHeadlessJwksCache.parsedKeys?.length ? (
- {currentHeadlessJwksCache.parsedKeys.map((key, index) => ( -
-
-
-

- KID -

-

- {key.kid || "-"} -

+ {currentHeadlessJwksCache.parsedKeys.map((key, index) => { + const normalizedAlgorithm = key.alg?.trim() ?? ""; + const isMissingAlgorithm = + normalizedAlgorithm === ""; + const isUnsupportedAlgorithm = + !isMissingAlgorithm && + !HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has( + normalizedAlgorithm, + ); + + return ( +
+
+
+

+ KID +

+

+ {key.kid || "-"} +

+
+
+

+ KTY +

+

+ {key.kty || "-"} +

+
+
+

+ USE +

+

+ {key.use || "-"} +

+
+
+

+ ALG +

+

+ {key.alg || + t( + "msg.dev.clients.general.public_key.cache.missing_algorithm_badge", + "알고리즘 미선언", + )} +

+ {isMissingAlgorithm && ( +

+ {t( + "msg.dev.clients.general.public_key.cache.missing_algorithm_reason", + "이 키는 `alg`가 비어 있어서 저장할 수 없습니다.", + )} +

+ )} + {isUnsupportedAlgorithm && ( +

+ {t( + "msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason", + "이 알고리즘은 Headless Login에서 지원되지 않습니다.", + )} +

+ )} +
-
+

- KTY + {t( + "ui.dev.clients.general.public_key.cache.parsed_key_n", + "N", + )}

-

- {key.kty || "-"} -

-
-
-

- USE -

-

- {key.use || "-"} -

-
-
-

- ALG -

-

- {key.alg || "-"} +

+ {key.n || "-"}

-
-

- {t( - "ui.dev.clients.general.public_key.cache.parsed_key_n", - "N", - )} -

-

- {key.n || "-"} -

-
-
- ))} + ); + })}
) : (
diff --git a/devfront/tests/devfront-clients-lifecycle.spec.ts b/devfront/tests/devfront-clients-lifecycle.spec.ts index 80a0d573..a67bff76 100644 --- a/devfront/tests/devfront-clients-lifecycle.spec.ts +++ b/devfront/tests/devfront-clients-lifecycle.spec.ts @@ -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(); + }); });