From 9f78698f54a84fd02a2d33f0d5a8f7d4754bb902 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 4 May 2026 14:58:31 +0900 Subject: [PATCH] =?UTF-8?q?headless=20login=20SSA=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/dev_handler.go | 60 ++++++------ backend/internal/handler/dev_handler_test.go | 95 +++++++++++++++++-- .../features/clients/ClientGeneralPage.tsx | 9 +- 3 files changed, 125 insertions(+), 39 deletions(-) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 1fbdb4a8..0b32a39a 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -1648,17 +1648,6 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private") } - // [Security] Check permission for private clients - if clientType == "private" { - isAppManager, err := h.checkAppManagerPermission(c) - if err != nil { - return errorJSON(c, fiber.StatusInternalServerError, "permission check error") - } - if !isAppManager && !h.canManageTenantClientsByPermit(c, profile, tenantID) { - return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client") - } - } - status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active"))) if status != "active" && status != "inactive" { return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") @@ -1700,6 +1689,18 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } + clientType = normalizeClientTypeForHeadless(clientType, metadata) + + // [Security] Check permission for private clients + if clientType == "private" { + isAppManager, err := h.checkAppManagerPermission(c) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "permission check error") + } + if !isAppManager && !h.canManageTenantClientsByPermit(c, profile, tenantID) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client") + } + } tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) if tokenAuthMethod == "" { @@ -1709,11 +1710,10 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { tokenAuthMethod = "client_secret_basic" } } - if err := validateHeadlessClientInput(clientType, valueOr(req.JwksUri, ""), req.Jwks, metadata); err != nil { + if err := validateHeadlessClientInput(valueOr(req.JwksUri, ""), req.Jwks, metadata); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig( - clientType, tokenAuthMethod, valueOr(req.JwksUri, ""), req.Jwks, @@ -1900,21 +1900,21 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { if clientType != "" { resolvedClientType = clientType } + resolvedClientType = normalizeClientTypeForHeadless(resolvedClientType, metadata) resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod) resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri) resolvedJWKS := req.Jwks if req.Jwks == nil { - if resolvedClientType == "pkce" && readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { + if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { resolvedJWKS = nil } else { resolvedJWKS = current.JWKS } } - if err := validateHeadlessClientInput(resolvedClientType, resolvedJWKSURI, resolvedJWKS, metadata); err != nil { + if err := validateHeadlessClientInput(resolvedJWKSURI, resolvedJWKS, metadata); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig( - resolvedClientType, resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, @@ -2633,12 +2633,10 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { } clientType := "private" - if strings.EqualFold(client.TokenEndpointAuthMethod, "none") { + if client.IsHeadlessLoginEnabled() { + clientType = "private" + } else if strings.EqualFold(client.TokenEndpointAuthMethod, "none") { clientType = "pkce" - } else if strings.EqualFold(client.TokenEndpointAuthMethod, "private_key_jwt") && client.Metadata != nil { - if val, ok := client.Metadata["headless_login_enabled"].(bool); ok && val { - clientType = "pkce" - } } name := strings.TrimSpace(client.ClientName) @@ -2786,7 +2784,6 @@ func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[stri } func normalizeHeadlessClientConfig( - clientType string, tokenAuthMethod string, jwksURI string, jwks interface{}, @@ -2798,12 +2795,12 @@ func normalizeHeadlessClientConfig( delete(metadata, domain.MetadataRequestObjectSigningAlg) headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) - if clientType == "pkce" && headlessEnabled { + if headlessEnabled { headlessTokenAuthMethod := readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod) - if headlessTokenAuthMethod == "" && !strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "none") { + if headlessTokenAuthMethod == "" && strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "private_key_jwt") { headlessTokenAuthMethod = strings.TrimSpace(tokenAuthMethod) } - if headlessTokenAuthMethod == "" { + if headlessTokenAuthMethod == "" || strings.EqualFold(headlessTokenAuthMethod, "none") { headlessTokenAuthMethod = "private_key_jwt" } metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod @@ -2820,7 +2817,7 @@ func normalizeHeadlessClientConfig( delete(metadata, domain.MetadataHeadlessJWKS) - return "none", "", nil, metadata + return headlessTokenAuthMethod, headlessJWKSURI, nil, metadata } delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod) @@ -2829,8 +2826,8 @@ func normalizeHeadlessClientConfig( return tokenAuthMethod, jwksURI, jwks, metadata } -func validateHeadlessClientInput(clientType string, jwksURI string, jwks interface{}, metadata map[string]interface{}) error { - if clientType != "pkce" || !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { +func validateHeadlessClientInput(jwksURI string, jwks interface{}, metadata map[string]interface{}) error { + if !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { return nil } @@ -2848,6 +2845,13 @@ func validateHeadlessClientInput(clientType string, jwksURI string, jwks interfa return nil } +func normalizeClientTypeForHeadless(clientType string, metadata map[string]interface{}) string { + if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { + return "private" + } + return clientType +} + func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string]interface{}, error) { if metadata == nil { return nil, nil diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 0eb992cb..8fad0258 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1467,7 +1467,7 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) { body, _ := json.Marshal(map[string]any{ "name": "Headless Login App", - "type": "pkce", + "type": "private", "redirectUris": []string{"https://rp.example.com/callback"}, "scopes": []string{"openid", "profile"}, "tokenEndpointAuthMethod": "private_key_jwt", @@ -1482,7 +1482,8 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) { resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) - assert.Equal(t, "none", captured.TokenEndpointAuthMethod) + assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) + assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) assert.Nil(t, captured.JWKS) assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"]) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) @@ -1633,6 +1634,87 @@ func TestCreateClient_AllowsExplicitSkipConsentFalse(t *testing.T) { assert.False(t, *captured.SkipConsent) } +func TestCreateClient_LegacyPKCEHeadlessInputIsNormalizedToPrivate(t *testing.T) { + var captured domain.HydraClient + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPost && r.URL.Path == "/clients" { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + err = json.Unmarshal(body, &captured) + assert.NoError(t, err) + + return httpJSONAny(r, http.StatusCreated, 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, + "jwks": captured.JWKS, + "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.Post("/api/v1/dev/clients", h.CreateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "Legacy Headless Login App", + "type": "pkce", + "redirectUris": []string{"https://rp.example.com/callback"}, + "metadata": map[string]any{ + "headless_login_enabled": true, + "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", + }, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) + assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) + assert.Nil(t, captured.JWKS) + assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"]) + assert.True(t, captured.IsHeadlessLoginEnabled()) +} + +func TestMapClientSummary_ClassifiesHeadlessLoginAsPrivate(t *testing.T) { + h := &DevHandler{} + + summary := h.mapClientSummary(domain.HydraClient{ + ClientID: "client-headless-login", + ClientName: "Headless Login App", + TokenEndpointAuthMethod: "none", + Metadata: map[string]any{ + "status": "active", + "headless_login_enabled": true, + "headless_token_endpoint_auth_method": "private_key_jwt", + "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", + }, + }) + + assert.Equal(t, "private", summary.Type) +} + func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) { var hydraCalled bool h := &DevHandler{ @@ -1895,7 +1977,7 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) { body, _ := json.Marshal(map[string]any{ "name": "Headless Login After", - "type": "pkce", + "type": "private", "tokenEndpointAuthMethod": "private_key_jwt", "jwksUri": "https://rp.example.com/.well-known/jwks.json", "metadata": map[string]any{ @@ -1908,8 +1990,8 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) { resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "none", captured.TokenEndpointAuthMethod) - assert.Equal(t, "", captured.JWKSUri) + assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) + assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"]) assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) _, hasInlineJWKS := captured.Metadata["headless_jwks"] @@ -2071,7 +2153,8 @@ func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(t *testing.T) { 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, "private_key_jwt", captured.TokenEndpointAuthMethod) + assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", 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) diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index f7ff4a06..b189abdd 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -981,10 +981,8 @@ function ClientGeneralPage() { .map((scope) => scope.name.trim()) .filter(Boolean); - const persistedClientType = headlessLoginEnabled ? "pkce" : clientType; - const effectiveTokenEndpointAuthMethod = headlessLoginEnabled - ? "none" - : tokenEndpointAuthMethod; + const persistedClientType = headlessLoginEnabled ? "private" : clientType; + const effectiveTokenEndpointAuthMethod = tokenEndpointAuthMethod; const payload: ClientUpsertRequest = { name, @@ -992,7 +990,8 @@ function ClientGeneralPage() { scopes: scopeNames, tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod, jwksUri: - effectiveTokenEndpointAuthMethod === "private_key_jwt" && + (headlessLoginEnabled || + effectiveTokenEndpointAuthMethod === "private_key_jwt") && trimmedJwksUri ? trimmedJwksUri : undefined,