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/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index 4657e624..d9487d72 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -175,6 +175,7 @@ function ClientDetailsPage() { } const client = data?.client; + const isHeadlessLogin = client?.metadata?.headless_login_enabled === true; if (!client) { return null; } @@ -213,16 +214,21 @@ function ClientDetailsPage() { }, ]; - const hasClientSecret = client.type === "private"; + const hasClientSecret = client.type === "private" && !isHeadlessLogin; const secretPlaceholder = "SECRET_NOT_AVAILABLE"; const clientSecret = hasClientSecret ? client?.clientSecret || secretPlaceholder : t("ui.common.na", "N/A"); const displaySecret = !hasClientSecret - ? t( - "msg.dev.clients.details.secret_not_applicable", - "PKCE 앱에는 Client Secret이 없습니다.", - ) + ? isHeadlessLogin + ? t( + "msg.dev.clients.details.secret_not_applicable_headless", + "이 앱은 Headless Login용 signed key 인증을 사용하므로 Client Secret을 사용하지 않습니다.", + ) + : t( + "msg.dev.clients.details.secret_not_applicable", + "PKCE 앱에는 Client Secret이 없습니다.", + ) : clientSecret === secretPlaceholder ? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE") : clientSecret; @@ -394,10 +400,15 @@ function ClientDetailsPage() { {!hasClientSecret ? (
- {t( - "msg.dev.clients.details.secret_not_applicable", - "PKCE 앱에는 Client Secret이 없습니다.", - )} + {isHeadlessLogin + ? t( + "msg.dev.clients.details.secret_not_applicable_headless", + "이 앱은 Headless Login용 signed key 인증을 사용하므로 Client Secret을 사용하지 않습니다.", + ) + : t( + "msg.dev.clients.details.secret_not_applicable", + "PKCE 앱에는 Client Secret이 없습니다.", + )}
) : null} diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index c0d76c06..b189abdd 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -391,12 +391,13 @@ function ClientGeneralPage() { useEffect(() => { if (!data) return; const { client } = data; + const metadata = client.metadata ?? {}; + const headlessEnabled = !!metadata.headless_login_enabled; + setName(client.name || client.id); - setClientType(client.type); + setClientType(headlessEnabled ? "private" : client.type); setStatus(client.status); setInitialStatus(client.status); - - const metadata = client.metadata ?? {}; if (typeof metadata.description === "string") setDescription(metadata.description); if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url); @@ -412,7 +413,6 @@ function ClientGeneralPage() { if (typeof metadata.auto_login_url === "string") setAutoLoginUrl(metadata.auto_login_url); - const headlessEnabled = !!metadata.headless_login_enabled; setHeadlessLoginEnabled(headlessEnabled); const restrictedTenants = Array.isArray(metadata.allowed_tenants) ? metadata.allowed_tenants @@ -532,18 +532,25 @@ function ClientGeneralPage() { const handleSecurityProfileChange = (profile: SecurityProfile) => { setClientType(profile); if (profile === "pkce") { - setTokenEndpointAuthMethod( - headlessLoginEnabled ? "private_key_jwt" : "none", - ); + setHeadlessLoginEnabled(false); + setTokenEndpointAuthMethod("none"); } else { - setTokenEndpointAuthMethod("client_secret_basic"); + setTokenEndpointAuthMethod( + headlessLoginEnabled ? "private_key_jwt" : "client_secret_basic", + ); } }; const handleHeadlessToggle = (enabled: boolean) => { setHeadlessLoginEnabled(enabled); - if (clientType === "pkce") { - setTokenEndpointAuthMethod(enabled ? "private_key_jwt" : "none"); + if (enabled) { + setClientType("private"); + setTokenEndpointAuthMethod("private_key_jwt"); + return; + } + + if (clientType === "private") { + setTokenEndpointAuthMethod("client_secret_basic"); } }; @@ -974,18 +981,17 @@ function ClientGeneralPage() { .map((scope) => scope.name.trim()) .filter(Boolean); - const effectiveTokenEndpointAuthMethod = - clientType === "pkce" && headlessLoginEnabled - ? "none" - : tokenEndpointAuthMethod; + const persistedClientType = headlessLoginEnabled ? "private" : clientType; + const effectiveTokenEndpointAuthMethod = tokenEndpointAuthMethod; const payload: ClientUpsertRequest = { name, - type: clientType, + type: persistedClientType, scopes: scopeNames, tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod, jwksUri: - effectiveTokenEndpointAuthMethod === "private_key_jwt" && + (headlessLoginEnabled || + effectiveTokenEndpointAuthMethod === "private_key_jwt") && trimmedJwksUri ? trimmedJwksUri : undefined, @@ -1003,14 +1009,10 @@ function ClientGeneralPage() { id_token_claims: normalizedIdTokenClaims, token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, headless_login_enabled: headlessLoginEnabled, - headless_token_endpoint_auth_method: - clientType === "pkce" && headlessLoginEnabled - ? tokenEndpointAuthMethod - : undefined, - headless_jwks_uri: - clientType === "pkce" && headlessLoginEnabled - ? trimmedJwksUri - : undefined, + headless_token_endpoint_auth_method: headlessLoginEnabled + ? tokenEndpointAuthMethod + : undefined, + headless_jwks_uri: headlessLoginEnabled ? trimmedJwksUri : undefined, tenant_access_restricted: tenantAccessRestricted, allowed_tenants: tenantAccessRestricted ? normalizedAllowedTenantIds @@ -2291,6 +2293,38 @@ function ClientGeneralPage() { {securityProfile === "private" ? "✓" : ""} + + {securityProfile === "private" && ( ++ {t( + "msg.dev.clients.general.security.headless_login_enable_help", + "Baron SSO 로그인 창 대신 RP 자체 로그인 UI를 사용하고, RP backend의 서명 키로 클라이언트를 검증하려는 경우 활성화합니다.", + )} +
+{t( "msg.dev.clients.general.public_key.headless_help", - "애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다.", + "RP가 자체 로그인 UI를 제공하더라도 실제 인증 흐름은 Baron API와 RP backend의 signed key 검증을 통해 이어집니다.", )}
diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 5dc148c0..2d1fd352 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -524,18 +524,28 @@ function ClientsPage() {