From 7c3295fdc73d9fea4d59c410d737cce32462c4f1 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 13:28:40 +0900 Subject: [PATCH 1/6] =?UTF-8?q?Ory=20Hydra=20Admin=20API=20=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=20=EB=A1=9C=EC=A7=81=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/hydra_admin_service_test.go | 401 ++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 backend/internal/service/hydra_admin_service_test.go diff --git a/backend/internal/service/hydra_admin_service_test.go b/backend/internal/service/hydra_admin_service_test.go new file mode 100644 index 00000000..0004e590 --- /dev/null +++ b/backend/internal/service/hydra_admin_service_test.go @@ -0,0 +1,401 @@ +/* +이 테스트 파일은 HydraAdminService의 기능을 검증하기 위한 유닛 테스트입니다. +Hydra Admin API와의 통신을 시뮬레이션하기 위해 httptest 패키지를 사용하여 +실제 네트워크 호출 없이 HTTP 핸들러를 모킹(Mocking)하는 방식으로 작성되었습니다. + +주요 테스트 항목: +1. 클라이언트 관리: List, Get, Create, Update, Patch, Delete (성공 및 NotFound 케이스) +2. OIDC 인증/동의 흐름: GetConsentRequest, AcceptConsentRequest, RejectConsentRequest, GetLoginRequest, AcceptLoginRequest, RejectLoginRequest +3. 세션 관리: ListConsentSessions, RevokeConsentSessions +4. 존재하지 않는 리소스 접근 시 에러 처리 +*/ + +package service + +import ( + "baron-sso-backend/internal/domain" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// clientForHandler는 주어진 핸들러로 요청을 보내는 http.Client를 반환합니다. +// ory_service_test.go에 정의된 것과 동일한 로직입니다. +func mockHydraClient(h http.Handler) *http.Client { + return &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + var bodyBytes []byte + if req.Body != nil { + bodyBytes, _ = io.ReadAll(req.Body) + } + r := httptest.NewRequest(req.Method, req.URL.String(), bytes.NewReader(bodyBytes)) + r.Header = req.Header.Clone() + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Result(), nil + }), + } +} + +func TestHydraAdminService_ListClients(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/clients") { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.String()) + return + } + + clients := []domain.HydraClient{ + {ClientID: "client-1", ClientName: "Client One"}, + {ClientID: "client-2", ClientName: "Client Two"}, + } + json.NewEncoder(w).Encode(clients) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + clients, err := svc.ListClients(context.Background(), 10, 0) + if err != nil { + t.Fatalf("ListClients failed: %v", err) + } + + if len(clients) != 2 { + t.Errorf("expected 2 clients, got %d", len(clients)) + } +} + +func TestHydraAdminService_GetClient_Success(t *testing.T) { + targetID := "test-client" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + client := domain.HydraClient{ + ClientID: targetID, + ClientName: "Test Client", + } + json.NewEncoder(w).Encode(client) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + client, err := svc.GetClient(context.Background(), targetID) + if err != nil { + t.Fatalf("GetClient failed: %v", err) + } + + if client.ClientID != targetID { + t.Errorf("expected %s, got %s", targetID, client.ClientID) + } +} + +func TestHydraAdminService_GetClient_NotFound(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + _, err := svc.GetClient(context.Background(), "non-existent") + if err != ErrHydraNotFound { + t.Errorf("expected ErrHydraNotFound, got %v", err) + } +} + +func TestHydraAdminService_PatchClientStatus(t *testing.T) { + targetID := "test-client" + status := "inactive" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + var patch []map[string]interface{} + json.NewDecoder(r.Body).Decode(&patch) + if patch[0]["value"] != status { + t.Errorf("expected status %s, got %v", status, patch[0]["value"]) + } + + client := domain.HydraClient{ClientID: targetID} + json.NewEncoder(w).Encode(client) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + _, err := svc.PatchClientStatus(context.Background(), targetID, status) + if err != nil { + t.Fatalf("PatchClientStatus failed: %v", err) + } +} + +func TestHydraAdminService_CreateClient(t *testing.T) { + newClient := domain.HydraClient{ClientID: "new-client"} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(newClient) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + created, err := svc.CreateClient(context.Background(), newClient) + if err != nil { + t.Fatalf("CreateClient failed: %v", err) + } + if created.ClientID != newClient.ClientID { + t.Errorf("expected %s, got %s", newClient.ClientID, created.ClientID) + } +} + +func TestHydraAdminService_UpdateClient(t *testing.T) { + targetID := "test-client" + updatedClient := domain.HydraClient{ClientID: targetID, ClientName: "Updated"} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + json.NewEncoder(w).Encode(updatedClient) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + res, err := svc.UpdateClient(context.Background(), targetID, updatedClient) + if err != nil { + t.Fatalf("UpdateClient failed: %v", err) + } + if res.ClientName != "Updated" { + t.Errorf("expected Updated, got %s", res.ClientName) + } +} + +func TestHydraAdminService_DeleteClient(t *testing.T) { + targetID := "test-client" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + err := svc.DeleteClient(context.Background(), targetID) + if err != nil { + t.Fatalf("DeleteClient failed: %v", err) + } +} + +func TestHydraAdminService_ListConsentSessions(t *testing.T) { + subject := "user-123" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("subject") != subject { + t.Errorf("expected subject %s, got %s", subject, r.URL.Query().Get("subject")) + } + sessions := []domain.HydraConsentSession{{Subject: subject}} + json.NewEncoder(w).Encode(sessions) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + res, err := svc.ListConsentSessions(context.Background(), subject, "") + if err != nil { + t.Fatalf("ListConsentSessions failed: %v", err) + } + if len(res) != 1 { + t.Errorf("expected 1 session, got %d", len(res)) + } +} + +func TestHydraAdminService_RevokeConsentSessions(t *testing.T) { + subject := "user-123" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + err := svc.RevokeConsentSessions(context.Background(), subject, "client-1") + if err != nil { + t.Fatalf("RevokeConsentSessions failed: %v", err) + } +} + +func TestHydraAdminService_GetConsentRequest(t *testing.T) { + challenge := "consent-challenge" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("consent_challenge") != challenge { + t.Errorf("expected challenge %s, got %s", challenge, r.URL.Query().Get("consent_challenge")) + } + resp := domain.HydraConsentRequest{Challenge: challenge} + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + req, err := svc.GetConsentRequest(context.Background(), challenge) + if err != nil { + t.Fatalf("GetConsentRequest failed: %v", err) + } + if req.Challenge != challenge { + t.Errorf("expected %s, got %s", challenge, req.Challenge) + } +} + +func TestHydraAdminService_RejectConsentRequest(t *testing.T) { + challenge := "consent-challenge" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + resp := struct { + RedirectTo string `json:"redirect_to"` + }{ + RedirectTo: "http://redirect", + } + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + resp, err := svc.RejectConsentRequest(context.Background(), challenge) + if err != nil { + t.Fatalf("RejectConsentRequest failed: %v", err) + } + if resp.RedirectTo != "http://redirect" { + t.Errorf("expected http://redirect, got %s", resp.RedirectTo) + } +} + +func TestHydraAdminService_RejectLoginRequest(t *testing.T) { + challenge := "login-challenge" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := struct { + RedirectTo string `json:"redirect_to"` + }{ + RedirectTo: "http://redirect", + } + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + resp, err := svc.RejectLoginRequest(context.Background(), challenge, "error", "desc") + if err != nil { + t.Fatalf("RejectLoginRequest failed: %v", err) + } + if resp.RedirectTo != "http://redirect" { + t.Errorf("expected http://redirect, got %s", resp.RedirectTo) + } +} + +func TestHydraAdminService_GetLoginRequest(t *testing.T) { + challenge := "login-challenge" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := domain.HydraLoginRequest{Challenge: challenge} + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + req, err := svc.GetLoginRequest(context.Background(), challenge) + if err != nil { + t.Fatalf("GetLoginRequest failed: %v", err) + } + if req.Challenge != challenge { + t.Errorf("expected %s, got %s", challenge, req.Challenge) + } +} + +func TestHydraAdminService_AcceptConsentRequest(t *testing.T) { + challenge := "consent-challenge" + grantInfo := &domain.HydraConsentRequest{RequestedScope: []string{"openid"}} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := struct { + RedirectTo string `json:"redirect_to"` + }{ + RedirectTo: "http://callback", + } + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + resp, err := svc.AcceptConsentRequest(context.Background(), challenge, grantInfo, nil) + if err != nil { + t.Fatalf("AcceptConsentRequest failed: %v", err) + } + if resp.RedirectTo != "http://callback" { + t.Errorf("expected http://callback, got %s", resp.RedirectTo) + } +} + +func TestHydraAdminService_AcceptLoginRequest(t *testing.T) { + challenge := "login-challenge" + subject := "user@example.com" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := struct { + RedirectTo string `json:"redirect_to"` + }{ + RedirectTo: "http://callback", + } + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + resp, err := svc.AcceptLoginRequest(context.Background(), challenge, subject) + if err != nil { + t.Fatalf("AcceptLoginRequest failed: %v", err) + } + if resp.RedirectTo != "http://callback" { + t.Errorf("expected http://callback, got %s", resp.RedirectTo) + } +} \ No newline at end of file From f362e217744fe3a6c3cec3e69ae8722cf094c2f6 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 13:29:40 +0900 Subject: [PATCH 2/6] =?UTF-8?q?HydraAdminService=EC=99=80=20RelyingParty?= =?UTF-8?q?=20=EA=B0=84=EC=9D=98=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=A4=EB=B0=B1=20=EB=A1=9C=EC=A7=81=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/relying_party_service_test.go | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 backend/internal/service/relying_party_service_test.go diff --git a/backend/internal/service/relying_party_service_test.go b/backend/internal/service/relying_party_service_test.go new file mode 100644 index 00000000..e5c5fe3d --- /dev/null +++ b/backend/internal/service/relying_party_service_test.go @@ -0,0 +1,295 @@ +/* +이 테스트 파일은 RelyingPartyService의 기능을 검증하기 위한 유닛 테스트입니다. +RelyingPartyService는 HydraAdminService, KetoService, RelyingPartyRepository와 협력하므로 +각 의존성을 모킹(Mocking)하여 통합 로직을 검증합니다. + +주요 테스트 항목: +1. Create: Hydra 클라이언트 생성 -> DB 저장 -> Keto 권한 설정 (성공 및 롤백 시나리오) +2. Get: DB 및 Hydra에서 정보 조회 +3. Update: Hydra 및 DB 업데이트 +4. Delete: DB 및 Hydra 삭제 +*/ + +package service + +import ( + "baron-sso-backend/internal/domain" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/mock" +) + +// --- Mocks --- + +type MockRelyingPartyRepository struct { + mock.Mock +} + +func (m *MockRelyingPartyRepository) Create(ctx context.Context, rp *domain.RelyingParty) error { + args := m.Called(ctx, rp) + return args.Error(0) +} + +func (m *MockRelyingPartyRepository) Update(ctx context.Context, rp *domain.RelyingParty) error { + args := m.Called(ctx, rp) + return args.Error(0) +} + +func (m *MockRelyingPartyRepository) Delete(ctx context.Context, clientID string) error { + args := m.Called(ctx, clientID) + return args.Error(0) +} + +func (m *MockRelyingPartyRepository) FindByID(ctx context.Context, clientID string) (*domain.RelyingParty, error) { + args := m.Called(ctx, clientID) + if rp, ok := args.Get(0).(*domain.RelyingParty); ok { + return rp, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockRelyingPartyRepository) ListByTenantID(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) { + args := m.Called(ctx, tenantID) + return args.Get(0).([]domain.RelyingParty), args.Error(1) +} + +func (m *MockRelyingPartyRepository) ListAll(ctx context.Context) ([]domain.RelyingParty, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.RelyingParty), args.Error(1) +} + +type MockKetoService struct { + mock.Mock +} + +func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) { + args := m.Called(ctx, subject, namespace, object, relation) + return args.Bool(0), args.Error(1) +} + +func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Error(0) +} + +func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Error(0) +} + +// --- Tests --- + +func TestRelyingPartyService_Create_Success(t *testing.T) { + // Setup + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + + tenantID := "tenant-1" + inputClient := domain.HydraClient{ + ClientName: "Test App", + } + + // Hydra Mock + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/clients") { + var req domain.HydraClient + json.NewDecoder(r.Body).Decode(&req) + + // Verify metadata injection + if req.Metadata["tenant_id"] != tenantID { + t.Errorf("expected tenant_id in metadata") + } + + req.ClientID = "generated-client-id" + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(req) + return + } + http.NotFound(w, r) + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + // Expectations + mockRepo.On("Create", mock.Anything, mock.MatchedBy(func(rp *domain.RelyingParty) bool { + return rp.ClientID == "generated-client-id" && rp.TenantID == tenantID + })).Return(nil) + + mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "generated-client-id", "parent_tenant", "Tenant:"+tenantID).Return(nil) + + // Execute + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + rp, err := svc.Create(context.Background(), tenantID, inputClient) + + // Verify + if err != nil { + t.Fatalf("Create failed: %v", err) + } + if rp.ClientID != "generated-client-id" { + t.Errorf("expected client id generated-client-id, got %s", rp.ClientID) + } + + mockRepo.AssertExpectations(t) + mockKeto.AssertExpectations(t) +} + +func TestRelyingPartyService_Create_HydraFail(t *testing.T) { + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + _, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{}) + + if err == nil { + t.Error("expected error from hydra") + } +} + +func TestRelyingPartyService_Create_DBFail_Rollback(t *testing.T) { + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + + clientID := "rollback-client-id" + + // Hydra Mock: Create Succeeds, Delete Called + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + json.NewEncoder(w).Encode(domain.HydraClient{ClientID: clientID}) + return + } + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, clientID) { + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + // DB Fails + mockRepo.On("Create", mock.Anything, mock.Anything).Return(errors.New("db error")) + + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + _, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{}) + + if err == nil { + t.Error("expected error from db") + } + + mockRepo.AssertExpectations(t) + // Keto should NOT be called + mockKeto.AssertNotCalled(t, "CreateRelation") +} + +func TestRelyingPartyService_Get_Success(t *testing.T) { + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + clientID := "client-123" + + mockRepo.On("FindByID", mock.Anything, clientID).Return(&domain.RelyingParty{ClientID: clientID, Name: "DB Name"}, nil) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(domain.HydraClient{ClientID: clientID, ClientName: "Hydra Name"}) + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + rp, hc, err := svc.Get(context.Background(), clientID) + + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if rp.Name != "DB Name" { + t.Errorf("expected DB Name, got %s", rp.Name) + } + if hc.ClientName != "Hydra Name" { + t.Errorf("expected Hydra Name, got %s", hc.ClientName) + } +} + +func TestRelyingPartyService_Update_Success(t *testing.T) { + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + clientID := "client-123" + + // Hydra Update + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + var req domain.HydraClient + json.NewDecoder(r.Body).Decode(&req) + json.NewEncoder(w).Encode(req) + return + } + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + // DB Update + mockRepo.On("FindByID", mock.Anything, clientID).Return(&domain.RelyingParty{ClientID: clientID, Name: "Old Name"}, nil) + mockRepo.On("Update", mock.Anything, mock.MatchedBy(func(rp *domain.RelyingParty) bool { + return rp.Name == "New Name" + })).Return(nil) + + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + + updateReq := domain.HydraClient{ClientName: "New Name"} + rp, err := svc.Update(context.Background(), clientID, updateReq) + + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if rp.Name != "New Name" { + t.Errorf("expected New Name, got %s", rp.Name) + } + + mockRepo.AssertExpectations(t) +} + +func TestRelyingPartyService_Delete_Success(t *testing.T) { + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + clientID := "client-123" + + mockRepo.On("Delete", mock.Anything, clientID).Return(nil) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + } + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + err := svc.Delete(context.Background(), clientID) + + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + mockRepo.AssertExpectations(t) +} From 5710bea4f9b87b8aac64291f00b0d0e49f1164b6 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 13:30:59 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20OIDC/Hydra=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/auth_handler_login_test.go | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 backend/internal/handler/auth_handler_login_test.go diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go new file mode 100644 index 00000000..ca2bff02 --- /dev/null +++ b/backend/internal/handler/auth_handler_login_test.go @@ -0,0 +1,287 @@ +/* +이 테스트 파일은 AuthHandler의 PasswordLogin 메서드 내 OIDC/Hydra 관련 로직을 검증합니다. +특히 Hydra Login Request 조회, 검증(Inactive 체크), 승인(Accept) 흐름을 중점적으로 테스트합니다. +*/ + +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/mock" +) + +// --- Mocks --- + +type MockIdentityProvider struct { + mock.Mock +} + +func (m *MockIdentityProvider) Name() string { + return "mock-idp" +} +func (m *MockIdentityProvider) GetMetadata() (*domain.IDPMetadata, error) { + return nil, nil +} +func (m *MockIdentityProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { + return "", nil +} +func (m *MockIdentityProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { + args := m.Called(loginID, password) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.AuthInfo), args.Error(1) +} +func (m *MockIdentityProvider) UserExists(loginID string) (bool, error) { + return true, nil +} +func (m *MockIdentityProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *MockIdentityProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + return nil, nil +} +func (m *MockIdentityProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *MockIdentityProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + return nil, nil +} +func (m *MockIdentityProvider) InitiatePasswordReset(loginID, redirectUrl string) error { + return nil +} +func (m *MockIdentityProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *MockIdentityProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { + return nil +} + +type MockKratosAdminService struct { + // Simple mock for FindIdentityIDByIdentifier +} + +func (m *MockKratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { + // Always return a static ID for simplicity in this test + if identifier == "fail" { + return "", errors.New("not found") + } + return "kratos-identity-id", nil +} + +// --- Helper --- + +func newAuthLoginTestApp(h *AuthHandler) *fiber.App { + app := fiber.New() + app.Post("/api/v1/auth/login", h.PasswordLogin) + return app +} + +// mockHydraTransport simulates Hydra API responses +func mockHydraTransport(handler http.Handler) http.RoundTripper { + return roundTripFunc(func(req *http.Request) (*http.Response, error) { + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w.Result(), nil + }) +} + +// --- Tests --- + +func TestPasswordLogin_OIDC_Success(t *testing.T) { + mockIdp := new(MockIdentityProvider) + + // Mock IDP SignIn Success + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt"}, + Subject: "kratos-identity-id", + }, nil) + + // Mock Hydra Responses + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet: + // GetLoginRequest + challenge := r.URL.Query().Get("login_challenge") + if challenge != "challenge-123" { + http.Error(w, "invalid challenge", http.StatusBadRequest) + return + } + json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Challenge: challenge, + Client: domain.HydraClient{ClientID: "client-1", Metadata: map[string]interface{}{"status": "active"}}, + }) + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: + // AcceptLoginRequest + json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) + default: + http.NotFound(w, r) + } + }) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: service.NewKratosAdminService(), // We need to mock this better if resolveKratosIdentityIDFromLoginID calls real API + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + // Inject Mock Kratos (Hack: overwrite the service field if it was an interface, but it's a struct pointer) + // AuthHandler uses *service.KratosAdminService struct pointer. + // KratosAdminService methods are real. We need to mock HTTP client inside KratosAdminService too. + + kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Mock FindIdentityIDByIdentifier response + if strings.Contains(r.URL.Path, "/identities") { + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": "kratos-identity-id"}, + }) + return + } + http.NotFound(w, r) + }) + h.KratosAdmin.HTTPClient = &http.Client{Transport: mockHydraTransport(kratosHandler)} + h.KratosAdmin.AdminURL = "http://kratos.test" + + app := newAuthLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + "login_challenge": "challenge-123", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + var got map[string]string + json.NewDecoder(resp.Body).Decode(&got) + if got["redirectTo"] != "http://rp/cb" { + t.Errorf("expected redirectTo http://rp/cb, got %s", got["redirectTo"]) + } +} + +func TestPasswordLogin_OIDC_InactiveClient(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt"}, + Subject: "kratos-identity-id", + }, nil) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet { + json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Client: domain.HydraClient{ClientID: "client-inactive", Metadata: map[string]interface{}{"status": "inactive"}}, + }) + return + } + http.NotFound(w, r) + }) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: service.NewKratosAdminService(), + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + + kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{{"id": "kratos-identity-id"}}) + }) + h.KratosAdmin.HTTPClient = &http.Client{Transport: mockHydraTransport(kratosHandler)} + h.KratosAdmin.AdminURL = "http://kratos.test" + + app := newAuthLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + "login_challenge": "challenge-inactive", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + // Should be Forbidden (403) + if resp.StatusCode != http.StatusForbidden { + t.Errorf("expected 403 Forbidden, got %d", resp.StatusCode) + } +} + +func TestPasswordLogin_NoOIDC_Success(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt"}, + Subject: "kratos-identity-id", + }, nil) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: service.NewKratosAdminService(), + Hydra: service.NewHydraAdminService(), + } + + kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{{"id": "kratos-identity-id"}}) + }) + h.KratosAdmin.HTTPClient = &http.Client{Transport: mockHydraTransport(kratosHandler)} + h.KratosAdmin.AdminURL = "http://kratos.test" + + app := newAuthLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + var got map[string]string + json.NewDecoder(resp.Body).Decode(&got) + if got["sessionJwt"] != "valid-jwt" { + t.Errorf("expected jwt valid-jwt, got %s", got["sessionJwt"]) + } + // No redirectTo + if _, ok := got["redirectTo"]; ok { + t.Errorf("expected no redirectTo, got %s", got["redirectTo"]) + } +} From b50f7b7436fcc9fe5d3bc0e86e9e89cefd100318 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 13:31:24 +0900 Subject: [PATCH 4/6] =?UTF-8?q?test=20corverage=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/backend_hydra_test_guide.md | 147 +++++++++++++++++++++++++++++++ docs/test_concepts_guide.md | 62 +++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 docs/backend_hydra_test_guide.md create mode 100644 docs/test_concepts_guide.md diff --git a/docs/backend_hydra_test_guide.md b/docs/backend_hydra_test_guide.md new file mode 100644 index 00000000..d9271b07 --- /dev/null +++ b/docs/backend_hydra_test_guide.md @@ -0,0 +1,147 @@ +# Backend Hydra Test Guide + +이 문서는 Baron SSO 백엔드 내에서 **Ory Hydra Admin API**와 연동되는 기능(`HydraAdminService`)을 테스트하는 방법과 커버리지 측정 방법을 설명합니다. + +## 1. 테스트 개요 + +백엔드는 OAuth2 클라이언트 관리, 인증/동의(Consent) 요청 승인 등을 위해 Ory Hydra의 Admin API를 호출합니다. +본 테스트 가이드는 `httptest` 패키지와 Mocking을 활용하여 실제 Hydra 서버 없이 백엔드의 연동 로직을 빠르고 독립적으로 검증하는 방법을 다룹니다. +(주의: 본 가이드는 관리자용 UI인 `adminfront` 테스트가 아닌, 백엔드 내부의 API 연동 코드 테스트를 다룹니다.) + +## 2. 테스트 환경 준비 + +테스트는 Go 언어의 표준 테스팅 프레임워크를 사용하므로 별도의 설치가 필요 없으나, 커버리지 확인을 위해 `backend/` 디렉토리에서 작업을 수행해야 합니다. + +```bash +cd backend +``` + +## 3. 테스트 파일 목록 및 실행 방법 + +작성된 Hydra 관련 테스트 코드는 크게 3가지 파일로 나뉩니다. + +### 3.1. HydraAdminService (백엔드 내부 서비스) 테스트 +백엔드 내부에서 Ory Hydra Admin API와 통신하는 최하단 로직(클라이언트 관리, OIDC 흐름, 세션 관리)을 검증합니다. + +* **위치:** `backend/internal/service/hydra_admin_service_test.go` +* **실행:** + ```bash + go test -v ./internal/service -run TestHydraAdminService + ``` + +### 3.2. Relying Party Service 테스트 +`HydraAdminService`와 로컬 DB(RelyingParty) 간의 통합 및 롤백 로직을 검증합니다. + +* **위치:** `backend/internal/service/relying_party_service_test.go` +* **실행:** + ```bash + go test -v ./internal/service -run TestRelyingPartyService + ``` + +### 3.3. Auth Handler 로그인 테스트 +로그인 요청 시 백엔드 핸들러 레벨에서 발생하는 OIDC/Hydra 흐름(Login Challenge 처리 등)을 검증합니다. + +* **위치:** `backend/internal/handler/auth_handler_login_test.go` +* **실행:** + ```bash + go test -v ./internal/handler -run TestPasswordLogin + ``` + +### 3.4. 전체 테스트 실행 (권장) +모든 Hydra 관련 연동 테스트를 한 번에 실행하려면 다음 명령어를 사용합니다. + +```bash +go test -v ./internal/service ./internal/handler -run "TestHydraAdminService|TestRelyingPartyService|TestPasswordLogin" +``` + +## 4. 테스트 커버리지 측정 + +테스트가 코드의 어느 부분을 검증했는지 수치로 확인합니다. + +### 4.1. 수치로 확인 (CLI) + +```bash +# 1. HydraAdminService (백엔드 서비스) 커버리지 +go test -v ./internal/service -run TestHydraAdminService -coverprofile=coverage_hydra.out +go tool cover -func=coverage_hydra.out | grep hydra_admin_service.go + +# 2. RelyingPartyService (통합 서비스) 커버리지 +go test -v ./internal/service -run TestRelyingPartyService -coverprofile=coverage_rp.out +go tool cover -func=coverage_rp.out | grep relying_party_service.go +``` + +**예시 출력:** +```text +baron-sso-backend/internal/service/hydra_admin_service.go:35: ListClients 100.0% +baron-sso-backend/internal/service/hydra_admin_service.go:70: GetClient 85.7% +... +total: (statements) 78.5% +``` + +### 4.2. 시각적으로 확인 (HTML) + +어떤 코드가 테스트되지 않았는지(Missing Branch) 브라우저에서 색상으로 확인합니다. + +```bash +go tool cover -html=coverage_hydra.out +``` + +* **초록색**: 테스트에 의해 실행된 코드 +* **빨간색**: 테스트되지 않은 코드 (추가 테스트 케이스 필요) +* **회색**: 테스트 대상이 아닌 코드 (선언부 등) + +## 5. 주요 테스트 항목 (Checklist) + +| 분류 | 메서드 | 테스트 내용 | 파일 위치 | +| :--- | :--- | :--- | :--- | +| **클라이언트 관리** | `ListClients` | 클라이언트 목록 페이징 조회 | `hydra_admin_service_test.go` | +| | `GetClient` | 특정 클라이언트 상세 조회 (성공/실패) | `hydra_admin_service_test.go` | +| | `CreateClient` | 신규 클라이언트 생성 및 메타데이터 검증 | `hydra_admin_service_test.go` | +| | `UpdateClient` | 클라이언트 정보 수정 (PUT) | `hydra_admin_service_test.go` | +| | `PatchClientStatus` | 클라이언트 상태 변경 (JSON Patch) | `hydra_admin_service_test.go` | +| | `DeleteClient` | 클라이언트 삭제 | `hydra_admin_service_test.go` | +| **인증/동의** | `GetConsentRequest` | Consent Challenge 검증 및 요청 정보 조회 | `hydra_admin_service_test.go` | +| | `AcceptConsentRequest` | 동의 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` | +| | `RejectConsentRequest` | 동의 거부 처리 | `hydra_admin_service_test.go` | +| | `GetLoginRequest` | Login Challenge 검증 | `hydra_admin_service_test.go` | +| | `AcceptLoginRequest` | 로그인 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` | +| | `RejectLoginRequest` | 로그인 거부 처리 | `hydra_admin_service_test.go` | +| **세션 관리** | `ListConsentSessions` | 특정 사용자의 활성 세션 목록 조회 | `hydra_admin_service_test.go` | +| | `RevokeConsentSessions` | 특정 사용자/클라이언트의 세션 만료 처리 | `hydra_admin_service_test.go` | +| **서비스 통합** | `Create` (RP) | Hydra 생성 -> DB 생성 -> Keto 권한 부여 | `relying_party_service_test.go` | +| | `Create` (Rollback) | DB 실패 시 Hydra 롤백(삭제) 검증 | `relying_party_service_test.go` | +| **핸들러 연동** | `PasswordLogin` | OIDC 로그인 성공 및 Challenge 승인 | `auth_handler_login_test.go` | +| | `PasswordLogin` | 비활성(Inactive) 클라이언트 로그인 차단 | `auth_handler_login_test.go` | + +## 6. 테스트 코드 작성 가이드 + +새로운 기능을 추가하거나 커버리지를 높일 때 다음 패턴을 참고하세요. + +```go +func TestHydraAdminService_NewFeature(t *testing.T) { + // 1. Mock 핸들러 정의 (예상되는 요청 검증 및 가짜 응답 반환) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Assert: 요청 메서드, URL, 바디 검증 + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + + // Response: 가짜 응답 작성 + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(expectedResponse) + }) + + // 2. 서비스 초기화 (Mock Client 주입) + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), // ory_service_test.go의 헬퍼 사용 + } + + // 3. 테스트 실행 및 검증 + result, err := svc.NewFeature(context.Background(), args) + if err != nil { + t.Fatalf("failed: %v", err) + } + // Assert: 결과값 검증 +} +``` diff --git a/docs/test_concepts_guide.md b/docs/test_concepts_guide.md new file mode 100644 index 00000000..87810b5b --- /dev/null +++ b/docs/test_concepts_guide.md @@ -0,0 +1,62 @@ +# 테스트 커버리지 및 모킹 가이드 + +이 문서는 백엔드 테스트 코드 작성 시 필수적인 개념인 **테스트 커버리지**와 **모킹(Mocking)**에 대해 설명합니다. + +--- + +## 1. 테스트 커버리지 (Test Coverage) + +**정의:** 테스트 코드가 전체 소스 코드 중 얼마나 많은 부분을 실행(검증)했는지를 나타내는 백분율(%) 지표입니다. + +### 왜 중요한가요? +* **안정성 지표:** 커버리지가 높을수록 코드의 많은 부분이 자동화된 검증을 거쳤음을 의미합니다. +* **사각지대 발견:** 테스트되지 않은 '죽은 코드(Dead Code)'나 누락된 예외 처리 분기(if-else)를 찾아낼 수 있습니다. +* **리팩토링 자신감:** 커버리지가 높으면 기존 기능을 깨뜨리지 않고 코드를 수정하기가 훨씬 수월합니다. + +### 어떻게 확인하나요? (Go 기준) +```bash +# 1. 테스트 실행 및 기록 저장 +go test -coverprofile=coverage.out ./... + +# 2. 브라우저에서 시각적으로 확인 (어느 줄이 안 됐는지 색상으로 표시됨) +go tool cover -html=coverage.out +``` + +--- + +## 2. 모킹 (Mocking) + +**정의:** 테스트하려는 대상이 의존하는 외부 요소(데이터베이스, 외부 API, 다른 서비스 등)를 **동작만 흉내 내는 가짜 객체(Mock)**로 대체하는 기술입니다. + +### 왜 필요한가요? +* **독립적 환경:** 실제 Ory Hydra 서버가 꺼져 있어도 백엔드 로직만 따로 테스트할 수 있습니다. +* **결정적 테스트:** 네트워크 상태에 상관없이 항상 동일한 결과를 얻을 수 있습니다. +* **예외 상황 시뮬레이션:** "서버 점검 중(503 에러)"과 같은 특수한 상황을 강제로 만들어 내 코드가 잘 대응하는지 확인할 수 있습니다. + +### `MockRelyingPartyRepository`는 무엇인가요? +사용자께서 보신 `MockRelyingPartyRepository`는 라이브러리가 제공하는 함수가 아니라, **테스트를 위해 직접 만든 구조체**입니다. + +1. **구조체 정의:** `RelyingPartyRepository`라는 인터페이스를 똑같이 흉내 내도록 우리가 직접 코드를 짭니다. +2. **라이브러리 활용:** 이때 `github.com/stretchr/testify/mock` 라이브러리를 사용하여 "이 함수가 호출되면 어떤 값을 반환하라"는 지시를 쉽게 내릴 수 있게 만듭니다. + +**예시 코드:** +```go +// 1. 우리가 직접 만든 가짜 객체 (이름은 마음대로 정할 수 있음) +type MockRelyingPartyRepository struct { + mock.Mock // testify 라이브러리의 기능을 빌려옴 +} + +// 2. 실제 DB 저장소인 척 함수를 구현 +func (m *MockRelyingPartyRepository) Create(ctx context.Context, rp *domain.RelyingParty) error { + args := m.Called(ctx, rp) // 호출 기록 + return args.Error(0) // 설정된 에러 반환 +} +``` + +--- + +## 3. 요약 및 원칙 + +1. **모킹을 통해 커버리지를 높이세요:** 실제 환경에서 만들기 힘든 에러 상황을 모킹으로 구현하여 코드의 예외 처리 로직(Red line)을 검증하세요. +2. **핵심 비즈니스 로직 우선:** 단순한 설정값 조회보다는 **로그인, 권한 체크, 데이터 통합** 등 서비스의 핵심 기능 커버리지를 **80% 이상**으로 유지하는 것을 권장합니다. +3. **수치에 집착하지 마세요:** 커버리지 100%가 "버그 없음"을 보장하지는 않습니다. 의미 있는 테스트 케이스를 작성하는 것이 더 중요합니다. From c8510fee4e3ab3d05720ef90bfcf49fbd90a7fc2 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 13:31:52 +0900 Subject: [PATCH 5/6] =?UTF-8?q?.out=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2e4a11ea..131ce960 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .codex/ *.swp *.log +*.out # Docker Services Data (Volumes) postgres_data/ From a4e5ee78d1b7220ad55467376b8a675d202e4de3 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 5 Feb 2026 14:48:37 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=EC=9C=84=ED=82=A4=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=B0=8F=20docs=20=EC=B5=9C=EC=8B=A0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose.ory.yaml | 476 +++++++++--------- docs/consent_flow_explanation.md | 77 --- .../consent_loop_fix_report.md | 0 docs/{ => trouble-shooting}/hydra-rp-dummy.md | 0 4 files changed, 237 insertions(+), 316 deletions(-) delete mode 100644 docs/consent_flow_explanation.md rename docs/{ => trouble-shooting}/consent_loop_fix_report.md (100%) rename docs/{ => trouble-shooting}/hydra-rp-dummy.md (100%) diff --git a/compose.ory.yaml b/compose.ory.yaml index 434fbbe3..5e9d5f9d 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -1,257 +1,255 @@ services: - postgres_ory: - image: postgres:${ORY_POSTGRES_TAG:-17-alpine} - container_name: ory_postgres - environment: - - POSTGRES_USER=${ORY_POSTGRES_USER:-ory} - - POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD:-secret} - - POSTGRES_DB=${ORY_POSTGRES_DB:-ory} - volumes: - - ./docker/ory/init-db:/docker-entrypoint-initdb.d - - ory_postgres_data:/var/lib/postgresql/data - networks: - - ory-net - healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}", - ] - interval: 5s - timeout: 5s - retries: 5 + postgres_ory: + image: postgres:${ORY_POSTGRES_TAG:-17-alpine} + container_name: ory_postgres + environment: + - POSTGRES_USER=${ORY_POSTGRES_USER:-ory} + - POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD:-secret} + - POSTGRES_DB=${ORY_POSTGRES_DB:-ory} + volumes: + - ./docker/ory/init-db:/docker-entrypoint-initdb.d + - ory_postgres_data:/var/lib/postgresql/data + networks: + - ory-net + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}", + ] + interval: 5s + timeout: 5s + retries: 5 - # --- Kratos --- - kratos-migrate: - image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 - - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433} - - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} - - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000} - - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"] - - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error - - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled - - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery - - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification - - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login - - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration - - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login - volumes: - - ./docker/ory/kratos:/etc/config/kratos - command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes - depends_on: - postgres_ory: - condition: service_healthy - networks: - - ory-net + # --- Kratos --- + kratos-migrate: + image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"] + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login + volumes: + - ./docker/ory/kratos:/etc/config/kratos + command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes + depends_on: + postgres_ory: + condition: service_healthy + networks: + - ory-net - kratos: - image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} - container_name: ory_kratos - ports: - - "${KRATOS_PUBLIC_PORT:-4433}:4433" - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 - - COOKIE_SECRET=${COOKIE_SECRET:-localcookie123} - - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433} - - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} - - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000} - - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"] - - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error - - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled - - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery - - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification - - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login - - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration - - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login - volumes: - - ./docker/ory/kratos:/etc/config/kratos - command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier - depends_on: - kratos-migrate: - condition: service_completed_successfully - networks: - - ory-net - - kratosnet + kratos: + image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} + container_name: ory_kratos + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 + - COOKIE_SECRET=${COOKIE_SECRET:-localcookie123} + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"] + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login + volumes: + - ./docker/ory/kratos:/etc/config/kratos + command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier + depends_on: + kratos-migrate: + condition: service_completed_successfully + networks: + - ory-net + - kratosnet - # --- Hydra --- - hydra-migrate: - image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 - command: migrate sql up -e --yes - depends_on: - postgres_ory: - condition: service_healthy - networks: - - ory-net + # --- Hydra --- + hydra-migrate: + image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 + command: migrate sql up -e --yes + depends_on: + postgres_ory: + condition: service_healthy + networks: + - ory-net - hydra: - image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} - container_name: ory_hydra - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 - - URLS_SELF_ISSUER=${USERFRONT_URL:-http://localhost:5000}/oidc - - URLS_LOGIN=${USERFRONT_URL:-http://localhost:5000}/login - - URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent - - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} - volumes: - - ./docker/ory/hydra:/etc/config/hydra - command: serve -c /etc/config/hydra/hydra.yml all --dev - depends_on: - hydra-migrate: - condition: service_completed_successfully - networks: - - ory-net - - hydranet + hydra: + image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} + container_name: ory_hydra + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 + - URLS_SELF_ISSUER=${USERFRONT_URL:-http://localhost:5000}/oidc + - URLS_LOGIN=${USERFRONT_URL:-http://localhost:5000}/login + - URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent + - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} + volumes: + - ./docker/ory/hydra:/etc/config/hydra + command: serve -c /etc/config/hydra/hydra.yml all --dev + depends_on: + hydra-migrate: + condition: service_completed_successfully + networks: + - ory-net + - hydranet - # --- Keto --- - keto-migrate: - image: oryd/keto:${KETO_VERSION:-v25.4.0} - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 - volumes: - - ./docker/ory/keto:/etc/config/keto - command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] - depends_on: - postgres_ory: - condition: service_healthy - networks: - - ory-net + # --- Keto --- + keto-migrate: + image: oryd/keto:${KETO_VERSION:-v25.4.0} + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 + volumes: + - ./docker/ory/keto:/etc/config/keto + command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] + depends_on: + postgres_ory: + condition: service_healthy + networks: + - ory-net - keto: - image: oryd/keto:${KETO_VERSION:-v25.4.0} - container_name: ory_keto - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 - volumes: - - ./docker/ory/keto:/etc/config/keto - command: serve -c /etc/config/keto/keto.yml - depends_on: - keto-migrate: - condition: service_completed_successfully - networks: - - ory-net + keto: + image: oryd/keto:${KETO_VERSION:-v25.4.0} + container_name: ory_keto + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 + volumes: + - ./docker/ory/keto:/etc/config/keto + command: serve -c /etc/config/keto/keto.yml + depends_on: + keto-migrate: + condition: service_completed_successfully + networks: + - ory-net - # --- Oathkeeper --- - oathkeeper: - image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v25.4.0} - container_name: ory_oathkeeper - user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}" - ports: - - "4457:4455" # Proxy - environment: - - APP_ENV=${APP_ENV:-development} - - LOG_LEVEL=debug - - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} - - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} - volumes: - - ./docker/ory/oathkeeper:/etc/config/oathkeeper - - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper - entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] - networks: - - ory-net - - public_net + # --- Oathkeeper --- + oathkeeper: + image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v25.4.0} + container_name: ory_oathkeeper + user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}" + ports: + - "4457:4455" # Proxy + environment: + - APP_ENV=${APP_ENV:-development} + - LOG_LEVEL=debug + - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} + - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} + volumes: + - ./docker/ory/oathkeeper:/etc/config/oathkeeper + - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper + entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] + networks: + - ory-net + - public_net - ory_clickhouse: - image: clickhouse/clickhouse-server:latest - container_name: ory_clickhouse - environment: - - CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory} - - CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass} - volumes: - - ory_clickhouse_data:/var/lib/clickhouse - - ./docker/ory/clickhouse:/docker-entrypoint-initdb.d - networks: - - ory-net + ory_clickhouse: + image: clickhouse/clickhouse-server:latest + container_name: ory_clickhouse + environment: + - CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory} + - CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass} + volumes: + - ory_clickhouse_data:/var/lib/clickhouse + - ./docker/ory/clickhouse:/docker-entrypoint-initdb.d + networks: + - ory-net - ory_vector: - image: timberio/vector:0.36.0-alpine - container_name: ory_vector - volumes: - - ./docker/ory/vector:/etc/vector - - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper - command: ["-c", "/etc/vector/vector.toml"] - depends_on: - - oathkeeper - - ory_clickhouse - networks: - - ory-net + ory_vector: + image: timberio/vector:0.36.0-alpine + container_name: ory_vector + volumes: + - ./docker/ory/vector:/etc/vector + - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper + command: ["-c", "/etc/vector/vector.toml"] + depends_on: + - oathkeeper + - ory_clickhouse + networks: + - ory-net - # --- 초기화 & 헬스체크 --- - ory_stack_check: - image: alpine:latest - container_name: ory_stack_check - command: > - /bin/sh -c " - apk add --no-cache curl; - echo 'Wait for services...'; - until curl -s http://kratos:4433/health/ready; do sleep 1; done; - until curl -s http://hydra:4444/health/ready; do sleep 1; done; - until curl -s http://keto:4466/health/ready; do sleep 1; done; - echo 'Ory Stack is fully operational!';" - depends_on: - - kratos - - hydra - - keto - networks: - - ory-net + # --- 초기화 & 헬스체크 --- + ory_stack_check: + image: alpine:latest + container_name: ory_stack_check + command: > + /bin/sh -c " + apk add --no-cache curl; + echo 'Wait for services...'; + until curl -s http://kratos:4433/health/ready; do sleep 1; done; + until curl -s http://hydra:4444/health/ready; do sleep 1; done; + until curl -s http://keto:4466/health/ready; do sleep 1; done; + echo 'Ory Stack is fully operational!';" + depends_on: + - kratos + - hydra + - keto + networks: + - ory-net - # 기본 RP (Admin Front 등) 자동 등록 컨테이너 - init-rp: - image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} - environment: - - HYDRA_ADMIN_URL=http://hydra:4445 - - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} - - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} - command: | - hydra clients create \ - --endpoint http://hydra:4445 \ - --id adminfront \ - --secret admin-secret \ - --grant-types authorization_code,refresh_token \ - --response-types code \ - --scope openid,offline_access,profile,email \ - --callbacks http://localhost:5000/callback; + # 기본 RP (Admin Front 등) 자동 등록 컨테이너 + init-rp: + image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} + environment: + - HYDRA_ADMIN_URL=http://hydra:4445 + - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} + - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} + command: | + hydra clients create \ + --endpoint http://hydra:4445 \ + --id adminfront \ + --secret admin-secret \ + --grant-types authorization_code,refresh_token \ + --response-types code \ + --scope openid,offline_access,profile,email \ + --callbacks http://localhost:5000/callback; - hydra clients create \ - --endpoint http://hydra:4445 \ - --id devfront \ - --grant-types authorization_code,refresh_token \ - --response-types code \ - --scope openid,offline_access,profile,email \ - --token-endpoint-auth-method none \ - --response-types code \ - --callbacks http://localhost:5174/callback; + hydra clients create \ + --endpoint http://hydra:4445 \ + --id devfront \ + --grant-types authorization_code,refresh_token \ + --response-types code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --response-types code \ + --callbacks http://localhost:5174/callback; - hydra clients create \ - --endpoint http://hydra:4445 \ - --id "$OATHKEEPER_INTROSPECT_CLIENT_ID" \ - --secret "$OATHKEEPER_INTROSPECT_CLIENT_SECRET" \ - --grant-types client_credentials \ - --response-types token \ - --scope openid,offline_access,profile,email; - depends_on: - ory_stack_check: - condition: service_completed_successfully - networks: - - hydranet + hydra clients create \ + --endpoint http://hydra:4445 \ + --id "$OATHKEEPER_INTROSPECT_CLIENT_ID" \ + --secret "$OATHKEEPER_INTROSPECT_CLIENT_SECRET" \ + --grant-types client_credentials \ + --response-types token \ + --scope openid,offline_access,profile,email; + depends_on: + ory_stack_check: + condition: service_completed_successfully + networks: + - hydranet volumes: - ory_postgres_data: - ory_clickhouse_data: + ory_postgres_data: + ory_clickhouse_data: networks: - ory-net: - external: true - name: ory-net - hydranet: - external: true - name: hydranet - kratosnet: - external: true - name: kratosnet - public_net: - external: true - name: public_net + ory-net: + external: true + name: ory-net + hydranet: + external: true + name: hydranet + kratosnet: + external: true + name: kratosnet + public_net: + external: true + name: public_net diff --git a/docs/consent_flow_explanation.md b/docs/consent_flow_explanation.md deleted file mode 100644 index 985dfd76..00000000 --- a/docs/consent_flow_explanation.md +++ /dev/null @@ -1,77 +0,0 @@ -# Baron SSO Consent(권한 동의) 흐름 설명 - -이 문서는 Baron SSO 시스템에서 `/consent` 페이지가 어떻게 구현되어 있으며, 사용자가 어떻게 이 페이지로 이동하게 되는지 설명합니다. - -## 1. 개요 - -Consent(권한 동의) 흐름은 **Ory Hydra**가 처리하는 OAuth2/OpenID Connect 프로토콜의 핵심 절차입니다. 클라이언트 앱(Relying Party, RP)이 사용자의 정보에 접근하기 위해 특정 권한(Scope)을 요청할 때, 사용자가 아직 해당 권한을 승인하지 않았다면 Hydra는 사용자를 설정된 Consent URL로 리다이렉트하여 동의를 구합니다. - -## 2. `/consent` 페이지로의 리다이렉트 과정 - -사용자가 `/consent` 페이지로 이동하는 것은 Ory Hydra의 환경 설정에 의해 제어됩니다. - -- **설정 파일**: `compose.ory.yaml` (및 `docker-compose.yaml`) -- **환경 변수**: `URLS_CONSENT` - -`compose.ory.yaml` 파일 내 `hydra` 서비스의 설정은 다음과 같습니다: - -```yaml -hydra: - environment: - - URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent -``` - -Hydra는 권한 동의가 필요하다고 판단하면, 사용자의 브라우저를 다음 주소로 리다이렉트합니다: -`{USERFRONT_URL}/consent?consent_challenge={challenge_id}` - -## 3. 프론트엔드 구현 (`userfront`) - -`userfront` 애플리케이션(Flutter)은 권한 동의 화면의 UI와 사용자 상호작용을 처리합니다. - -### 라우트 처리 -- **파일**: `userfront/lib/main.dart` -- **로직**: 라우터 설정에서 `/consent` 경로를 처리합니다. URL 쿼리 파라미터에서 `consent_challenge`를 추출하여 `ConsentScreen` 위젯에 전달합니다. - -### UI 및 비즈니스 로직 -- **파일**: `userfront/lib/features/auth/presentation/consent_screen.dart` -- **로직**: - 1. **정보 로드**: 페이지 로드 시 `AuthProxyService.getConsentInfo(widget.consentChallenge)`를 호출하여 동의 요청의 상세 정보를 가져옵니다. - 2. **화면 표시**: 접근을 요청한 앱의 이름과 요청된 권한 목록(Scope)을 사용자에게 보여줍니다. - 3. **동의 실행**: 사용자가 "동의" 버튼을 누르면 `AuthProxyService.acceptConsent(widget.consentChallenge)`를 호출합니다. - 4. **최종 이동**: 백엔드로부터 성공 응답과 함께 `redirectTo` URL을 받으면, 해당 URL로 브라우저를 이동시켜 로그인/인증 과정을 완료합니다. - -### API 서비스 -- **파일**: `userfront/lib/core/services/auth_proxy_service.dart` -- **엔드포인트**: - - 정보 조회: `GET /api/v1/auth/consent` - - 동의 수락: `POST /api/v1/auth/consent/accept` - -## 4. 백엔드 구현 (`backend`) - -백엔드는 프론트엔드와 Ory Hydra Admin API 사이에서 보안 및 통신을 중계합니다. - -### 핸들러 -- **파일**: `backend/internal/handler/auth_handler.go` -- **주요 함수**: - - `GetConsentRequest`: 전달받은 `challenge`를 사용하여 Hydra로부터 권한 동의 요청의 상세 내용(클라이언트 정보, 스코프 등)을 가져옵니다. - - `AcceptConsentRequest`: Hydra에 권한 동의 수락을 통보합니다. Hydra가 생성한 최종 리다이렉트 URL(`redirect_to`)을 응답으로 받아 프론트엔드에 전달합니다. - -### Hydra Admin 서비스 연동 -- **파일**: `backend/internal/service/hydra_admin_service.go` -- **로직**: Hydra Admin API와 직접 통신합니다. - - 정보 조회: `GET /oauth2/auth/requests/consent` (Hydra Admin 인터페이스) - - 동의 수락: `PUT /oauth2/auth/requests/consent/accept` (Hydra Admin 인터페이스) - -## 5. 전체 흐름 요약 - -1. **사용자**: 클라이언트 앱(예: adminfront, devfront 등)에서 로그인을 시도합니다. -2. **Hydra**: 사용자의 세션을 확인하고, 해당 앱에 필요한 권한 동의가 있는지 확인합니다. 동의가 필요하면 사용자를 `USERFRONT_URL/consent?consent_challenge=...`로 보냅니다. -3. **Userfront**: `/consent` 페이지가 로드되고 `consent_challenge`를 인식합니다. -4. **Userfront -> Backend**: `GET /api/v1/auth/consent`를 호출하여 어떤 권한을 요청 중인지 묻습니다. -5. **Backend -> Hydra**: Hydra Admin API에 해당 챌린지의 상세 정보를 조회하여 반환합니다. -6. **사용자**: 화면에서 권한 내용을 확인하고 "동의"를 클릭합니다. -7. **Userfront -> Backend**: `POST /api/v1/auth/consent/accept`를 호출합니다. -8. **Backend -> Hydra**: Hydra Admin API에 동의 수락을 요청합니다. -9. **Hydra -> Backend**: 인증을 완료할 수 있는 최종 리다이렉트 URL(클라이언트 앱의 callback 주소)을 반환합니다. -10. **Backend -> Userfront**: 해당 URL을 전달합니다. -11. **Userfront**: 사용자를 클라이언트 앱으로 이동시키며 프로세스가 종료됩니다. \ No newline at end of file diff --git a/docs/consent_loop_fix_report.md b/docs/trouble-shooting/consent_loop_fix_report.md similarity index 100% rename from docs/consent_loop_fix_report.md rename to docs/trouble-shooting/consent_loop_fix_report.md diff --git a/docs/hydra-rp-dummy.md b/docs/trouble-shooting/hydra-rp-dummy.md similarity index 100% rename from docs/hydra-rp-dummy.md rename to docs/trouble-shooting/hydra-rp-dummy.md