From 487ed20286f5cee887d1531b356a095d15c4b9cf Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 23 Apr 2026 11:33:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?consent=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B0=98=EB=B3=B5=20=EB=85=B8=EC=B6=9C=20=ED=98=84=EC=83=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/domain/hydra_models.go | 1 + backend/internal/handler/dev_handler.go | 17 ++ backend/internal/handler/dev_handler_test.go | 180 ++++++++++++++++++ docs/test-plan/backend-test-inventory.md | 3 + .../issue-614-skip-consent.md | 36 ++++ 5 files changed, 237 insertions(+) create mode 100644 docs/trouble-shooting/issue-614-skip-consent.md diff --git a/backend/internal/domain/hydra_models.go b/backend/internal/domain/hydra_models.go index 596523a1..15143227 100644 --- a/backend/internal/domain/hydra_models.go +++ b/backend/internal/domain/hydra_models.go @@ -23,6 +23,7 @@ type HydraClient struct { ResponseTypes []string `json:"response_types,omitempty"` Scope string `json:"scope,omitempty"` TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + SkipConsent *bool `json:"skip_consent,omitempty"` JWKSUri string `json:"jwks_uri,omitempty"` JWKS interface{} `json:"jwks,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 2e07e0ae..1a0b8a95 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -101,6 +101,7 @@ type clientSummary struct { Scopes []string `json:"scopes"` ClientSecret string `json:"clientSecret,omitempty"` TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"` + SkipConsent bool `json:"skipConsent"` JwksUri string `json:"jwksUri,omitempty"` Jwks interface{} `json:"jwks,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -185,6 +186,7 @@ type clientUpsertRequest struct { GrantTypes *[]string `json:"grantTypes"` ResponseTypes *[]string `json:"responseTypes"` TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"` + SkipConsent *bool `json:"skipConsent"` JwksUri *string `json:"jwksUri"` Jwks interface{} `json:"jwks"` Metadata *map[string]interface{} `json:"metadata"` @@ -1554,6 +1556,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { ResponseTypes: responseTypes, Scope: strings.Join(scopes, " "), TokenEndpointAuthMethod: tokenAuthMethod, + SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)), JWKSUri: jwksURI, JWKS: jwks, Metadata: metadata, @@ -1737,6 +1740,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { resolvedJWKS, metadata, ) + resolvedSkipConsent := valueOrBool(req.SkipConsent, valueOrBool(current.SkipConsent, true)) updated := domain.HydraClient{ ClientID: current.ClientID, @@ -1746,6 +1750,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes), Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))), TokenEndpointAuthMethod: resolvedTokenAuthMethod, + SkipConsent: boolPtr(resolvedSkipConsent), JWKSUri: resolvedJWKSURI, JWKS: resolvedJWKS, Metadata: metadata, @@ -2491,6 +2496,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { Scopes: scopes, ClientSecret: clientSecret, TokenEndpointAuthMethod: client.TokenEndpointAuthMethod, + SkipConsent: valueOrBool(client.SkipConsent, true), JwksUri: client.JWKSUri, Jwks: client.JWKS, Metadata: client.Metadata, @@ -2610,6 +2616,17 @@ func valueOr(ptr *string, fallback string) string { return *ptr } +func boolPtr(value bool) *bool { + return &value +} + +func valueOrBool(ptr *bool, fallback bool) bool { + if ptr == nil { + return fallback + } + return *ptr +} + func valueOrSlice(ptr *[]string, fallback []string) []string { if ptr == nil { return fallback diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 0710cd22..8bbc2b50 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1245,6 +1245,117 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) { assert.False(t, hasRequestObjectAlg) } +func TestCreateClient_DefaultsSkipConsentToTrue(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, + "skip_consent": captured.SkipConsent, + "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": "Trusted App", + "type": "pkce", + "redirectUris": []string{"https://rp.example.com/callback"}, + }) + 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.NotNil(t, captured.SkipConsent) + assert.True(t, *captured.SkipConsent) +} + +func TestCreateClient_AllowsExplicitSkipConsentFalse(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, + "skip_consent": captured.SkipConsent, + "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": "Consent Required App", + "type": "pkce", + "skipConsent": false, + "redirectUris": []string{"https://rp.example.com/callback"}, + }) + 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.NotNil(t, captured.SkipConsent) + assert.False(t, *captured.SkipConsent) +} + func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) { var hydraCalled bool h := &DevHandler{ @@ -1394,6 +1505,75 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) { assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) } +func TestUpdateClient_AllowsExplicitSkipConsentFalse(t *testing.T) { + var captured domain.HydraClient + currentSkipConsent := true + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, domain.HydraClient{ + ClientID: "client-1", + ClientName: "Trusted Before", + RedirectURIs: []string{"https://rp.example.com/callback"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + Scope: "openid profile", + TokenEndpointAuthMethod: "none", + SkipConsent: ¤tSkipConsent, + Metadata: map[string]interface{}{"status": "active"}, + }), nil + } + if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { + 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, + "skip_consent": captured.SkipConsent, + "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": "Consent Required After", + "type": "pkce", + "skipConsent": false, + }) + req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.NotNil(t, captured.SkipConsent) + assert.False(t, *captured.SkipConsent) +} + func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(t *testing.T) { var captured domain.HydraClient diff --git a/docs/test-plan/backend-test-inventory.md b/docs/test-plan/backend-test-inventory.md index ee223920..7dbe8a20 100644 --- a/docs/test-plan/backend-test-inventory.md +++ b/docs/test-plan/backend-test-inventory.md @@ -52,6 +52,9 @@ | `backend/internal/handler/auth_handler_test.go:209` | `TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists` | 오류/예외/거부 경로 검증 | | `backend/internal/handler/auth_handler_test.go:249` | `TestProcessPasswordResetToken_EncodesLoginIDInRedirect` | 리다이렉트/쿼리 보존 규칙 검증 | | `backend/internal/handler/dev_handler_test.go:103` | `TestCreateClient_Success` | Hydra/RP 연동 검증 | +| `backend/internal/handler/dev_handler_test.go:1248` | `TestCreateClient_DefaultsSkipConsentToTrue` | Hydra RP 생성 시 `skip_consent` 기본값 검증 | +| `backend/internal/handler/dev_handler_test.go:1303` | `TestCreateClient_AllowsExplicitSkipConsentFalse` | Hydra RP 생성 시 명시적 consent 요구 설정 보존 검증 | +| `backend/internal/handler/dev_handler_test.go:1508` | `TestUpdateClient_AllowsExplicitSkipConsentFalse` | Hydra RP 수정 시 명시적 consent 요구 설정 보존 검증 | | `backend/internal/handler/dev_handler_test.go:15` | `TestListClients_Success` | Hydra/RP 연동 검증 | | `backend/internal/handler/dev_handler_test.go:49` | `TestGetClient_Success` | Hydra/RP 연동 검증 | | `backend/internal/handler/dev_handler_test.go:83` | `TestGetClient_NotFound` | 오류/예외/거부 경로 검증 | diff --git a/docs/trouble-shooting/issue-614-skip-consent.md b/docs/trouble-shooting/issue-614-skip-consent.md new file mode 100644 index 00000000..bd960170 --- /dev/null +++ b/docs/trouble-shooting/issue-614-skip-consent.md @@ -0,0 +1,36 @@ +# Issue #614 일반 RP Consent 반복 노출 + +## 현상 +- `https://ssob.hmac.kr/`의 나의 App 현황에서 일반 서비스 클라이언트 바로가기를 열 때 Hydra consent 화면이 매번 노출되었습니다. +- `DevFront`, `AdminFront`는 동일 경로에서 consent 화면이 반복 노출되지 않았습니다. + +## 원인 +- 일반 RP 생성/수정 API가 Hydra OAuth2 client의 `skip_consent` 값을 전달하지 않았습니다. +- 백엔드 DTO와 DevFront 설정 모델에도 해당 필드가 없어 신규/기존 RP를 신뢰 앱으로 제어할 수 없었습니다. +- `remember: true` consent 세션은 이미 적용되어 있었지만, Hydra client 자체의 `skip_consent`와는 별도 정책입니다. + +## 조치 +- `domain.HydraClient`에 `skip_consent` JSON 필드를 추가했습니다. +- Dev API는 `skipConsent` 요청 값을 받을 수 있지만, DevFront UI에는 별도 체크박스를 추가하지 않습니다. +- 신규 RP 생성 시 `skipConsent`가 생략되면 기본값을 `true`로 Hydra에 전달합니다. +- 기존 RP 수정 시 현재 값이 없으면 `true`로 보정하고, 명시적으로 `false`를 선택하면 그대로 Hydra에 전달합니다. + +## 검증 +- `TestCreateClient_DefaultsSkipConsentToTrue` + - 신규 RP 생성 요청에서 `skipConsent`가 생략되어도 Hydra payload의 `skip_consent`가 `true`인지 검증합니다. +- `TestCreateClient_AllowsExplicitSkipConsentFalse` + - 신규 RP 생성 요청에서 명시한 `skipConsent: false`가 Hydra payload에 보존되는지 검증합니다. +- `TestUpdateClient_AllowsExplicitSkipConsentFalse` + - 기존 RP 수정 요청에서 `skipConsent: false`가 Hydra update payload에 보존되는지 검증합니다. + +## 실행 결과 +- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/handler -run 'Test(CreateClient_(DefaultsSkipConsentToTrue|AllowsExplicitSkipConsentFalse)|UpdateClient_AllowsExplicitSkipConsentFalse)' -count=1` +- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/handler -count=1` +- `cd devfront && npx biome check src/features/clients/ClientGeneralPage.tsx src/lib/devApi.ts src/locales/en.toml src/locales/ko.toml src/locales/template.toml --formatter-enabled=false --organize-imports-enabled=false` +- `cd devfront && npx tsc -b --pretty false` +- `node tools/i18n-scanner/index.js` +- `node tools/i18n-scanner/value-check.js` + +## 수동 테스트용 RP +- `tools/consent-demo-page/index.php`를 추가했습니다. +- DevFront에서 테스트용 RP를 따로 만들고, 데모 페이지의 `.env`에 해당 `CLIENT_ID`와 `REDIRECT_URI`를 설정하면 브라우저 기반 OIDC/consent 흐름을 확인할 수 있습니다. From 2ee1ee4037ec17bd787e34ead625da52e7142fdd Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 23 Apr 2026 16:49:11 +0900 Subject: [PATCH 2/2] =?UTF-8?q?dev=20=EB=B3=91=ED=95=A9=20code-check=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/tests/helpers/devfront-fixtures.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index 5b62f1c2..3f6ff964 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -156,14 +156,22 @@ export async function seedAuth(page: Page, role?: string) { expires_at: issuedAt + 3600, }; - window.localStorage.setItem( + const storageKeys = [ + "user:http://localhost:5000/oidc:devfront", + "user:http://localhost:5000/oidc/:devfront", + "user:https://sso-test.hmac.kr/oidc:devfront", + "user:https://sso-test.hmac.kr/oidc/:devfront", "oidc.user:http://localhost:5000/oidc:devfront", - JSON.stringify(mockOidcUser), - ); - window.localStorage.setItem( "oidc.user:http://localhost:5000/oidc/:devfront", - JSON.stringify(mockOidcUser), - ); + "oidc.user:https://sso-test.hmac.kr/oidc:devfront", + "oidc.user:https://sso-test.hmac.kr/oidc/:devfront", + ]; + + for (const key of storageKeys) { + window.localStorage.setItem(key, JSON.stringify(mockOidcUser)); + window.sessionStorage.setItem(key, JSON.stringify(mockOidcUser)); + } + window.localStorage.setItem("dev_role", injectedRole || "rp_admin"); window.localStorage.setItem("dev_tenant_id", "tenant-a"); },