forked from baron/baron-sso
1827 lines
56 KiB
Go
1827 lines
56 KiB
Go
/*
|
|
이 테스트 파일은 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"
|
|
"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"
|
|
)
|
|
|
|
// --- 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
|
|
}
|
|
|
|
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 {
|
|
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()
|
|
|
|
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 {
|
|
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()
|
|
|
|
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 _, ok := got["sessionJwt"]; ok {
|
|
t.Errorf("expected OIDC response to omit sessionJwt, got %v", got["sessionJwt"])
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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"])
|
|
}
|
|
}
|
|
|
|
func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(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: "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) {
|
|
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) {
|
|
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) {
|
|
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_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"])
|
|
}
|
|
}
|