/* 이 테스트 파일은 AuthHandler의 PasswordLogin 메서드 내 OIDC/Hydra 관련 로직을 검증합니다. 특히 Hydra Login Request 조회, 검증(Inactive 체크), 승인(Accept) 흐름을 중점적으로 테스트합니다. */ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/middleware" "baron-sso-backend/internal/service" "baron-sso-backend/internal/testsupport" "bytes" "context" "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/rsa" "encoding/json" "errors" "io" "log/slog" "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" "github.com/stretchr/testify/require" ) // --- 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 } func (m *MockKratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { args := m.Called(ctx, identityID) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]service.KratosSession), args.Error(1) } func (m *MockKratosAdminService) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { args := m.Called(ctx, sessionID) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*service.KratosSession), args.Error(1) } func (m *MockKratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { args := m.Called(ctx, sessionID) return args.Error(0) } // --- 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 } type passwordLoginUserRepo struct { usersByID map[string]domain.User } func (r *passwordLoginUserRepo) Create(ctx context.Context, user *domain.User) error { return nil } func (r *passwordLoginUserRepo) Update(ctx context.Context, user *domain.User) error { return nil } func (r *passwordLoginUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) { return nil, errors.New("not found") } func (r *passwordLoginUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) { if r != nil { if user, ok := r.usersByID[id]; ok { return &user, nil } } return nil, errors.New("not found") } func (r *passwordLoginUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { return nil, nil } func (r *passwordLoginUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { return nil, nil } func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { return nil, 0, nil } func (r *passwordLoginUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { return 0, nil } func (r *passwordLoginUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { return nil, nil } func (r *passwordLoginUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { return nil, nil } func (r *passwordLoginUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) { return nil, nil } func (r *passwordLoginUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) { return nil, nil } func (r *passwordLoginUserRepo) Delete(ctx context.Context, id string) error { return nil } func (r *passwordLoginUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error { return nil } func (r *passwordLoginUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) { return nil, nil } func (r *passwordLoginUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) { return false, nil } func (r *passwordLoginUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) { return "", nil } 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 } func mustHeadlessClientAssertionWithCustomClaims( t *testing.T, privateKey *rsa.PrivateKey, clientID string, claims josejwt.Claims, ) 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) } raw, err := josejwt.Signed(signer).Claims(claims).Serialize() if err != nil { t.Fatalf("failed to sign client assertion: %v", err) } return raw } func mustHeadlessJWKForAlgorithm(t *testing.T, alg jose.SignatureAlgorithm) (any, map[string]any) { t.Helper() var privateKey any var publicKey any switch alg { case jose.RS256, jose.RS384, jose.RS512, jose.PS256, jose.PS384, jose.PS512: key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("failed to generate rsa key: %v", err) } privateKey = key publicKey = &key.PublicKey case jose.ES256: key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("failed to generate ecdsa key: %v", err) } privateKey = key publicKey = &key.PublicKey case jose.ES384: key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) if err != nil { t.Fatalf("failed to generate ecdsa key: %v", err) } privateKey = key publicKey = &key.PublicKey case jose.ES512: key, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) if err != nil { t.Fatalf("failed to generate ecdsa key: %v", err) } privateKey = key publicKey = &key.PublicKey case jose.EdDSA: _, key, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("failed to generate ed25519 key: %v", err) } privateKey = key publicKey = key.Public() default: t.Fatalf("unsupported test algorithm: %s", alg) } keySet := jose.JSONWebKeySet{ Keys: []jose.JSONWebKey{ { Key: publicKey, KeyID: "test-kid", Use: "sig", Algorithm: string(alg), }, }, } 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 mustHeadlessClientAssertionWithAlgorithm(t *testing.T, privateKey any, alg jose.SignatureAlgorithm, clientID, audience string) string { t.Helper() signer, err := jose.NewSigner(jose.SigningKey{ Algorithm: alg, Key: jose.JSONWebKey{ Key: privateKey, KeyID: "test-kid", Use: "sig", Algorithm: string(alg), }, }, 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 } func runHeadlessPasswordLoginWithAssertion(t *testing.T, jwks map[string]any, clientAssertion string) *http.Response { t.Helper() t.Setenv("BACKEND_PUBLIC_URL", "") return runHeadlessPasswordLoginWithAssertionRequest(t, jwks, clientAssertion, "http://example.com/api/v1/auth/headless/password/login", nil) } func runHeadlessPasswordLoginWithAssertionRequest( t *testing.T, jwks map[string]any, clientAssertion string, requestURL string, headers map[string]string, ) *http.Response { t.Helper() if !testsupport.PortBindingAvailable() { t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") } 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) jwksBody, err := json.Marshal(jwks) if err != nil { t.Fatalf("failed to marshal jwks body: %v", err) } jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write(jwksBody) })) t.Cleanup(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: "headless-login-client", 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", }, }, }) 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": "headless-login-client", "client_assertion": clientAssertion, "loginId": "employee001", "password": "password", "login_challenge": "challenge-123", }) req := httptest.NewRequest(http.MethodPost, requestURL, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") for key, value := range headers { req.Header.Set(key, value) } resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } return resp } func runHeadlessPasswordLoginWithAssertionAndLogger( t *testing.T, jwks map[string]any, clientAssertion string, logger *slog.Logger, ) *http.Response { t.Helper() t.Setenv("BACKEND_PUBLIC_URL", "") return runHeadlessPasswordLoginWithAssertionAndLoggerRequest( t, jwks, clientAssertion, "http://example.com/api/v1/auth/headless/password/login", nil, logger, ) } func runHeadlessPasswordLoginWithAssertionAndLoggerRequest( t *testing.T, jwks map[string]any, clientAssertion string, requestURL string, headers map[string]string, logger *slog.Logger, ) *http.Response { t.Helper() if !testsupport.PortBindingAvailable() { t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") } 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) jwksBody, err := json.Marshal(jwks) if err != nil { t.Fatalf("failed to marshal jwks body: %v", err) } jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write(jwksBody) })) t.Cleanup(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: "headless-login-client", 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", }, }, }) 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": "headless-login-client", "client_assertion": clientAssertion, "loginId": "employee001", "password": "password", "login_challenge": "challenge-123", }) req := httptest.NewRequest(http.MethodPost, requestURL, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Request-Id", "req-headless-test-123") for key, value := range headers { req.Header.Set(key, value) } if logger != nil { previous := slog.Default() slog.SetDefault(logger.With()) t.Cleanup(func() { slog.SetDefault(previous) }) } resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } return resp } // 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 got["sessionJwt"] != "valid-jwt" { t.Errorf("expected sessionJwt to be valid-jwt, got %v", got["sessionJwt"]) } if got["token"] != "valid-jwt" { t.Errorf("expected token to be valid-jwt, got %v", got["token"]) } } func TestPasswordLogin_OIDC_AuditIncludesClientMetadata(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"}, Subject: "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: "devfront", ClientName: "DevFront", Metadata: map[string]interface{}{"status": "active"}, }, }) 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, "user@example.com").Return("kratos-identity-id", nil) auditRepo := &mockAuditRepo{} h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, AuditRepo: auditRepo, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } app := fiber.New() app.Use(middleware.AuditMiddleware(middleware.AuditConfig{ Repo: auditRepo, BodyDump: true, })) app.Post("/api/v1/auth/password/login", h.PasswordLogin) body, _ := json.Marshal(map[string]string{ "loginId": "user@example.com", "password": "password", "login_challenge": "challenge-123", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/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)) } if len(auditRepo.logs) != 1 { t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs)) } log := auditRepo.logs[0] if log.EventType != "POST /api/v1/auth/password/login" { t.Fatalf("expected password login audit event, got %q", log.EventType) } if log.UserID != "kratos-identity-id" { t.Fatalf("expected audit user_id kratos-identity-id, got %q", log.UserID) } details, err := parseAuditDetails(log.Details) if err != nil { t.Fatalf("failed to parse audit details: %v", err) } if got, _ := details["client_id"].(string); got != "devfront" { t.Fatalf("expected client_id devfront, got %v", details["client_id"]) } if got, _ := details["client_name"].(string); got != "DevFront" { t.Fatalf("expected client_name DevFront, got %v", details["client_name"]) } } func TestPasswordLogin_UserFront_AuditIncludesDefaultClientMetadata(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"}, Subject: "kratos-identity-id", }, nil) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) auditRepo := &mockAuditRepo{} h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, AuditRepo: auditRepo, } app := fiber.New() app.Use(middleware.AuditMiddleware(middleware.AuditConfig{ Repo: auditRepo, BodyDump: true, })) app.Post("/api/v1/auth/password/login", h.PasswordLogin) body, _ := json.Marshal(map[string]string{ "loginId": "user@example.com", "password": "password", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/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)) } if len(auditRepo.logs) != 1 { t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs)) } if auditRepo.logs[0].UserID != "kratos-identity-id" { t.Fatalf("expected audit user_id kratos-identity-id, got %q", auditRepo.logs[0].UserID) } details, err := parseAuditDetails(auditRepo.logs[0].Details) if err != nil { t.Fatalf("failed to parse audit details: %v", err) } if got, _ := details["client_id"].(string); got != "userfront" { t.Fatalf("expected client_id userfront, got %v", details["client_id"]) } if got, _ := details["client_name"].(string); got != "UserFront" { t.Fatalf("expected client_name UserFront, got %v", details["client_name"]) } } func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") if !testsupport.PortBindingAvailable() { t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") } 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: "headless-login-client", 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, "headless-login-client", "http://example.com/api/v1/auth/headless/password/login", ) body, _ := json.Marshal(map[string]string{ "client_id": "headless-login-client", "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"]) } if len(resp.Cookies()) != 0 { t.Fatalf("expected headless response to omit cookies, got %v", resp.Cookies()) } } func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") if !testsupport.PortBindingAvailable() { t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") } mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee002", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, Subject: "kratos-target-b", }, 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() acceptCalled := false 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", Skip: true, Subject: "kratos-userfront-a", Client: domain.HydraClient{ ClientID: "headless-login-client", 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: acceptCalled = true _ = 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, "employee002").Return("kratos-target-b", nil) 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": "headless-login-client", "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/password/login"), "loginId": "employee002", "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) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusConflict, resp.StatusCode) require.False(t, acceptCalled) require.Empty(t, resp.Cookies()) var got map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.Equal(t, "oidc_subject_conflict", got["code"]) require.Equal(t, "redirect_to_userfront_login", got["recommendedAction"]) require.Equal(t, "kratos-userfront-a", got["currentSubject"]) require.Equal(t, "kratos-target-b", got["targetSubject"]) } func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") if !testsupport.PortBindingAvailable() { t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") } mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, Subject: "kratos-userfront-a", }, 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", Skip: true, Subject: "kratos-userfront-a", Client: domain.HydraClient{ ClientID: "headless-login-client", 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-userfront-a", nil) 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": "headless-login-client", "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/password/login"), "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) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) require.Empty(t, resp.Cookies()) var got map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.Equal(t, "http://rp/cb", got["redirectTo"]) require.Nil(t, got["sessionJwt"]) } func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"}, Subject: "kratos-identity-id", }, nil) privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) 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: "headless-login-client", ClientName: "Headless Login Portal", 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", }, }, }) 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) auditRepo := &mockAuditRepo{} headlessClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Host == "rp.example.com" && r.URL.Path == "/.well-known/jwks.json" { return httpResponse(r, http.StatusOK, string(jwksBody)), nil } return httpResponse(r, http.StatusNotFound, "not found"), nil })} h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, AuditRepo: auditRepo, HeadlessJWKS: service.NewHeadlessJWKSCacheService( nil, headlessClient, ), Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } app := fiber.New() app.Use(middleware.AuditMiddleware(middleware.AuditConfig{ Repo: auditRepo, BodyDump: true, })) app.Post("/api/v1/auth/headless/password/login", h.HeadlessPasswordLogin) clientAssertion := mustHeadlessClientAssertion( t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/password/login", ) body, _ := json.Marshal(map[string]string{ "client_id": "headless-login-client", "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") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36") 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)) } if len(auditRepo.logs) != 1 { t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs)) } log := auditRepo.logs[0] if log.EventType != "POST /api/v1/auth/headless/password/login" { t.Fatalf("expected headless password login audit event, got %q", log.EventType) } if log.UserID != "kratos-identity-id" { t.Fatalf("expected audit user_id kratos-identity-id, got %q", log.UserID) } if log.SessionID != "session-123" { t.Fatalf("expected audit session_id session-123, got %q", log.SessionID) } details, err := parseAuditDetails(log.Details) if err != nil { t.Fatalf("failed to parse audit details: %v", err) } if got, _ := details["client_id"].(string); got != "headless-login-client" { t.Fatalf("expected client_id headless-login-client, got %v", details["client_id"]) } if got, _ := details["client_name"].(string); got != "Headless Login Portal" { t.Fatalf("expected client_name Headless Login Portal, got %v", details["client_name"]) } if got, _ := details["login_challenge"].(string); got != "challenge-123" { t.Fatalf("expected login_challenge challenge-123, got %v", details["login_challenge"]) } } func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") if !testsupport.PortBindingAvailable() { t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") } 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: "headless-login-client", 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", "headless_jwks": map[string]any{ "keys": []map[string]any{}, }, }, }, }) 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, "headless-login-client", "http://example.com/api/v1/auth/headless/password/login", ) body, _ := json.Marshal(map[string]string{ "client_id": "headless-login-client", "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)) } } func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") if !testsupport.PortBindingAvailable() { t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") } mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, Subject: "kratos-identity-id", }, nil) stalePrivateKey, staleJWKS := mustHeadlessRSAJWK(t) staleRaw, err := json.Marshal(staleJWKS) if err != nil { t.Fatalf("failed to marshal stale jwks: %v", err) } freshPrivateKey, freshJWKS := mustHeadlessRSAJWK(t) freshRaw, err := json.Marshal(freshJWKS) if err != nil { t.Fatalf("failed to marshal fresh jwks: %v", err) } fetchCount := 0 jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fetchCount++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write(freshRaw) })) 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: "headless-login-client", 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) redisRepo := &testRedisRepo{values: map[string]string{}} cacheService := service.NewHeadlessJWKSCacheService(redisRepo, jwksServer.Client()) now := time.Now() expiresAt := now.Add(30 * time.Minute) if err := cacheService.SaveState("headless-login-client", domain.HeadlessJWKSCacheState{ ClientID: "headless-login-client", JWKSURI: jwksServer.URL + "/.well-known/jwks.json", RawJWKS: string(staleRaw), CachedKids: []string{"test-kid"}, CachedAt: &now, LastCheckedAt: &now, ExpiresAt: &expiresAt, LastRefreshStatus: "success", }); err != nil { t.Fatalf("failed to save cached jwks state: %v", err) } h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, RedisService: redisRepo, HeadlessJWKS: cacheService, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } app := newHeadlessPasswordLoginTestApp(h) clientAssertion := mustHeadlessClientAssertion( t, freshPrivateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/password/login", ) body, _ := json.Marshal(map[string]string{ "client_id": "headless-login-client", "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)) } if fetchCount != 1 { t.Fatalf("expected exactly one jwks refresh fetch, got %d", fetchCount) } if stalePrivateKey == nil { t.Fatalf("expected stale key to be generated") } } 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: "headless-login-client", 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 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": "headless-login-client", "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) { if !testsupport.PortBindingAvailable() { t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") } 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 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: "headless-login-client", 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", }, }, }) 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, "headless-login-client", "http://example.com/api/v1/auth/headless/password/login", ) body, _ := json.Marshal(map[string]string{ "client_id": "headless-login-client", "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)) } 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"] != "invalid_client_assertion_signature" { t.Fatalf("expected code=invalid_client_assertion_signature, got=%v", got["code"]) } if got["error"] != "Client assertion signature verification failed" { t.Fatalf("expected detailed signature error, got=%v", got["error"]) } } func TestHeadlessPasswordLogin_AudienceMismatchReturnsDetailedCode(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) clientAssertion := mustHeadlessClientAssertion( t, privateKey, "headless-login-client", "https://rp.example.com/oidc/token", ) resp := runHeadlessPasswordLoginWithAssertion(t, jwks, clientAssertion) 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)) } 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"] != "invalid_client_assertion_audience" { t.Fatalf("expected code=invalid_client_assertion_audience, got=%v", got["code"]) } if got["error"] != "Client assertion audience mismatch" { t.Fatalf("expected audience mismatch error, got=%v", got["error"]) } } func TestHeadlessPasswordLogin_AcceptsForwardedHTTPSAudience(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") privateKey, jwks := mustHeadlessRSAJWK(t) clientAssertion := mustHeadlessClientAssertion( t, privateKey, "headless-login-client", "https://sso.hmac.kr/api/v1/auth/headless/password/login", ) resp := runHeadlessPasswordLoginWithAssertionRequest( t, jwks, clientAssertion, "http://sso.hmac.kr/api/v1/auth/headless/password/login", map[string]string{ "X-Forwarded-Proto": "https", "X-Forwarded-Host": "sso.hmac.kr", }, ) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) t.Fatalf("expected 200 for forwarded https audience, got %d, body: %s", resp.StatusCode, string(bodyBytes)) } 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["redirectTo"] != "http://rp/cb" { t.Fatalf("expected redirectTo, got=%v", got["redirectTo"]) } } func TestHeadlessPasswordLogin_AcceptsConfiguredPublicHTTPSAudience(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "https://sso.hmac.kr") privateKey, jwks := mustHeadlessRSAJWK(t) clientAssertion := mustHeadlessClientAssertion( t, privateKey, "headless-login-client", "https://sso.hmac.kr/api/v1/auth/headless/password/login", ) resp := runHeadlessPasswordLoginWithAssertionRequest( t, jwks, clientAssertion, "http://sso.hmac.kr/api/v1/auth/headless/password/login", nil, ) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) t.Fatalf("expected 200 for configured public https audience, got %d, body: %s", resp.StatusCode, string(bodyBytes)) } 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["redirectTo"] != "http://rp/cb" { t.Fatalf("expected redirectTo, got=%v", got["redirectTo"]) } } func TestHeadlessPasswordLogin_RejectsHTTPAudienceWhenConfiguredPublicURLIsHTTPS(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "https://sso.hmac.kr") privateKey, jwks := mustHeadlessRSAJWK(t) clientAssertion := mustHeadlessClientAssertion( t, privateKey, "headless-login-client", "http://sso.hmac.kr/api/v1/auth/headless/password/login", ) resp := runHeadlessPasswordLoginWithAssertionRequest( t, jwks, clientAssertion, "http://sso.hmac.kr/api/v1/auth/headless/password/login", nil, ) defer resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { bodyBytes, _ := io.ReadAll(resp.Body) t.Fatalf("expected 401 for mismatched http audience, got %d, body: %s", resp.StatusCode, string(bodyBytes)) } 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"] != "invalid_client_assertion_audience" { t.Fatalf("expected invalid_client_assertion_audience, got=%v", got["code"]) } } func TestHeadlessPasswordLogin_IssSubMismatchReturnsDetailedCode(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) now := time.Now() clientAssertion := mustHeadlessClientAssertionWithCustomClaims( t, privateKey, "headless-login-client", josejwt.Claims{ Issuer: "other-client", Subject: "headless-login-client", Audience: josejwt.Audience{"http://example.com/api/v1/auth/headless/password/login"}, Expiry: josejwt.NewNumericDate(now.Add(5 * time.Minute)), IssuedAt: josejwt.NewNumericDate(now), NotBefore: josejwt.NewNumericDate( now.Add(-1 * time.Minute), ), ID: "assertion-iss-mismatch", }, ) resp := runHeadlessPasswordLoginWithAssertion(t, jwks, clientAssertion) 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)) } 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"] != "invalid_client_assertion_iss_sub" { t.Fatalf("expected code=invalid_client_assertion_iss_sub, got=%v", got["code"]) } if got["error"] != "Client assertion issuer or subject mismatch" { t.Fatalf("expected iss/sub mismatch error, got=%v", got["error"]) } } func TestHeadlessPasswordLogin_ExpiredAssertionReturnsDetailedCode(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) now := time.Now() clientAssertion := mustHeadlessClientAssertionWithCustomClaims( t, privateKey, "headless-login-client", josejwt.Claims{ Issuer: "headless-login-client", Subject: "headless-login-client", Audience: josejwt.Audience{"http://example.com/api/v1/auth/headless/password/login"}, Expiry: josejwt.NewNumericDate(now.Add(-1 * time.Minute)), IssuedAt: josejwt.NewNumericDate(now.Add(-10 * time.Minute)), NotBefore: josejwt.NewNumericDate( now.Add(-11 * time.Minute), ), ID: "assertion-expired", }, ) resp := runHeadlessPasswordLoginWithAssertion(t, jwks, clientAssertion) 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)) } 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"] != "invalid_client_assertion_expired" { t.Fatalf("expected code=invalid_client_assertion_expired, got=%v", got["code"]) } if got["error"] != "Client assertion has expired" { t.Fatalf("expected expired assertion error, got=%v", got["error"]) } } func TestHeadlessPasswordLogin_DebugLogIncludesDiagnostics(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) clientAssertion := mustHeadlessClientAssertion( t, privateKey, "headless-login-client", "https://rp.example.com/oidc/token", ) buf := &bytes.Buffer{} logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) resp := runHeadlessPasswordLoginWithAssertionAndLogger(t, jwks, clientAssertion, logger) 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)) } logOutput := buf.String() if !strings.Contains(logOutput, "expected_audiences") { t.Fatalf("expected debug log to include expected_audiences, got=%s", logOutput) } if !strings.Contains(logOutput, "received_audiences") { t.Fatalf("expected debug log to include received_audiences, got=%s", logOutput) } if !strings.Contains(logOutput, "invalid_client_assertion_audience") { t.Fatalf("expected debug log to include reason code, got=%s", logOutput) } } func TestHeadlessPasswordLogin_InfoLogOmitsDebugDiagnostics(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) clientAssertion := mustHeadlessClientAssertion( t, privateKey, "headless-login-client", "https://rp.example.com/oidc/token", ) buf := &bytes.Buffer{} logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo})) resp := runHeadlessPasswordLoginWithAssertionAndLogger(t, jwks, clientAssertion, logger) 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)) } logOutput := buf.String() if strings.Contains(logOutput, "expected_audiences") { t.Fatalf("expected info log to omit expected_audiences, got=%s", logOutput) } if strings.Contains(logOutput, "received_audiences") { t.Fatalf("expected info log to omit received_audiences, got=%s", logOutput) } } func TestHeadlessPasswordLogin_SuccessLogIncludesCorrelationFields(t *testing.T) { privateKey, jwks := mustHeadlessRSAJWK(t) clientAssertion := mustHeadlessClientAssertion( t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/password/login", ) buf := &bytes.Buffer{} logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo})) resp := runHeadlessPasswordLoginWithAssertionAndLogger(t, jwks, clientAssertion, logger) 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)) } logOutput := buf.String() for _, needle := range []string{ "headless password login succeeded", `"req_id":"req-headless-test-123"`, `"path":"/api/v1/auth/headless/password/login"`, `"client_id":"headless-login-client"`, `"login_challenge_prefix":"challenge-12"`, `"response_status":200`, } { if !strings.Contains(logOutput, needle) { t.Fatalf("expected success log to include %s, got=%s", needle, logOutput) } } } func TestHeadlessPasswordLogin_AcceptsConfiguredClientAssertionAlgorithms(t *testing.T) { algorithms := []jose.SignatureAlgorithm{ jose.RS256, jose.RS384, jose.RS512, jose.PS256, jose.PS384, jose.PS512, jose.ES256, jose.ES384, jose.ES512, jose.EdDSA, } for _, algorithm := range algorithms { t.Run(string(algorithm), func(t *testing.T) { privateKey, jwks := mustHeadlessJWKForAlgorithm(t, algorithm) clientAssertion := mustHeadlessClientAssertionWithAlgorithm( t, privateKey, algorithm, "headless-login-client", "http://example.com/api/v1/auth/headless/password/login", ) resp := runHeadlessPasswordLoginWithAssertion(t, jwks, clientAssertion) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) t.Fatalf("expected 200 for %s, got %d, body: %s", algorithm, 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: "headless-login-client", 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": "headless-login-client", "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": "headless-login-client", "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_SharedBrowserSameSubjectAllowed(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "new-session-id"}, Subject: "kratos-user-1", }, nil) kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1") t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, } 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") req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session") resp, err := app.Test(req) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.Equal(t, "valid-jwt", got["sessionJwt"]) mockIdp.AssertExpectations(t) mockKratos.AssertExpectations(t) } func TestPasswordLogin_SharedBrowserDifferentSubjectConflicts(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "new-session-id"}, Subject: "kratos-user-1", }, nil) kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, } 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") req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session") resp, err := app.Test(req) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusConflict, resp.StatusCode) var got map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.Equal(t, "session_subject_conflict", got["code"]) require.Empty(t, resp.Cookies()) mockIdp.AssertExpectations(t) mockKratos.AssertExpectations(t) } func TestPasswordLogin_ArchivedUserRejected(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "archived@example.com", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "archived-jwt"}, Subject: "archived-user-id", }, nil) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "archived@example.com").Return("archived-user-id", nil) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, Hydra: service.NewHydraAdminService(), UserRepo: &passwordLoginUserRepo{usersByID: map[string]domain.User{ "archived-user-id": { ID: "archived-user-id", Email: "archived@example.com", Name: "Archived User", Status: domain.UserStatusArchived, }, }}, } app := newAuthLoginTestApp(h) body, _ := json.Marshal(map[string]string{ "loginId": "archived@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.StatusForbidden { t.Fatalf("expected 403, got %d", resp.StatusCode) } } func TestEnsureUserActivityAllowedByStatus(t *testing.T) { tests := []struct { name string status string wantErr bool }{ {name: "active allowed", status: domain.UserStatusActive}, {name: "temporary leave allowed", status: domain.UserStatusTemporaryLeave}, {name: "baron guest allowed", status: domain.UserStatusBaronGuest}, {name: "suspended rejected", status: domain.UserStatusSuspended, wantErr: true}, {name: "preboarding rejected", status: domain.UserStatusPreboarding, wantErr: true}, {name: "extended leave rejected", status: domain.UserStatusExtendedLeave, wantErr: true}, {name: "archived rejected", status: domain.UserStatusArchived, wantErr: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { h := &AuthHandler{ UserRepo: &passwordLoginUserRepo{usersByID: map[string]domain.User{ "user-id": { ID: "user-id", Email: "user@example.com", Name: "User", Status: tc.status, }, }}, } err := h.ensureUserActivityAllowed(context.Background(), "user-id") if tc.wantErr { require.Error(t, err) return } require.NoError(t, err) }) } } 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"]) } } func (m *MockKratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { return "", nil }