/* 이 테스트 파일은 AuthHandler의 PasswordLogin 메서드 내 OIDC/Hydra 관련 로직을 검증합니다. 특히 Hydra Login Request 조회, 검증(Inactive 체크), 승인(Accept) 흐름을 중점적으로 테스트합니다. */ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" "context" "crypto/rand" "crypto/rsa" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-jose/go-jose/v4" josejwt "github.com/go-jose/go-jose/v4/jwt" "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 { mock.Mock } func (m *MockKratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { args := m.Called(ctx, identifier) return args.String(0), args.Error(1) } func (m *MockKratosAdminService) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) { args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*service.KratosIdentity), args.Error(1) } func (m *MockKratosAdminService) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) { return nil, nil } func (m *MockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) { return nil, nil } func (m *MockKratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { return nil } func (m *MockKratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error { return nil } // --- Helper --- func newAuthLoginTestApp(h *AuthHandler) *fiber.App { app := fiber.New() app.Post("/api/v1/auth/login", h.PasswordLogin) return app } func newHeadlessPasswordLoginTestApp(h *AuthHandler) *fiber.App { app := fiber.New() app.Post("/api/v1/auth/headless/password/login", h.HeadlessPasswordLogin) return app } func mustHeadlessRSAJWK(t *testing.T) (*rsa.PrivateKey, map[string]any) { t.Helper() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("failed to generate rsa key: %v", err) } keySet := jose.JSONWebKeySet{ Keys: []jose.JSONWebKey{ { Key: &privateKey.PublicKey, KeyID: "test-kid", Use: "sig", Algorithm: string(jose.RS256), }, }, } raw, err := json.Marshal(keySet) if err != nil { t.Fatalf("failed to marshal jwks: %v", err) } var jwks map[string]any if err := json.Unmarshal(raw, &jwks); err != nil { t.Fatalf("failed to decode jwks map: %v", err) } return privateKey, jwks } func mustHeadlessClientAssertion(t *testing.T, privateKey *rsa.PrivateKey, clientID, audience string) string { t.Helper() signer, err := jose.NewSigner(jose.SigningKey{ Algorithm: jose.RS256, Key: jose.JSONWebKey{ Key: privateKey, KeyID: "test-kid", Use: "sig", Algorithm: string(jose.RS256), }, }, nil) if err != nil { t.Fatalf("failed to create signer: %v", err) } now := time.Now() raw, err := josejwt.Signed(signer).Claims(josejwt.Claims{ Issuer: clientID, Subject: clientID, Audience: josejwt.Audience{audience}, Expiry: josejwt.NewNumericDate(now.Add(5 * time.Minute)), IssuedAt: josejwt.NewNumericDate(now), NotBefore: josejwt.NewNumericDate( now.Add(-1 * time.Minute), ), ID: "assertion-1", }).Serialize() if err != nil { t.Fatalf("failed to sign client assertion: %v", err) } return raw } // 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) } }) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } 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]interface{} json.NewDecoder(resp.Body).Decode(&got) if got["redirectTo"] != "http://rp/cb" { t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"]) } if _, ok := got["sessionJwt"]; ok { t.Errorf("expected OIDC response to omit sessionJwt, got %v", got["sessionJwt"]) } } func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, Subject: "kratos-identity-id", }, nil) privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write(jwksBody) })) defer jwksServer.Close() 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: json.NewEncoder(w).Encode(domain.HydraLoginRequest{ Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", TokenEndpointAuthMethod: "none", Metadata: map[string]interface{}{ "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", }, }, }) case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) default: http.NotFound(w, r) } }) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } app := newHeadlessPasswordLoginTestApp(h) clientAssertion := mustHeadlessClientAssertion( t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/password/login", ) body, _ := json.Marshal(map[string]string{ "client_id": "trusted-rp", "client_assertion": clientAssertion, "loginId": "employee001", "password": "password", "login_challenge": "challenge-123", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/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]interface{} if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("failed to decode response: %v", err) } if got["redirectTo"] != "http://rp/cb" { t.Fatalf("expected redirectTo http://rp/cb, got %v", got["redirectTo"]) } if _, ok := got["sessionJwt"]; ok { t.Fatalf("expected headless response to omit sessionJwt, got %v", got["sessionJwt"]) } } func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, Subject: "kratos-identity-id", }, nil) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil) 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: json.NewEncoder(w).Encode(domain.HydraLoginRequest{ Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", TokenEndpointAuthMethod: "private_key_jwt", JWKS: map[string]any{ "keys": []map[string]any{}, }, Metadata: map[string]interface{}{ "status": "active", "headless_login_enabled": true, }, }, }) return case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) return } http.NotFound(w, r) }) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } app := newHeadlessPasswordLoginTestApp(h) body, _ := json.Marshal(map[string]string{ "client_id": "trusted-rp", "loginId": "employee001", "password": "password", "login_challenge": "challenge-123", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/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.StatusBadRequest { bodyBytes, _ := io.ReadAll(resp.Body) t.Fatalf("expected 400, got %d, body: %s", resp.StatusCode, string(bodyBytes)) } } func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, Subject: "kratos-identity-id", }, nil) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil) validKey, jwks := mustHeadlessRSAJWK(t) invalidKey, _ := mustHeadlessRSAJWK(t) _ = validKey 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: json.NewEncoder(w).Encode(domain.HydraLoginRequest{ Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", TokenEndpointAuthMethod: "private_key_jwt", JWKS: jwks, Metadata: map[string]interface{}{ "status": "active", "headless_login_enabled": true, }, }, }) return case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) return } http.NotFound(w, r) }) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } app := newHeadlessPasswordLoginTestApp(h) clientAssertion := mustHeadlessClientAssertion( t, invalidKey, "trusted-rp", "http://example.com/api/v1/auth/headless/password/login", ) body, _ := json.Marshal(map[string]string{ "client_id": "trusted-rp", "client_assertion": clientAssertion, "loginId": "employee001", "password": "password", "login_challenge": "challenge-123", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/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.StatusUnauthorized { bodyBytes, _ := io.ReadAll(resp.Body) t.Fatalf("expected 401, got %d, body: %s", resp.StatusCode, string(bodyBytes)) } } func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) { mockIdp := new(MockIdentityProvider) 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{ Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", TokenEndpointAuthMethod: "none", Metadata: map[string]interface{}{ "status": "active", "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", "headless_token_endpoint_auth_method": "private_key_jwt", }, }, }) return } http.NotFound(w, r) }) h := &AuthHandler{ IdpProvider: mockIdp, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } app := newHeadlessPasswordLoginTestApp(h) body, _ := json.Marshal(map[string]string{ "client_id": "trusted-rp", "loginId": "employee001", "password": "password", "login_challenge": "challenge-123", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/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.StatusForbidden { bodyBytes, _ := io.ReadAll(resp.Body) t.Fatalf("expected 403, got %d, body: %s", resp.StatusCode, string(bodyBytes)) } } func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) { mockIdp := new(MockIdentityProvider) 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{ Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "other-rp", TokenEndpointAuthMethod: "none", Metadata: map[string]interface{}{ "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", }, }, }) return } http.NotFound(w, r) }) h := &AuthHandler{ IdpProvider: mockIdp, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } app := newHeadlessPasswordLoginTestApp(h) body, _ := json.Marshal(map[string]string{ "client_id": "trusted-rp", "loginId": "employee001", "password": "password", "login_challenge": "challenge-123", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/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.StatusForbidden { bodyBytes, _ := io.ReadAll(resp.Body) t.Fatalf("expected 403, got %d, body: %s", resp.StatusCode, string(bodyBytes)) } } 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) }) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } 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) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, Hydra: service.NewHydraAdminService(), } 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"]) } } func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "user@example.com", "wrong-password").Return(nil, errors.New("비밀번호가 일치하지 않습니다")) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: service.NewKratosAdminService(), Hydra: service.NewHydraAdminService(), } app := newAuthLoginTestApp(h) body, _ := json.Marshal(map[string]string{ "loginId": "user@example.com", "password": "wrong-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.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } var got map[string]any if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("failed to decode response: %v", err) } if got["code"] != "password_or_email_mismatch" { t.Fatalf("expected code=password_or_email_mismatch, got=%v", got["code"]) } if got["error"] != "Invalid credentials" { t.Fatalf("expected error=Invalid credentials, got=%v", got["error"]) } }