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/ 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"]) + } +} diff --git a/backend/internal/service/hydra_admin_service_test.go b/backend/internal/service/hydra_admin_service_test.go index 1a65bfc2..d2f48d8d 100644 --- a/backend/internal/service/hydra_admin_service_test.go +++ b/backend/internal/service/hydra_admin_service_test.go @@ -284,11 +284,11 @@ func TestHydraAdminService_ErrorHandling(t *testing.T) { defer server.Close() s := &HydraAdminService{AdminURL: server.URL} - + _, err := s.GetClient(context.Background(), "invalid") assert.Error(t, err) assert.Contains(t, err.Error(), "status=400") - + err = s.DeleteClient(context.Background(), "invalid") assert.Error(t, err) @@ -306,7 +306,7 @@ func TestHydraAdminService_NotFound(t *testing.T) { defer server.Close() s := &HydraAdminService{AdminURL: server.URL} - + _, err := s.GetClient(context.Background(), "none") assert.Equal(t, ErrHydraNotFound, err) } 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) +} 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