package handlers import ( "bytes" "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "testing" "time" webauthnlib "github.com/go-webauthn/webauthn/webauthn" "github.com/gofiber/fiber/v2" "github.com/pquerna/otp/totp" "wucher/internal/config" "wucher/internal/domain/auth" "wucher/internal/domain/contact" "wucher/internal/service" sharedconst "wucher/internal/shared/const" "wucher/internal/shared/pkg/uuidv7" "wucher/internal/transport/http/dto" ) type memTokenStore struct { store map[string][]byte lastKey string } func newMemTokenStore() *memTokenStore { return &memTokenStore{store: map[string][]byte{}} } func (m *memTokenStore) Set(_ context.Context, key string, value []byte, _ time.Duration) error { m.store[key] = value m.lastKey = key return nil } func (m *memTokenStore) SetIfNotExists(_ context.Context, key string, value []byte, _ time.Duration) (bool, error) { if _, ok := m.store[key]; ok { return false, nil } m.store[key] = value m.lastKey = key return true, nil } func (m *memTokenStore) Get(_ context.Context, key string) ([]byte, error) { v, ok := m.store[key] if !ok { return nil, nil } return v, nil } func (m *memTokenStore) Delete(_ context.Context, key string) error { delete(m.store, key) return nil } type memRepo struct { users map[string]*auth.User usersByMail map[string]*auth.User usersByUsername map[string]*auth.User identities map[string]*auth.UserIdentity totp map[string]*auth.UserTOTP webauthnCredentials map[string][]auth.UserWebAuthnCredential resetTokens map[string]*auth.PasswordResetToken securityPINResetTokens map[string]*auth.SecurityPINResetToken emailLoginOTPs map[string]*auth.UserEmailLoginOTP rolesByName map[string]*auth.Role rolesByID map[string]*auth.Role permsByRole map[string][]auth.Permission permsErr error consumeResetErr error consumePINResetErr error deleteTOTPErr error deleteWebAuthnErr error } func newMemRepo() *memRepo { return &memRepo{ users: map[string]*auth.User{}, usersByMail: map[string]*auth.User{}, usersByUsername: map[string]*auth.User{}, identities: map[string]*auth.UserIdentity{}, totp: map[string]*auth.UserTOTP{}, webauthnCredentials: map[string][]auth.UserWebAuthnCredential{}, resetTokens: map[string]*auth.PasswordResetToken{}, securityPINResetTokens: map[string]*auth.SecurityPINResetToken{}, emailLoginOTPs: map[string]*auth.UserEmailLoginOTP{}, rolesByName: map[string]*auth.Role{}, rolesByID: map[string]*auth.Role{}, permsByRole: map[string][]auth.Permission{}, } } func (r *memRepo) CreateUser(_ context.Context, user *auth.User) error { if user != nil && !user.IsActive { user.IsActive = true } r.users[string(user.ID)] = user r.usersByMail[user.Email] = user if user.Username != nil { r.usersByUsername[*user.Username] = user } return nil } func (r *memRepo) GetUserByID(_ context.Context, id []byte) (*auth.User, error) { return r.users[string(id)], nil } func (r *memRepo) GetUserByEmail(_ context.Context, email string) (*auth.User, error) { return r.usersByMail[email], nil } func (r *memRepo) GetUserBySSOEmail(_ context.Context, ssoEmail string) (*auth.User, error) { return r.usersByMail[ssoEmail], nil } func (r *memRepo) GetUserByUsername(_ context.Context, username string) (*auth.User, error) { return r.usersByUsername[username], nil } func (r *memRepo) UpdateUserTimezone(_ context.Context, id []byte, timezoneName string) error { u := r.users[string(id)] if u == nil { return nil } u.Timezone = timezoneName return nil } func (r *memRepo) SetUserPassword(_ context.Context, id []byte, passwordHash, salt []byte) error { u := r.users[string(id)] if u == nil { return nil } u.PasswordHash = passwordHash u.SaltValue = salt return nil } func (r *memRepo) SetUserSecurityPIN(_ context.Context, id []byte, pinHash []byte, setAt time.Time) error { u := r.users[string(id)] if u == nil { return nil } u.SecurityPINHash = append([]byte(nil), pinHash...) ts := setAt u.SecurityPINSetAt = &ts return nil } func (r *memRepo) MarkEmailVerified(_ context.Context, id []byte) error { u := r.users[string(id)] now := time.Now() u.EmailVerifiedAt = &now return nil } func (r *memRepo) CreatePasswordResetToken(_ context.Context, token *auth.PasswordResetToken) error { if token == nil { return nil } cp := *token if len(cp.ID) == 0 { cp.ID = uuidv7.MustBytes() } r.resetTokens[string(cp.TokenHash)] = &cp return nil } func (r *memRepo) InvalidateActivePasswordResetTokensByUserID(_ context.Context, userID []byte, usedAt time.Time) error { for _, rec := range r.resetTokens { if string(rec.UserID) == string(userID) && rec.UsedAt == nil && rec.ExpiresAt.After(usedAt) { ts := usedAt rec.UsedAt = &ts } } return nil } func (r *memRepo) ConsumePasswordResetTokenAndSetPassword(_ context.Context, tokenHash, passwordHash, salt []byte, usedAt time.Time) ([]byte, bool, error) { if r.consumeResetErr != nil { return nil, false, r.consumeResetErr } rec := r.resetTokens[string(tokenHash)] if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(usedAt) { return nil, false, nil } u := r.users[string(rec.UserID)] if u == nil { return nil, false, nil } u.PasswordHash = passwordHash u.SaltValue = salt ts := usedAt rec.UsedAt = &ts for _, other := range r.resetTokens { if string(other.UserID) == string(rec.UserID) && string(other.ID) != string(rec.ID) && other.UsedAt == nil && other.ExpiresAt.After(usedAt) { otherTS := usedAt other.UsedAt = &otherTS } } return rec.UserID, true, nil } func (r *memRepo) CreateSecurityPINResetToken(_ context.Context, token *auth.SecurityPINResetToken) error { if token == nil { return nil } cp := *token if len(cp.ID) == 0 { cp.ID = uuidv7.MustBytes() } r.securityPINResetTokens[string(cp.TokenHash)] = &cp return nil } func (r *memRepo) InvalidateActiveSecurityPINResetTokensByUserID(_ context.Context, userID []byte, usedAt time.Time) error { for _, rec := range r.securityPINResetTokens { if string(rec.UserID) == string(userID) && rec.UsedAt == nil && rec.ExpiresAt.After(usedAt) { ts := usedAt rec.UsedAt = &ts } } return nil } func (r *memRepo) GetActiveSecurityPINResetTokenUser(_ context.Context, tokenHash []byte, now time.Time) (*auth.User, error) { rec := r.securityPINResetTokens[string(tokenHash)] if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(now) { return nil, nil } return r.users[string(rec.UserID)], nil } func (r *memRepo) ConsumeSecurityPINResetTokenAndSetPIN(_ context.Context, tokenHash, pinHash []byte, usedAt time.Time) ([]byte, bool, error) { if r.consumePINResetErr != nil { return nil, false, r.consumePINResetErr } rec := r.securityPINResetTokens[string(tokenHash)] if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(usedAt) { return nil, false, nil } u := r.users[string(rec.UserID)] if u == nil { return nil, false, nil } u.SecurityPINHash = append([]byte(nil), pinHash...) ts := usedAt u.SecurityPINSetAt = &ts rec.UsedAt = &ts return rec.UserID, true, nil } func (r *memRepo) UpsertIdentity(_ context.Context, identity *auth.UserIdentity) error { r.identities[identity.Provider+"|"+identity.ProviderSubject] = identity return nil } func (r *memRepo) CreateIdentity(_ context.Context, identity *auth.UserIdentity) error { r.identities[identity.Provider+"|"+identity.ProviderSubject] = identity return nil } func (r *memRepo) GetIdentityByProviderSubject(_ context.Context, provider, subject string) (*auth.UserIdentity, error) { return r.identities[provider+"|"+subject], nil } func (r *memRepo) GetIdentityByUserID(_ context.Context, userID []byte) (*auth.UserIdentity, error) { for _, identity := range r.identities { if bytes.Equal(identity.UserID, userID) { return identity, nil } } return nil, nil } func (r *memRepo) DeleteIdentityByUserIDProvider(_ context.Context, userID []byte, provider string) (bool, error) { for key, identity := range r.identities { if bytes.Equal(identity.UserID, userID) && identity.Provider == provider { delete(r.identities, key) return true, nil } } return false, nil } func (r *memRepo) GetUserTOTP(_ context.Context, userID []byte) (*auth.UserTOTP, error) { return r.totp[string(userID)], nil } func (r *memRepo) UpsertUserTOTP(_ context.Context, rec *auth.UserTOTP) error { r.totp[string(rec.UserID)] = rec return nil } func (r *memRepo) DisableUserTOTP(_ context.Context, userID []byte) error { if r.totp[string(userID)] != nil { r.totp[string(userID)].Enabled = false } return nil } func (r *memRepo) DeleteUserTOTPSetup(_ context.Context, userID []byte) error { if r.deleteTOTPErr != nil { return r.deleteTOTPErr } delete(r.totp, string(userID)) return nil } func (r *memRepo) UpdateUserTOTPLastUsed(_ context.Context, userID []byte) error { return nil } func (r *memRepo) GetActiveUserEmailLoginOTP(_ context.Context, userID []byte, now time.Time) (*auth.UserEmailLoginOTP, error) { rec := r.emailLoginOTPs[string(userID)] if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(now) { return nil, nil } return rec, nil } func (r *memRepo) CountUserEmailLoginOTPSince(_ context.Context, userID []byte, since time.Time) (int64, error) { rec := r.emailLoginOTPs[string(userID)] if rec == nil || rec.CreatedAt.Before(since) { return 0, nil } return 1, nil } func (r *memRepo) CreateUserEmailLoginOTP(_ context.Context, otp *auth.UserEmailLoginOTP) error { if otp == nil { return nil } cp := *otp if cp.CreatedAt.IsZero() { cp.CreatedAt = time.Now().UTC() } r.emailLoginOTPs[string(cp.UserID)] = &cp return nil } func (r *memRepo) InvalidateActiveUserEmailLoginOTPs(_ context.Context, userID []byte, usedAt time.Time) error { rec := r.emailLoginOTPs[string(userID)] if rec != nil && rec.UsedAt == nil && rec.ExpiresAt.After(usedAt) { ts := usedAt rec.UsedAt = &ts } return nil } func (r *memRepo) ConsumeUserEmailLoginOTP(_ context.Context, userID, otpHash []byte, now time.Time) (bool, int, error) { rec := r.emailLoginOTPs[string(userID)] if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(now) { return false, 0, nil } if bytes.Equal(rec.OTPHash, otpHash) { ts := now rec.UsedAt = &ts return true, max(0, rec.MaxAttempts-rec.AttemptCount), nil } rec.AttemptCount++ left := max(0, rec.MaxAttempts-rec.AttemptCount) if rec.AttemptCount >= rec.MaxAttempts { ts := now rec.UsedAt = &ts } return false, left, nil } func (r *memRepo) ListUserWebAuthnCredentials(_ context.Context, userID []byte) ([]auth.UserWebAuthnCredential, error) { list := r.webauthnCredentials[string(userID)] out := make([]auth.UserWebAuthnCredential, len(list)) copy(out, list) return out, nil } func (r *memRepo) CreateUserWebAuthnCredential(_ context.Context, credential *auth.UserWebAuthnCredential) error { if credential == nil { return nil } cp := *credential if len(cp.ID) == 0 { cp.ID = uuidv7.MustBytes() } key := string(cp.UserID) r.webauthnCredentials[key] = append(r.webauthnCredentials[key], cp) return nil } func (r *memRepo) UpdateUserWebAuthnCredential(_ context.Context, userID, credentialID, credentialJSON []byte, usedAt time.Time) error { key := string(userID) list := r.webauthnCredentials[key] for i := range list { if bytes.Equal(list[i].CredentialID, credentialID) { list[i].CredentialJSON = append([]byte(nil), credentialJSON...) ts := usedAt list[i].LastUsedAt = &ts r.webauthnCredentials[key] = list return nil } } return nil } func (r *memRepo) DeleteAllUserWebAuthnCredentials(_ context.Context, userID []byte) error { if r.deleteWebAuthnErr != nil { return r.deleteWebAuthnErr } delete(r.webauthnCredentials, string(userID)) return nil } func (r *memRepo) CreateRole(_ context.Context, role *auth.Role) error { return nil } func (r *memRepo) UpdateRole(_ context.Context, role *auth.Role) error { return nil } func (r *memRepo) DeleteRole(_ context.Context, id []byte) error { return nil } func (r *memRepo) GetRoleByID(_ context.Context, id []byte) (*auth.Role, error) { return r.rolesByID[string(id)], nil } func (r *memRepo) GetRoleByName(_ context.Context, name string) (*auth.Role, error) { return r.rolesByName[name], nil } func (r *memRepo) ListRoles(_ context.Context, _ string, _ string, _ int, _ int) ([]auth.Role, int64, error) { return nil, 0, nil } func (r *memRepo) ListPermissions(_ context.Context, _ string, _ string, _ int, _ int) ([]auth.Permission, int64, error) { return nil, 0, nil } func (r *memRepo) ListPermissionsByRoleID(_ context.Context, roleID []byte) ([]auth.Permission, error) { return r.permsByRole[string(roleID)], r.permsErr } func (r *memRepo) HasRolePermission(_ context.Context, _ []byte, _ string) (bool, error) { return true, nil } func (r *memRepo) GetPermissionByID(_ context.Context, _ []byte) (*auth.Permission, error) { return nil, nil } func (r *memRepo) GetPermissionByKey(_ context.Context, _ string) (*auth.Permission, error) { return nil, nil } func (r *memRepo) UpdatePermission(_ context.Context, _ *auth.Permission) error { return nil } func (r *memRepo) AssignPermission(_ context.Context, roleID, permissionID []byte) (*auth.RolePermission, error) { return &auth.RolePermission{RoleID: roleID, PermissionID: permissionID}, nil } func (r *memRepo) RemovePermission(_ context.Context, _ []byte, _ []byte) error { return nil } const ( testWebAuthnCreationResponseJSON = `{ "id":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g", "rawId":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g", "type":"public-key", "authenticatorAttachment":"platform", "clientExtensionResults":{"appid":true}, "response":{ "attestationObject":"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw", "clientDataJSON":"eyJjaGFsbGVuZ2UiOiJXOEd6RlU4cEdqaG9SYldyTERsYW1BZnFfeTRTMUNaRzFWdW9lUkxBUnJFIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ", "transports":["usb","nfc","fake"] } }` testWebAuthnAssertionResponseTemplate = `{ "id":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng", "rawId":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng", "clientExtensionResults":{"appID":"example.com"}, "type":"public-key", "response":{ "authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ", "clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9", "signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc", "userHandle":"%s" } }` testWebAuthnRegistrationChallenge = "W8GzFU8pGjhoRbWrLDlamAfq_y4S1CZG1VuoeRLARrE" testWebAuthnLoginChallenge = "E4PTcIH_HfX1pC6Sigk1SC9NAlgeztN0439vi8z_c9k" ) type testWebAuthnChallengeEnvelope struct { Ceremony string `json:"ceremony"` UserID []byte `json:"user_id"` Session webauthnlib.SessionData `json:"session"` } func newWebAuthnAuthService(repo *memRepo, store *memTokenStore, withJWT bool) *service.AuthService { cfg := config.AuthConfig{ WebAuthnRPID: "webauthn.io", WebAuthnRPDisplayName: "Wucher Test", WebAuthnRPOrigins: []string{"https://webauthn.io"}, WebAuthnChallengeTTL: 2 * time.Minute, } if withJWT { cfg.JWTAccessSecret = "access" cfg.JWTRefreshSecret = "refresh" cfg.JWTAccessTTL = time.Minute cfg.JWTRefreshTTL = time.Hour } return service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) } func seedWebAuthnUser(repo *memRepo, email string, verified bool) []byte { userID := uuidv7.MustBytes() row := &auth.User{ ID: userID, Email: email, RoleID: uuidv7.MustBytes(), IsActive: true, } if verified { now := time.Now().UTC() row.EmailVerifiedAt = &now } repo.users[string(userID)] = row repo.usersByMail[email] = row return userID } func putWebAuthnChallenge(t *testing.T, store *memTokenStore, token, ceremony string, userID []byte, session webauthnlib.SessionData) { t.Helper() envelope := testWebAuthnChallengeEnvelope{ Ceremony: ceremony, UserID: append([]byte(nil), userID...), Session: session, } raw, err := json.Marshal(envelope) if err != nil { t.Fatalf("marshal challenge envelope: %v", err) } store.store["auth:webauthn:challenge:"+token] = raw } func seedWebAuthnLoginCredential(t *testing.T, repo *memRepo, userID []byte) { t.Helper() credentialID, err := base64.RawURLEncoding.DecodeString("AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng") if err != nil { t.Fatalf("decode credential id: %v", err) } publicKey, err := base64.RawURLEncoding.DecodeString("pQMmIAEhWCAoCF-x0dwEhzQo-ABxHIAgr_5WL6cJceREc81oIwFn7iJYIHEHx8ZhBIE42L26-rSC_3l0ZaWEmsHAKyP9rgslApUdAQI") if err != nil { t.Fatalf("decode public key: %v", err) } payload, err := json.Marshal(webauthnlib.Credential{ ID: credentialID, PublicKey: publicKey, AttestationType: "none", Authenticator: webauthnlib.Authenticator{ SignCount: 1553097240, }, }) if err != nil { t.Fatalf("marshal credential: %v", err) } repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{ UserID: userID, CredentialID: credentialID, CredentialJSON: payload, }} } func seedWebAuthnRegistrationExistingCredential(t *testing.T, repo *memRepo, userID []byte) { t.Helper() credentialID, err := base64.RawURLEncoding.DecodeString("6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g") if err != nil { t.Fatalf("decode credential id: %v", err) } payload, err := json.Marshal(webauthnlib.Credential{ID: credentialID}) if err != nil { t.Fatalf("marshal credential: %v", err) } repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{ UserID: userID, CredentialID: credentialID, CredentialJSON: payload, }} } func webAuthnAssertionPayloadForUser(userID []byte) []byte { handle := base64.RawURLEncoding.EncodeToString(userID) return []byte(fmt.Sprintf(testWebAuthnAssertionResponseTemplate, handle)) } func TestAuthHandler_Login_TOTPChallenge(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, TOTPChallengeTTL: time.Minute, TOTPIssuer: "Wucher", DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg}) user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) if err != nil { t.Fatalf("register failed: %v", err) } if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil { t.Fatalf("mark email verified: %v", err) } // enable TOTP secret, _, _, _ := svc.SetupTOTP(context.Background(), user.ID, user.Email) code, _ := totp.GenerateCode(secret, time.Now().UTC()) _, _ = svc.ConfirmTOTP(context.Background(), user.ID, code) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/login", h.Login) payload := []byte(`{"data":{"type":"auth_login","attributes":{"email":"user@example.com","password":"Password123!"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusAccepted { t.Fatalf("expected 202, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"totp_enabled":true`)) { t.Fatalf("expected totp_enabled true in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"webauthn_configured":false`)) { t.Fatalf("expected webauthn_configured false in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"linked_sso":false`)) { t.Fatalf("expected linked_sso false in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"pin_configured":false`)) { t.Fatalf("expected pin_configured false in response, got %s", string(body)) } } func TestAuthHandler_Register_Disabled(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() cfg := config.AuthConfig{ DisableRegister: true, DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/register", h.Register) payload := []byte(`{\"data\":{\"type\":\"auth_register\",\"attributes\":{\"email\":\"user@example.com\",\"username\":\"john.doe\",\"password\":\"Password123!\",\"first_name\":\"John\",\"last_name\":\"Doe\"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusForbidden { t.Fatalf("expected 403, got %d", resp.StatusCode) } } func TestPermissionAttrs_WithPermissions(t *testing.T) { repo := newMemRepo() userID := uuidv7.MustBytes() roleID := uuidv7.MustBytes() repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID, RoleIDs: [][]byte{roleID}} repo.permsByRole[string(roleID)] = []auth.Permission{ {ID: uuidv7.MustBytes(), Name: "Read User", Key: "user.read", Description: "Read users"}, } svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) app := fiber.New() var got []map[string]any app.Get("/t", func(c *fiber.Ctx) error { got = permissionAttrs(svc, c, userID) return c.SendStatus(http.StatusOK) }) req, _ := http.NewRequest(http.MethodGet, "/t", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if len(got) != 1 || got[0]["key"] != "user.read" { t.Fatalf("unexpected permissions: %#v", got) } } func TestPermissionModuleGroups_WithPermissions(t *testing.T) { repo := newMemRepo() userID := uuidv7.MustBytes() roleID := uuidv7.MustBytes() repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID, RoleIDs: [][]byte{roleID}} repo.permsByRole[string(roleID)] = []auth.Permission{ {ID: uuidv7.MustBytes(), Name: "Read User", Key: "user.read", Description: "Read users"}, {ID: uuidv7.MustBytes(), Name: "Read Role", Key: "role.read", Description: "Read roles"}, } svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) app := fiber.New() var got []dto.PermissionGroupResource app.Get("/t", func(c *fiber.Ctx) error { got = permissionModuleGroups(svc, c, userID) return c.SendStatus(http.StatusOK) }) req, _ := http.NewRequest(http.MethodGet, "/t", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if len(got) != 2 || got[0].Module != "user" || got[1].Module != "role" { t.Fatalf("unexpected grouped permissions: %#v", got) } if len(got[0].Permissions) != 1 || got[0].Permissions[0].Attributes.Key != "user.read" { t.Fatalf("unexpected user module permissions: %#v", got[0]) } } func TestPermissionAttrs_OnError(t *testing.T) { repo := newMemRepo() repo.permsErr = errors.New("db error") userID := uuidv7.MustBytes() roleID := uuidv7.MustBytes() repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID, RoleIDs: [][]byte{roleID}} svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) app := fiber.New() var got []map[string]any app.Get("/t", func(c *fiber.Ctx) error { got = permissionAttrs(svc, c, userID) return c.SendStatus(http.StatusOK) }) req, _ := http.NewRequest(http.MethodGet, "/t", nil) _, _ = app.Test(req) if len(got) != 0 { t.Fatalf("expected empty permissions on error, got %#v", got) } } func TestAuthHandler_Register_Success(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/register", h.Register) payload := []byte(`{"data":{"type":"auth_register","attributes":{"email":"user@example.com","username":"john.doe","password":"Password123!","first_name":"John","last_name":"Doe"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"username":"john.doe"`)) { t.Fatalf("expected response to include username, got %s", string(body)) } if !bytes.Contains(body, []byte(`"role_id":"`)) { t.Fatalf("expected response to include role_id, got %s", string(body)) } if len(resp.Header.Values("Set-Cookie")) != 0 { t.Fatalf("expected register to not issue auth cookies") } } func TestAuthHandler_ForgotPassword_SameResponseExistingAndMissingEmail(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, PasswordResetURL: "http://localhost:3000/reset-password", PasswordResetTTL: 20 * time.Minute, ForgotPasswordEmailCooldown: 0, ForgotPasswordMinDuration: 0, ForgotPasswordIPRateMax: 10, ForgotPasswordIPRateWindow: time.Minute, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) if err := repo.CreateUser(context.Background(), &auth.User{ ID: uuidv7.MustBytes(), Email: "exists@example.com", FirstName: "John", LastName: "Doe", RoleID: uuidv7.MustBytes(), }); err != nil { t.Fatalf("create user: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/forgot-password", h.ForgotPassword) existingPayload := []byte(`{"data":{"type":"auth_forgot_password","attributes":{"email":"exists@example.com"}}}`) missingPayload := []byte(`{"data":{"type":"auth_forgot_password","attributes":{"email":"missing@example.com"}}}`) req1, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/forgot-password", bytes.NewReader(existingPayload)) req1.Header.Set("Content-Type", "application/json") resp1, err := app.Test(req1) if err != nil { t.Fatalf("existing app.Test: %v", err) } body1, _ := io.ReadAll(resp1.Body) req2, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/forgot-password", bytes.NewReader(missingPayload)) req2.Header.Set("Content-Type", "application/json") resp2, err := app.Test(req2) if err != nil { t.Fatalf("missing app.Test: %v", err) } body2, _ := io.ReadAll(resp2.Body) if resp1.StatusCode != http.StatusOK || resp2.StatusCode != http.StatusOK { t.Fatalf("expected both 200, got %d and %d", resp1.StatusCode, resp2.StatusCode) } if string(body1) != string(body2) { t.Fatalf("forgot-password responses should match, got existing=%s missing=%s", string(body1), string(body2)) } } func TestAuthHandler_ForgotPassword_InvalidJSON(t *testing.T) { repo := newMemRepo() svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/forgot-password", h.ForgotPassword) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/forgot-password", bytes.NewReader([]byte(`{"data":`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_ForgotPassword_ValidationError(t *testing.T) { repo := newMemRepo() svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/forgot-password", h.ForgotPassword) payload := []byte(`{"data":{"type":"auth_forgot_password","attributes":{"email":"invalid-email"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/forgot-password", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } } func TestAuthHandler_ResetPassword_InvalidTokenGenericMessage(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, PasswordResetTTL: 20 * time.Minute, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/reset-password", h.ResetPassword) payload := []byte(`{"data":{"type":"auth_reset_password","attributes":{"token":"missing-token","new_password":"Password123!Long","confirm_password":"Password123!Long"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte("This reset link is invalid or expired.")) { t.Fatalf("expected generic invalid token message, got %s", string(body)) } } func TestAuthHandler_ResetPassword_InvalidJSON(t *testing.T) { repo := newMemRepo() cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, } svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/reset-password", h.ResetPassword) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader([]byte(`{"data":`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_ResetPassword_ValidationError(t *testing.T) { repo := newMemRepo() cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, } svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/reset-password", h.ResetPassword) payload := []byte(`{"data":{"type":"auth_reset_password","attributes":{"token":"","new_password":"short","confirm_password":"short"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } } func TestAuthHandler_ResetPassword_ConfirmMismatch(t *testing.T) { repo := newMemRepo() cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, } svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/reset-password", h.ResetPassword) payload := []byte(`{"data":{"type":"auth_reset_password","attributes":{"token":"token-x","new_password":"Password123!Long","confirm_password":"Different123!Long"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte("confirm_password must match new_password")) { t.Fatalf("expected confirm mismatch message, got %s", string(body)) } } func TestAuthHandler_ResetPassword_InternalError(t *testing.T) { repo := newMemRepo() repo.consumeResetErr = errors.New("db unavailable") cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, } svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/reset-password", h.ResetPassword) payload := []byte(`{"data":{"type":"auth_reset_password","attributes":{"token":"token-x","new_password":"Password123!Long","confirm_password":"Password123!Long"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", resp.StatusCode) } } func TestAuthHandler_ResetPassword_Success(t *testing.T) { repo := newMemRepo() cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, } svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) user := &auth.User{ ID: uuidv7.MustBytes(), Email: "user@example.com", } if err := repo.CreateUser(context.Background(), user); err != nil { t.Fatalf("create user: %v", err) } rawToken := "valid-reset-token" tokenHash := sha256.Sum256([]byte(rawToken)) repo.resetTokens[string(tokenHash[:])] = &auth.PasswordResetToken{ ID: uuidv7.MustBytes(), UserID: user.ID, TokenHash: tokenHash[:], ExpiresAt: time.Now().UTC().Add(20 * time.Minute), } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/reset-password", h.ResetPassword) payload := []byte(`{"data":{"type":"auth_reset_password","attributes":{"token":"valid-reset-token","new_password":"Password123!Long","confirm_password":"Password123!Long"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if len(user.PasswordHash) == 0 || len(user.SaltValue) == 0 { t.Fatalf("expected password hash and salt to be updated") } } func TestAuthHandler_VerifyEmail(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, EmailVerifyTTL: time.Hour, EmailVerifyURL: "http://localhost/verify", DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) // token stored in mock key := store.lastKey[len("auth:email_verify:"):] h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/verify-email", h.VerifyEmail) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token="+key, nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if user.EmailVerifiedAt == nil { t.Fatalf("expected email verified") } } func TestAuthHandler_Login_Success(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil { t.Fatalf("mark email verified: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/login", h.Login) payload := []byte(`{"data":{"type":"auth_login","attributes":{"email":"user@example.com","password":"Password123!"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusAccepted { t.Fatalf("expected 202, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"requires_email_otp":true`)) { t.Fatalf("expected requires_email_otp true in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"requires_totp_setup":true`)) { t.Fatalf("expected requires_totp_setup true in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"resend_cooldown_seconds"`)) { t.Fatalf("expected resend_cooldown_seconds in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"max_resend_per_hour"`)) { t.Fatalf("expected max_resend_per_hour in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"challenge_token"`)) { t.Fatalf("expected challenge_token in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"webauthn_configured":false`)) { t.Fatalf("expected webauthn_configured false in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"linked_sso":false`)) { t.Fatalf("expected linked_sso false in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"pin_configured":false`)) { t.Fatalf("expected pin_configured false in response, got %s", string(body)) } _ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{ ID: uuidv7.MustBytes(), UserID: user.ID, Provider: "microsoft", ProviderSubject: "sub", Email: user.Email, }) resp2, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp2.StatusCode != http.StatusAccepted { t.Fatalf("expected 202, got %d", resp2.StatusCode) } body2, _ := io.ReadAll(resp2.Body) if !bytes.Contains(body2, []byte(`"linked_sso":true`)) { t.Fatalf("expected linked_sso true in response, got %s", string(body2)) } } func TestAuthHandler_Refresh_And_Logout(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil { t.Fatalf("mark email verified: %v", err) } pair, _ := svc.IssueTokens(context.Background(), user) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/refresh", h.Refresh) app.Post("/api/v1/auth/logout", h.Logout) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/refresh", nil) req.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pair.RefreshToken}) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } req2, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) req2.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pair.RefreshToken}) resp2, err := app.Test(req2) if err != nil { t.Fatalf("app.Test: %v", err) } if resp2.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp2.StatusCode) } if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, service.ErrInvalidToken) { t.Fatalf("expected refresh token invalid after logout, got %v", err) } if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, service.ErrInvalidToken) { t.Fatalf("expected access token invalid after logout, got %v", err) } sessions, err := svc.ListUserSessions(context.Background(), user.ID, "") if err != nil { t.Fatalf("list sessions after logout: %v", err) } if len(sessions) != 0 { t.Fatalf("expected session removed after logout, got %#v", sessions) } } func TestAuthHandler_MicrosoftFrontChannelLogout(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil { t.Fatalf("mark email verified: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/front-channel-logout", h.MicrosoftFrontChannelLogout) // A request carrying a foreign issuer must NOT revoke the session. pairForeign, _ := svc.IssueTokens(context.Background(), user) reqForeign, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/front-channel-logout?iss=https://evil.example.com", nil) reqForeign.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: pairForeign.AccessToken}) reqForeign.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pairForeign.RefreshToken}) respForeign, err := app.Test(reqForeign) if err != nil { t.Fatalf("app.Test (foreign iss): %v", err) } if respForeign.StatusCode != http.StatusOK { t.Fatalf("expected 200 for foreign iss, got %d", respForeign.StatusCode) } if _, err := svc.RefreshTokens(context.Background(), pairForeign.RefreshToken); err != nil { t.Fatalf("expected session to survive foreign issuer logout, got %v", err) } // A legitimate front-channel logout revokes the caller's session and tokens. pair, _ := svc.IssueTokens(context.Background(), user) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/front-channel-logout?iss=https://login.microsoftonline.com/tenant/v2.0&sid=abc", nil) req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: pair.AccessToken}) req.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pair.RefreshToken}) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "no-store") { t.Fatalf("expected no-store cache header, got %q", cc) } if got := resp.Header.Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors") { t.Fatalf("expected frame-ancestors CSP, got %q", got) } if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, service.ErrInvalidToken) { t.Fatalf("expected refresh token invalid after front-channel logout, got %v", err) } if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, service.ErrInvalidToken) { t.Fatalf("expected access token invalid after front-channel logout, got %v", err) } sessions, err := svc.ListUserSessions(context.Background(), user.ID, "") if err != nil { t.Fatalf("list sessions after logout: %v", err) } if len(sessions) != 0 { t.Fatalf("expected sessions revoked after front-channel logout, got %#v", sessions) } } func TestAuthHandler_MicrosoftFrontChannelLogout_UsesSidWithoutCookies(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil { t.Fatalf("mark email verified: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/front-channel-logout", h.MicrosoftFrontChannelLogout) const microsoftSID = "ms-session-abc" pair, err := svc.IssueTokensWithClientForProviderAndMicrosoftSID(context.Background(), user, "", "", "microsoft", microsoftSID) if err != nil { t.Fatalf("issue microsoft tokens: %v", err) } req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/front-channel-logout?iss=https://login.microsoftonline.com/tenant/v2.0&sid="+microsoftSID, nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, service.ErrInvalidToken) { t.Fatalf("expected refresh token invalid after sid logout, got %v", err) } if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, service.ErrInvalidToken) { t.Fatalf("expected access token invalid after sid logout, got %v", err) } } func TestAuthHandler_SetAuthCookiesForMicrosoftUsesNoneSecure(t *testing.T) { svc := service.NewAuthService(newMemRepo(), newMemRepo(), newMemTokenStore(), nil, service.AuthServiceDependencies{ SSO: nil, Protector: nil, Config: config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, JWTCookieSecure: true, JWTCookieSameSite: "Lax", }, }) app := fiber.New() app.Get("/cookie", func(c *fiber.Ctx) error { setAuthCookiesForProvider(c, svc, service.TokenPair{ AccessToken: "a", RefreshToken: "r", RefreshTTL: time.Hour, }, "microsoft") return c.SendStatus(http.StatusOK) }) req, _ := http.NewRequest(http.MethodGet, "/cookie", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } cookies := resp.Header.Values("Set-Cookie") if len(cookies) == 0 { t.Fatalf("expected Set-Cookie headers") } for _, raw := range cookies { if !strings.Contains(raw, "SameSite=None") { t.Fatalf("expected SameSite=None for microsoft cookie, got %q", raw) } if !strings.Contains(strings.ToLower(raw), "secure") { t.Fatalf("expected Secure for microsoft cookie, got %q", raw) } } } func TestAuthHandler_SetAuthCookiesForMicrosoftFallsBackWhenInsecure(t *testing.T) { svc := service.NewAuthService(newMemRepo(), newMemRepo(), newMemTokenStore(), nil, service.AuthServiceDependencies{ SSO: nil, Protector: nil, Config: config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, JWTCookieSecure: false, JWTCookieSameSite: "Lax", }, }) app := fiber.New() app.Get("/cookie", func(c *fiber.Ctx) error { setAuthCookiesForProvider(c, svc, service.TokenPair{ AccessToken: "a", RefreshToken: "r", RefreshTTL: time.Hour, }, "microsoft") return c.SendStatus(http.StatusOK) }) req, _ := http.NewRequest(http.MethodGet, "/cookie", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } cookies := resp.Header.Values("Set-Cookie") if len(cookies) == 0 { t.Fatalf("expected Set-Cookie headers") } for _, raw := range cookies { if strings.Contains(raw, "SameSite=None") { t.Fatalf("expected fallback same-site for insecure microsoft cookie, got %q", raw) } if strings.Contains(strings.ToLower(raw), "secure") { t.Fatalf("expected insecure microsoft cookie fallback, got %q", raw) } } } func TestAuthHandler_Logout_DoesNotRedirectToSSO(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, DefaultRoleName: "admin", SSOSuccessRedirectURL: "http://localhost/logout-done", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{ SSO: &serviceMockSSO{}, Protector: nil, Config: cfg, }) user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) _ = repo.MarkEmailVerified(context.Background(), user.ID) pair, _ := svc.IssueTokens(context.Background(), user) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/logout", h.Logout) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) req.Header.Set("Accept", "text/html") req.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pair.RefreshToken}) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } } func TestAuthHandler_Logout_DoesNotReturnSSOLogoutURL(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, DefaultRoleName: "admin", SSOSuccessRedirectURL: "http://localhost/logout-done", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{ SSO: &serviceMockSSO{}, Protector: nil, Config: cfg, }) user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) _ = repo.MarkEmailVerified(context.Background(), user.ID) pair, _ := svc.IssueTokens(context.Background(), user) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/logout", h.Logout) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) req.Header.Set("X-Requested-With", "XMLHttpRequest") req.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pair.RefreshToken}) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if bytes.Contains(body, []byte(`sso_logout_url`)) { t.Fatalf("did not expect sso logout url in body, got %s", string(body)) } } func TestAuthHandler_Me(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByID[string(roleID)] = &auth.Role{ID: roleID, Name: "admin"} cfg := config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) ssoEmail := "u.sso@e.com" user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com", SSOEmail: &ssoEmail, RoleID: roleID} if err := repo.CreateUser(context.Background(), user); err != nil { t.Fatalf("create user: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/me", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.Me(c) }) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"totp_enabled":false`)) { t.Fatalf("expected totp_enabled false in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"webauthn_configured":false`)) { t.Fatalf("expected webauthn_configured false in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"pin_configured":false`)) { t.Fatalf("expected pin_configured false in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"pin_blocked":false`)) { t.Fatalf("expected pin_blocked false in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"email_sso":"u.sso@e.com"`)) { t.Fatalf("expected email_sso in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"microsoft_scope":""`)) { t.Fatalf("expected microsoft_scope in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"dul_bases":[]`)) { t.Fatalf("expected dul_bases empty array in response, got %s", string(body)) } } func TestAuthHandler_Me_PilotDULBases(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByID[string(roleID)] = &auth.Role{ID: roleID, Name: "pilot"} cfg := config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) user := &auth.User{ID: uuidv7.MustBytes(), Email: "pilot@e.com", RoleID: roleID} if err := repo.CreateUser(context.Background(), user); err != nil { t.Fatalf("create user: %v", err) } baseID := uuidv7.MustBytes() raw := strings.ToLower(hex.EncodeToString(baseID)) + "|" + strings.ToLower(hex.EncodeToString([]byte("Halim Base"))) + "|dul|" + strings.ToLower(hex.EncodeToString([]byte("hems"))) contactSvc := &stubContactService{ getItem: &contact.ContactListItem{ UserID: user.ID, RoleCode: "pilot", BaseRolesRaw: &raw, }, } h := NewAuthHandler(svc).WithContactService(contactSvc) app := fiber.New() app.Get("/api/v1/auth/me", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.Me(c) }) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"dul_bases":[`)) || !bytes.Contains(body, []byte(`"name":"Halim Base"`)) || !bytes.Contains(body, []byte(`"category":"hems"`)) { t.Fatalf("expected pilot dul_bases payload, got %s", string(body)) } } func TestAuthHandler_Me_WithSessionAuthContext(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByID[string(roleID)] = &auth.Role{ID: roleID, Name: "admin"} cfg := config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com", RoleID: roleID, IsActive: true} if err := repo.CreateUser(context.Background(), user); err != nil { t.Fatalf("create user: %v", err) } t.Run("email session", func(t *testing.T) { pair, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0", "127.0.0.1") if err != nil { t.Fatalf("issue tokens: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/me", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.Me(c) }) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: pair.AccessToken}) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"login_method":"email"`)) { t.Fatalf("expected login_method email in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"auth_provider":"email"`)) { t.Fatalf("expected auth_provider email in response, got %s", string(body)) } }) t.Run("microsoft session", func(t *testing.T) { pair, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", "microsoft") if err != nil { t.Fatalf("issue provider tokens: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/me", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.Me(c) }) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: pair.AccessToken}) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"login_method":"sso"`)) { t.Fatalf("expected login_method sso in response, got %s", string(body)) } if !bytes.Contains(body, []byte(`"auth_provider":"microsoft"`)) { t.Fatalf("expected auth_provider microsoft in response, got %s", string(body)) } }) } func TestAuthHandler_UpdateTimezone(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByID[string(roleID)] = &auth.Role{ID: roleID, Name: "admin"} cfg := config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com", RoleID: roleID, Timezone: "UTC"} if err := repo.CreateUser(context.Background(), user); err != nil { t.Fatalf("create user: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Patch("/api/v1/auth/me/timezone", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.UpdateTimezone(c) }) t.Run("success", func(t *testing.T) { payload := []byte(`{"data":{"type":"auth_timezone_update","attributes":{"timezone":"Asia/Jakarta"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/auth/me/timezone", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if got := repo.users[string(user.ID)].Timezone; got != "Asia/Jakarta" { t.Fatalf("expected timezone updated, got %q", got) } }) t.Run("invalid timezone", func(t *testing.T) { payload := []byte(`{"data":{"type":"auth_timezone_update","attributes":{"timezone":"UTC+7"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/auth/me/timezone", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("empty timezone", func(t *testing.T) { payload := []byte(`{"data":{"type":"auth_timezone_update","attributes":{"timezone":" "}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/auth/me/timezone", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid json", func(t *testing.T) { req, _ := http.NewRequest(http.MethodPatch, "/api/v1/auth/me/timezone", bytes.NewReader([]byte(`{\"data\":`))) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("unauthorized missing auth context", func(t *testing.T) { appNoAuth := fiber.New() appNoAuth.Patch("/api/v1/auth/me/timezone", h.UpdateTimezone) payload := []byte(`{"data":{"type":"auth_timezone_update","attributes":{"timezone":"Asia/Jakarta"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/auth/me/timezone", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := appNoAuth.Test(req) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) } func TestAuthHandler_TOTPSetup_Confirm_Disable(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) cfg := config.AuthConfig{ TOTPIssuer: "Wucher", TOTPChallengeTTL: time.Minute, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() userID := uuidv7.MustBytes() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", append([]byte(nil), userID...)) return c.Next() }) app.Post("/api/v1/auth/totp/setup", h.TOTPSetup) app.Post("/api/v1/auth/totp/confirm", h.TOTPConfirm) app.Post("/api/v1/auth/totp/disable", h.TOTPDisable) if err := repo.CreateUser(context.Background(), &auth.User{ ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes(), IsActive: true, }); err != nil { t.Fatalf("create user: %v", err) } payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_setup","attributes":{"user_id":"%s","email":"user@example.com"}}}`, func() string { s, _ := uuidv7.BytesToString(userID); return s }())) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } // confirm secretRec, _ := repo.GetUserTOTP(context.Background(), userID) secret, _ := protector.Decrypt(secretRec.SecretEncrypted) code, _ := totp.GenerateCode(string(secret), time.Now().UTC()) payload2 := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_confirm","attributes":{"user_id":"%s","code":"%s"}}}`, func() string { s, _ := uuidv7.BytesToString(userID); return s }(), code)) req2, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/confirm", bytes.NewReader(payload2)) req2.Header.Set("Content-Type", "application/json") resp2, err := app.Test(req2) if err != nil { t.Fatalf("app.Test: %v", err) } if resp2.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp2.StatusCode) } // disable payload3 := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_disable","attributes":{"user_id":"%s"}}}`, func() string { s, _ := uuidv7.BytesToString(userID); return s }())) req3, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/disable", bytes.NewReader(payload3)) req3.Header.Set("Content-Type", "application/json") resp3, err := app.Test(req3) if err != nil { t.Fatalf("app.Test: %v", err) } if resp3.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp3.StatusCode) } } func TestAuthHandler_TOTPResetSetup_WithPassword(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, DefaultRoleName: "admin", TOTPIssuer: "Wucher", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg}) user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) if err != nil { t.Fatalf("register failed: %v", err) } if _, _, _, err := svc.SetupTOTP(context.Background(), user.ID, user.Email); err != nil { t.Fatalf("setup totp failed: %v", err) } if repo.totp[string(user.ID)] == nil { t.Fatalf("expected totp setup to exist") } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.TOTPResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.totp[string(user.ID)] != nil { t.Fatalf("expected totp setup to be removed") } } func TestAuthHandler_TOTPResetSetup_Branches(t *testing.T) { newFixture := func(t *testing.T) (*memRepo, *service.AuthService, *auth.User) { t.Helper() repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, err := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) if err != nil { t.Fatalf("protector: %v", err) } roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, DefaultRoleName: "admin", TOTPIssuer: "Wucher", SecurityPINMaxAttempts: 3, SecurityPINLockDuration: time.Minute, SecurityPINActionTokenTTL: time.Minute, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg}) user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) if err != nil { t.Fatalf("register failed: %v", err) } if _, _, _, err := svc.SetupTOTP(context.Background(), user.ID, user.Email); err != nil { t.Fatalf("setup totp failed: %v", err) } return repo, svc, user } t.Run("missing auth context", func(t *testing.T) { _, svc, _ := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/reset-setup", h.TOTPResetSetup) payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("invalid json", func(t *testing.T) { _, svc, user := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.TOTPResetSetup(c) }) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader([]byte(`{"data":`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("validation error", func(t *testing.T) { _, svc, user := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.TOTPResetSetup(c) }) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader([]byte(`{}`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("password required", func(t *testing.T) { _, svc, user := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.TOTPResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"password"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("wrong password", func(t *testing.T) { _, svc, user := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.TOTPResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"password","password":"WrongPassword!"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("user not found", func(t *testing.T) { _, svc, _ := newFixture(t) h := NewAuthHandler(svc) missingUserID := uuidv7.MustBytes() app := fiber.New() app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", missingUserID) return h.TOTPResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("pin invalid token", func(t *testing.T) { _, svc, user := newFixture(t) if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil { t.Fatalf("set pin: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.TOTPResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"pin"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set(pinVerificationHeader, "bad-token") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("pin success", func(t *testing.T) { repo, svc, user := newFixture(t) if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil { t.Fatalf("set pin: %v", err) } actionToken, err := svc.VerifySecurityPINForAction(context.Background(), user.ID, "123456", sharedconst.PinActionTOTPResetSetup) if err != nil { t.Fatalf("verify pin: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.TOTPResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"pin"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set(pinVerificationHeader, actionToken) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.totp[string(user.ID)] != nil { t.Fatalf("expected totp setup removed after pin reset") } }) t.Run("pin delete setup error", func(t *testing.T) { repo, svc, user := newFixture(t) repo.deleteTOTPErr = errors.New("delete failed") if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil { t.Fatalf("set pin: %v", err) } actionToken, err := svc.VerifySecurityPINForAction(context.Background(), user.ID, "123456", sharedconst.PinActionTOTPResetSetup) if err != nil { t.Fatalf("verify pin: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.TOTPResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"pin"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set(pinVerificationHeader, actionToken) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestAuthHandler_WebAuthnResetSetup_WithPIN(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) if err != nil { t.Fatalf("register failed: %v", err) } if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil { t.Fatalf("set pin failed: %v", err) } actionToken, err := svc.VerifySecurityPINForAction(context.Background(), user.ID, "123456", sharedconst.PinActionWebAuthnResetSetup) if err != nil { t.Fatalf("pin verify for action failed: %v", err) } seedWebAuthnLoginCredential(t, repo, user.ID) if len(repo.webauthnCredentials[string(user.ID)]) == 0 { t.Fatalf("expected webauthn credentials to exist") } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.WebAuthnResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"pin"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set(pinVerificationHeader, actionToken) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if len(repo.webauthnCredentials[string(user.ID)]) != 0 { t.Fatalf("expected webauthn credentials to be removed") } } func TestAuthHandler_WebAuthnResetSetup_Branches(t *testing.T) { newFixture := func(t *testing.T) (*memRepo, *service.AuthService, *auth.User) { t.Helper() repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, err := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) if err != nil { t.Fatalf("protector: %v", err) } roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, DefaultRoleName: "admin", SecurityPINMaxAttempts: 3, SecurityPINLockDuration: time.Minute, SecurityPINActionTokenTTL: time.Minute, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg}) user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) if err != nil { t.Fatalf("register failed: %v", err) } seedWebAuthnLoginCredential(t, repo, user.ID) return repo, svc, user } t.Run("missing auth context", func(t *testing.T) { _, svc, _ := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/reset-setup", h.WebAuthnResetSetup) payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("invalid json", func(t *testing.T) { _, svc, user := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.WebAuthnResetSetup(c) }) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader([]byte(`{"data":`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("validation error", func(t *testing.T) { _, svc, user := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.WebAuthnResetSetup(c) }) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader([]byte(`{}`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("password required", func(t *testing.T) { _, svc, user := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.WebAuthnResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"password"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("wrong password", func(t *testing.T) { _, svc, user := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.WebAuthnResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"password","password":"WrongPassword!"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("user not found", func(t *testing.T) { _, svc, _ := newFixture(t) h := NewAuthHandler(svc) missingUserID := uuidv7.MustBytes() app := fiber.New() app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", missingUserID) return h.WebAuthnResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("pin invalid token", func(t *testing.T) { _, svc, user := newFixture(t) if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil { t.Fatalf("set pin: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.WebAuthnResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"pin"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set(pinVerificationHeader, "bad-token") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("pin delete setup error", func(t *testing.T) { repo, svc, user := newFixture(t) repo.deleteWebAuthnErr = errors.New("delete failed") if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil { t.Fatalf("set pin: %v", err) } actionToken, err := svc.VerifySecurityPINForAction(context.Background(), user.ID, "123456", sharedconst.PinActionWebAuthnResetSetup) if err != nil { t.Fatalf("verify pin: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.WebAuthnResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"pin"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set(pinVerificationHeader, actionToken) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("password success", func(t *testing.T) { repo, svc, user := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return h.WebAuthnResetSetup(c) }) payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if len(repo.webauthnCredentials[string(user.ID)]) != 0 { t.Fatalf("expected webauthn credentials removed") } }) } func TestAuthHandler_MicrosoftLogin_Callback(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"} cfg := config.AuthConfig{ SSOStateTTL: time.Minute, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, SSOSuccessRedirectURL: "http://localhost/cb", } sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg}) userID := uuidv7.MustBytes() if err := repo.CreateUser(context.Background(), &auth.User{ ID: userID, Email: "user@example.com", RoleID: roleID, Timezone: "UTC", }); err != nil { t.Fatalf("create user: %v", err) } _ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{ ID: uuidv7.MustBytes(), UserID: userID, Provider: "microsoft", ProviderSubject: "sub", Email: "user@example.com", }) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin) app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"type":"auth_microsoft_url"`)) { t.Fatalf("expected JSON:API type auth_microsoft_url, got body: %s", string(body)) } if !bytes.Contains(body, []byte(`"url":"http://example.com?`)) || !bytes.Contains(body, []byte(`state=`)) { t.Fatalf("expected oauth redirect url in response body, got body: %s", string(body)) } // Extract state from token store state := store.lastKey[len("auth:sso_state:"):] req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil) resp2, err := app.Test(req2) if err != nil { t.Fatalf("app.Test: %v", err) } if resp2.StatusCode != http.StatusFound { t.Fatalf("expected 302, got %d", resp2.StatusCode) } } func TestAuthHandler_Exchange(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"} cfg := config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{Config: cfg}) userID := uuidv7.MustBytes() if err := repo.CreateUser(context.Background(), &auth.User{ ID: userID, Email: "user@example.com", RoleID: roleID, Timezone: "UTC", }); err != nil { t.Fatalf("create user: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/exchange", h.Exchange) t.Run("success", func(t *testing.T) { exp := time.Now().UTC().Add(5 * time.Minute).Unix() payload := fmt.Sprintf(`{"data":{"type":"auth_exchange","attributes":{"access_token":"at","id_token":"it","id_token_claims":{"sub":"sub-123","preferred_username":"user@example.com","name":"User Name","exp":%d,"tid":"tenant-1","oid":"obj-1"}}}}`, exp) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/exchange", strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(body)) } }) t.Run("expired id token claim", func(t *testing.T) { exp := time.Now().UTC().Add(-5 * time.Minute).Unix() payload := fmt.Sprintf(`{"data":{"type":"auth_exchange","attributes":{"access_token":"at","id_token":"it","id_token_claims":{"sub":"sub-123","preferred_username":"user@example.com","exp":%d}}}}`, exp) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/exchange", strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) } func TestAuthHandler_MicrosoftLogin_CallbackXHR_NoRedirect(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"} cfg := config.AuthConfig{ SSOStateTTL: time.Minute, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, SSOSuccessRedirectURL: "http://localhost:3000/challenge/msentra", } sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg}) userID := uuidv7.MustBytes() if err := repo.CreateUser(context.Background(), &auth.User{ ID: userID, Email: "user@example.com", RoleID: roleID, Timezone: "UTC", }); err != nil { t.Fatalf("create user: %v", err) } _ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{ ID: uuidv7.MustBytes(), UserID: userID, Provider: "microsoft", ProviderSubject: "sub", Email: "user@example.com", }) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin) app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } state := store.lastKey[len("auth:sso_state:"):] req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil) req2.Header.Set("Accept", "application/json") req2.Header.Set("Sec-Fetch-Mode", "cors") req2.Header.Set("Sec-Fetch-Dest", "empty") resp2, err := app.Test(req2) if err != nil { t.Fatalf("app.Test: %v", err) } if resp2.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp2.StatusCode) } body, _ := io.ReadAll(resp2.Body) if !bytes.Contains(body, []byte(`"type":"auth_identity"`)) { t.Fatalf("expected auth_identity response, got body: %s", string(body)) } } func TestAuthHandler_MicrosoftLogin_UsesInteractivePromptByDefault(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() sso := &serviceMockSSO{authURL: "http://example.com?state=%s&prompt=select_account"} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{ SSO: sso, Protector: nil, Config: config.AuthConfig{SSOStateTTL: time.Minute}, }) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) // /microsoft/login is now an interactive sign-in; silent prompt=none belongs to // /microsoft/login-check only. if !bytes.Contains(body, []byte(`prompt=select_account`)) { t.Fatalf("expected interactive prompt=select_account in response body, got: %s", string(body)) } if bytes.Contains(body, []byte(`prompt=none`)) { t.Fatalf("expected interactive login, but response used silent prompt=none: %s", string(body)) } } func TestAuthHandler_MicrosoftCallback_LoginCheck_NoMicrosoftSession(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane"}} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{ SSO: sso, Protector: nil, Config: config.AuthConfig{SSOStateTTL: time.Minute}, }) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback) // The silent login_check flow is seeded by the session-check entrypoint (no longer by // /microsoft/login, which is now an interactive sign-in). if _, err := svc.BeginMicrosoftSessionCheck(context.Background()); err != nil { t.Fatalf("begin session check: %v", err) } state := store.lastKey[len("auth:sso_state:"):] req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?state="+state+"&error=login_required", nil) resp2, err := app.Test(req2) if err != nil { t.Fatalf("app.Test callback: %v", err) } if resp2.StatusCode != http.StatusFound { t.Fatalf("expected 302, got %d", resp2.StatusCode) } location := resp2.Header.Get("Location") if !strings.Contains(location, "http://example.com?") || !strings.Contains(location, "state=") { t.Fatalf("expected redirect to interactive microsoft auth url, got: %s", location) } if !strings.Contains(location, "prompt=select_account") { t.Fatalf("expected interactive fallback with prompt=select_account, got: %s", location) } if _, ok := store.store["auth:sso_state:"+state]; ok { t.Fatalf("expected state to be cleared for failed login_check flow") } } func TestAuthHandler_MicrosoftCallback_LoginCheckStrict_NoMicrosoftSession(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane"}} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{ SSO: sso, Protector: nil, Config: config.AuthConfig{SSOStateTTL: time.Minute}, }) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/login-check", h.MicrosoftLoginCheck) app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login-check", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } state := store.lastKey[len("auth:sso_state:"):] req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?state="+state+"&error=login_required", nil) req2.Header.Set("Accept", "application/json") req2.Header.Set("Sec-Fetch-Mode", "cors") req2.Header.Set("Sec-Fetch-Dest", "empty") resp2, err := app.Test(req2) if err != nil { t.Fatalf("app.Test callback: %v", err) } if resp2.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp2.StatusCode) } } func TestAuthHandler_MicrosoftCallback_LoginCheckStrict_ActiveSession(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane"}} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{ SSO: sso, Protector: nil, Config: config.AuthConfig{SSOStateTTL: time.Minute}, }) roleID := uuidv7.MustBytes() repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"} userID := uuidv7.MustBytes() if err := repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: roleID, Timezone: "UTC"}); err != nil { t.Fatalf("create user: %v", err) } _ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{ ID: uuidv7.MustBytes(), UserID: userID, Provider: "microsoft", ProviderSubject: "sub", Email: "user@example.com", }) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/login-check", h.MicrosoftLoginCheck) app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login-check", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } state := store.lastKey[len("auth:sso_state:"):] req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil) req2.Header.Set("Accept", "application/json") req2.Header.Set("Sec-Fetch-Mode", "cors") req2.Header.Set("Sec-Fetch-Dest", "empty") resp2, err := app.Test(req2) if err != nil { t.Fatalf("app.Test callback: %v", err) } if resp2.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp2.StatusCode) } body, _ := io.ReadAll(resp2.Body) if !bytes.Contains(body, []byte(`"type":"auth_microsoft_session"`)) { t.Fatalf("expected auth_microsoft_session response, got body: %s", string(body)) } } func TestAuthHandler_MicrosoftReauth_Callback(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"} cfg := config.AuthConfig{ SSOStateTTL: time.Minute, SSOSuccessRedirectURL: "http://localhost/cb", } sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg}) identity := &auth.UserIdentity{ ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Provider: "microsoft", ProviderSubject: "sub", Email: "user@example.com", } if err := repo.CreateUser(context.Background(), &auth.User{ ID: identity.UserID, Email: "user@example.com", RoleID: roleID, Timezone: "UTC", }); err != nil { t.Fatalf("create user: %v", err) } _ = repo.CreateIdentity(context.Background(), identity) h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", identity.UserID) return c.Next() }) app.Get("/api/v1/auth/microsoft/reauth", h.MicrosoftReauth) app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/reauth?purpose=pin_reset_request", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } state := store.lastKey[len("auth:sso_state:"):] req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil) resp2, err := app.Test(req2) if err != nil { t.Fatalf("callback app.Test: %v", err) } if resp2.StatusCode != http.StatusFound { t.Fatalf("expected 302, got %d", resp2.StatusCode) } location, err := url.Parse(resp2.Header.Get("Location")) if err != nil { t.Fatalf("parse location: %v", err) } if location.Query().Get("sso_reauth") != "1" { t.Fatalf("expected sso_reauth marker in redirect") } if location.Query().Get("purpose") != "pin_reset_request" { t.Fatalf("unexpected purpose in redirect: %s", location.Query().Get("purpose")) } if location.Query().Get("reauth_token") == "" { t.Fatalf("expected reauth_token in redirect") } } func TestAuthHandler_MicrosoftLink_Callback(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"} cfg := config.AuthConfig{ SSOStateTTL: time.Minute, SSOSuccessRedirectURL: "http://localhost/cb", } sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub-link", Email: "user@example.com", Name: "Jane Doe"}} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg}) userID := uuidv7.MustBytes() if err := repo.CreateUser(context.Background(), &auth.User{ ID: userID, Email: "user@example.com", RoleID: roleID, Timezone: "UTC", }); err != nil { t.Fatalf("create user: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Get("/api/v1/auth/microsoft/link", h.MicrosoftLink) app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/link", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } state := store.lastKey[len("auth:sso_state:"):] req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil) resp2, err := app.Test(req2) if err != nil { t.Fatalf("callback app.Test: %v", err) } if resp2.StatusCode != http.StatusFound { t.Fatalf("expected 302, got %d", resp2.StatusCode) } location, err := url.Parse(resp2.Header.Get("Location")) if err != nil { t.Fatalf("parse location: %v", err) } if location.Query().Get("sso_link") != "1" { t.Fatalf("expected sso_link marker in redirect") } if location.Query().Get("provider") != "microsoft" { t.Fatalf("unexpected provider in redirect: %s", location.Query().Get("provider")) } } type serviceMockSSO struct { claims service.MicrosoftClaims authURL string } func (m *serviceMockSSO) AuthCodeURL(state string) string { if strings.TrimSpace(m.authURL) != "" { return fmt.Sprintf(m.authURL, state) } return "http://example.com?state=" + state } func (m *serviceMockSSO) LogoutURL(postLogoutRedirectURL string) string { if strings.TrimSpace(postLogoutRedirectURL) == "" { return "http://example.com/logout" } return "http://example.com/logout?post_logout_redirect_uri=" + url.QueryEscape(postLogoutRedirectURL) } func (m *serviceMockSSO) ExchangeCode(ctx context.Context, code string) (service.MicrosoftClaims, error) { return m.claims, nil } func TestAuthHandler_Register_InvalidJSON(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/register", h.Register) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_Register_RoleNotFound(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/register", h.Register) payload := []byte(`{"data":{"type":"auth_register","attributes":{"email":"user@example.com","username":"john.doe","password":"Password123!","first_name":"John","last_name":"Doe"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_VerifyEmail_MissingToken(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/verify-email", h.VerifyEmail) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/verify-email", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_Login_InvalidJSON(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/login", h.Login) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_MicrosoftLogin_NotConfigured(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_MicrosoftCallback_Invalid(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_MicrosoftCallback_InvalidState(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{SSOStateTTL: time.Minute}}) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state=missing", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_MicrosoftCallback_SSONotLinked(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{SSOStateTTL: time.Minute}}) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin) app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil) _, _ = app.Test(req) state := store.lastKey[len("auth:sso_state:"):] req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil) resp2, err := app.Test(req2) if err != nil { t.Fatalf("app.Test: %v", err) } if resp2.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp2.StatusCode) } body, _ := io.ReadAll(resp2.Body) if !bytes.Contains(body, []byte(`"title":"SSO not linked"`)) { t.Fatalf("expected sso not linked response, got %s", string(body)) } } func TestAuthHandler_MicrosoftCallback_SSONotLinked_BrowserRedirectsToFrontendError(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() cfg := config.AuthConfig{ SSOStateTTL: time.Minute, SSOSuccessRedirectURL: "http://localhost:3000/en/challenge/msentra", } sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin) app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test login: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } state := store.lastKey[len("auth:sso_state:"):] req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil) req2.Header.Set("Accept", "text/html") resp2, err := app.Test(req2) if err != nil { t.Fatalf("app.Test callback: %v", err) } if resp2.StatusCode != http.StatusFound { t.Fatalf("expected 302, got %d", resp2.StatusCode) } location := resp2.Header.Get("Location") if !strings.Contains(location, "http://localhost:3000/en/challenge/msentra?") { t.Fatalf("expected frontend redirect, got %q", location) } if !strings.Contains(location, "sso=error") || !strings.Contains(location, "reason=sso_not_linked") { t.Fatalf("expected error query params in redirect, got %q", location) } } func TestAuthHandler_SSOLinkAndUnlink(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() roleID := uuidv7.MustBytes() repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"} userID := uuidv7.MustBytes() if err := repo.CreateUser(context.Background(), &auth.User{ ID: userID, Email: "user@example.com", RoleID: roleID, Timezone: "UTC", }); err != nil { t.Fatalf("create user: %v", err) } sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/sso/link", h.SSOLink) app.Delete("/api/v1/auth/sso/unlink", h.SSOUnlink) linkPayload := []byte(`{"data":{"type":"auth_sso_link","attributes":{"provider":"microsoft","code":"code"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/sso/link", bytes.NewReader(linkPayload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } unlinkPayload := []byte(`{"data":{"type":"auth_sso_unlink","attributes":{"provider":"microsoft"}}}`) req2, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sso/unlink", bytes.NewReader(unlinkPayload)) req2.Header.Set("Content-Type", "application/json") resp2, err := app.Test(req2) if err != nil { t.Fatalf("app.Test: %v", err) } if resp2.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp2.StatusCode) } } func TestAuthHandler_TOTPSetup_InvalidUserID(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() userID := uuidv7.MustBytes() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", append([]byte(nil), userID...)) return c.Next() }) app.Post("/api/v1/auth/totp/setup", h.TOTPSetup) payload := []byte(`{"data":{"type":"auth_totp_setup","attributes":{"user_id":"bad","email":"user@example.com"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } } func TestAuthHandler_TOTPSetup_InvalidJSON(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() userID := uuidv7.MustBytes() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", append([]byte(nil), userID...)) return c.Next() }) app.Post("/api/v1/auth/totp/setup", h.TOTPSetup) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/setup", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_Invite_InvalidJSON(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/invite", h.Invite) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_Invite_InvalidRoleID(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/invite", h.Invite) payload := []byte(`{"data":{"type":"auth_invite","attributes":{"email":"user@example.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"bad-uuid"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } } func TestAuthHandler_Invite_InvalidRoleIDs(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/invite", h.Invite) roleID, _ := uuidv7.BytesToString(uuidv7.MustBytes()) payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_invite","attributes":{"email":"user@example.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"%s","role_ids":["bad-uuid"]}}}`, roleID)) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } } func TestAuthHandler_Invite_ServiceError(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) roleID := uuidv7.MustBytes() if err := repo.CreateUser(context.Background(), &auth.User{ ID: uuidv7.MustBytes(), Email: "user@example.com", FirstName: "Jane", LastName: "Doe", RoleID: roleID, }); err != nil { t.Fatalf("create user: %v", err) } roleIDStr, _ := uuidv7.BytesToString(roleID) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/invite", h.Invite) payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_invite","attributes":{"email":"user@example.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"%s"}}}`, roleIDStr)) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_Invite_Success(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() cfg := config.AuthConfig{ SetPasswordURL: "http://localhost/set-password", InviteTTL: time.Hour, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) roleID := uuidv7.MustBytes() roleIDStr, _ := uuidv7.BytesToString(roleID) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/invite", h.Invite) payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_invite","attributes":{"email":"new-user@example.com","username":"new.user","first_name":"John","last_name":"Doe","mobile_phone":" +6281234 ","role_id":"%s"}}}`, roleIDStr)) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } if store.lastKey == "" { t.Fatalf("expected invite token to be stored") } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"username":"new.user"`)) { t.Fatalf("expected response to include username, got %s", string(body)) } } func TestAuthHandler_Invite_SuccessWithRoleIDs(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() cfg := config.AuthConfig{ SetPasswordURL: "http://localhost/set-password", InviteTTL: time.Hour, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) roleID := uuidv7.MustBytes() extraRoleID := uuidv7.MustBytes() roleIDStr, _ := uuidv7.BytesToString(roleID) extraRoleIDStr, _ := uuidv7.BytesToString(extraRoleID) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/invite", h.Invite) payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_invite","attributes":{"email":"multi-role@example.com","username":"multi.role","first_name":"John","last_name":"Doe","role_id":"%s","role_ids":["%s","%s"]}}}`, roleIDStr, roleIDStr, extraRoleIDStr)) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } found := false for _, created := range repo.users { if created.Email != "multi-role@example.com" { continue } found = true if len(created.RoleIDs) != 2 { t.Fatalf("expected 2 role ids stored, got %d", len(created.RoleIDs)) } } if !found { t.Fatalf("expected invited user stored in repo") } } func TestAuthHandler_SetPassword_InvalidJSON(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/set-password", h.SetPassword) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/set-password", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_SetPassword_ValidationError(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/set-password", h.SetPassword) payload := []byte(`{"data":{"type":"auth_set_password","attributes":{"token":"abc","password":"123"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/set-password", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } } func TestAuthHandler_SetPassword_InvalidToken(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/set-password", h.SetPassword) payload := []byte(`{"data":{"type":"auth_set_password","attributes":{"token":"missing-token","password":"Password123!"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/set-password", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_SetPassword_Success(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, SetPasswordURL: "http://localhost/set-password", InviteTTL: time.Hour, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) roleID := uuidv7.MustBytes() user, err := svc.InviteUser(context.Background(), "invitee@example.com", "invitee", "John", "Doe", "", roleID) if err != nil { t.Fatalf("invite user: %v", err) } inviteToken := store.lastKey[len("auth:invite:"):] h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/set-password", h.SetPassword) payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_set_password","attributes":{"token":"%s","password":"Password123!"}}}`, inviteToken)) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/set-password", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } savedUser, err := repo.GetUserByID(context.Background(), user.ID) if err != nil { t.Fatalf("get user: %v", err) } if savedUser == nil { t.Fatalf("expected user to exist") } if len(savedUser.PasswordHash) == 0 || len(savedUser.SaltValue) == 0 { t.Fatalf("expected password hash and salt to be set") } if savedUser.EmailVerifiedAt == nil { t.Fatalf("expected email verified at to be set") } } func TestAuthHandler_ResendSetPasswordInvite(t *testing.T) { t.Run("validation error invalid user_id", func(t *testing.T) { h := NewAuthHandler(service.NewAuthService(newMemRepo(), nil, newMemTokenStore(), nil, service.AuthServiceDependencies{ Config: config.AuthConfig{ SetPasswordURL: "http://localhost/set-password", ForgotPasswordMinDuration: 0, }, })) app := fiber.New() app.Post("/api/v1/auth/invite/resend-set-password", h.ResendSetPasswordInvite) payload := []byte(`{"data":{"type":"auth_resend_set_password","attributes":{"user_id":"bad"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite/resend-set-password", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("generic success and enqueue when user has no password", func(t *testing.T) { repo := newMemRepo() tokens := newMemTokenStore() cfg := config.AuthConfig{ SetPasswordURL: "http://localhost/set-password", InviteTTL: time.Hour, ForgotPasswordMinDuration: 0, ForgotPasswordEmailCooldown: time.Minute, } svc := service.NewAuthService(repo, nil, tokens, nil, service.AuthServiceDependencies{Config: cfg}) h := NewAuthHandler(svc) userID := uuidv7.MustBytes() repo.users[string(userID)] = &auth.User{ ID: userID, Email: "invitee@example.com", FirstName: "Invitee", LastName: "User", IsActive: true, } app := fiber.New() app.Post("/api/v1/auth/invite/resend-set-password", h.ResendSetPasswordInvite) userIDStr, _ := uuidv7.BytesToString(userID) payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_resend_set_password","attributes":{"user_id":"%s"}}}`, userIDStr)) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite/resend-set-password", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:invite:resend:cooldown:") { t.Fatalf("expected resend cooldown key, got %q", tokens.lastKey) } }) } func TestAuthHandler_TOTPConfirm_InvalidCode(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) cfg := config.AuthConfig{TOTPIssuer: "Wucher"} svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg}) h := NewAuthHandler(svc) app := fiber.New() userID := uuidv7.MustBytes() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", append([]byte(nil), userID...)) return c.Next() }) app.Post("/api/v1/auth/totp/confirm", h.TOTPConfirm) _, _, _, _ = svc.SetupTOTP(context.Background(), userID, "user@example.com") payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_confirm","attributes":{"user_id":"%s","code":"000000"}}}`, func() string { s, _ := uuidv7.BytesToString(userID); return s }())) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/confirm", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } } func TestAuthHandler_TOTPConfirm_InvalidUserID(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() userID := uuidv7.MustBytes() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", append([]byte(nil), userID...)) return c.Next() }) app.Post("/api/v1/auth/totp/confirm", h.TOTPConfirm) payload := []byte(`{"data":{"type":"auth_totp_confirm","attributes":{"user_id":"bad","code":"123456"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/confirm", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } } func TestAuthHandler_TOTPConfirm_InvalidJSON(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() userID := uuidv7.MustBytes() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", append([]byte(nil), userID...)) return c.Next() }) app.Post("/api/v1/auth/totp/confirm", h.TOTPConfirm) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/confirm", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_TOTPVerify_InvalidChallenge(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/verify", h.TOTPVerify) payload := []byte(`{"data":{"type":"auth_totp_verify","attributes":{"challenge_token":"bad","code":"123456"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), `"code":"TOKEN_INVALID"`) { t.Fatalf("expected TOKEN_INVALID code, got body=%s", string(body)) } } func TestAuthHandler_TOTPVerify_InvalidJSON(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/verify", h.TOTPVerify) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_TOTPVerify_TokenIssueError(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) cfg := config.AuthConfig{ TOTPIssuer: "Wucher", TOTPChallengeTTL: time.Minute, // No JWT secrets to force token issue error } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg}) // prepare user + totp userID := uuidv7.MustBytes() if err := repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "u@e.com", RoleID: uuidv7.MustBytes()}); err != nil { t.Fatalf("create user: %v", err) } secret, _, _, _ := svc.SetupTOTP(context.Background(), userID, "u@e.com") code, _ := totp.GenerateCode(secret, time.Now().UTC()) _, _ = svc.ConfirmTOTP(context.Background(), userID, code) challenge, _ := svc.CreateTOTPChallenge(context.Background(), userID) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/verify", h.TOTPVerify) payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_verify","attributes":{"challenge_token":"%s","code":"%s"}}}`, challenge, code)) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_TOTPDisable_InvalidUserID(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() userID := uuidv7.MustBytes() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", append([]byte(nil), userID...)) return c.Next() }) app.Post("/api/v1/auth/totp/disable", h.TOTPDisable) payload := []byte(`{"data":{"type":"auth_totp_disable","attributes":{"user_id":"bad"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/disable", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } } func TestAuthHandler_TOTPDisable_InvalidJSON(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() userID := uuidv7.MustBytes() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", append([]byte(nil), userID...)) return c.Next() }) app.Post("/api/v1/auth/totp/disable", h.TOTPDisable) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/disable", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } } func TestAuthHandler_ParseSameSite_All(t *testing.T) { if parseSameSite("Strict") != "Strict" { t.Fatalf("expected strict") } if parseSameSite("None") != "None" { t.Fatalf("expected none") } if parseSameSite("Lax") != "Lax" { t.Fatalf("expected lax") } } func TestAuthHandler_TOTPVerify_SetsCookies(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, TOTPChallengeTTL: time.Minute, TOTPIssuer: "Wucher", DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg}) user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID) if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil { t.Fatalf("mark email verified: %v", err) } secret, _, _, _ := svc.SetupTOTP(context.Background(), user.ID, user.Email) code, _ := totp.GenerateCode(secret, time.Now().UTC()) _, _ = svc.ConfirmTOTP(context.Background(), user.ID, code) challenge, _ := svc.CreateTOTPChallenge(context.Background(), user.ID) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/verify", h.TOTPVerify) payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_verify","attributes":{"challenge_token":"%s","code":"%s"}}}`, challenge, code)) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } } func TestAuthHandler_TOTPVerify_WrongThenCorrectSameChallenge(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) roleID := uuidv7.MustBytes() repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"} repo.rolesByID[string(roleID)] = repo.rolesByName["admin"] cfg := config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, TOTPChallengeTTL: time.Minute, TOTPIssuer: "Wucher", DefaultRoleName: "admin", } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg}) user, _ := svc.RegisterWithEmail(context.Background(), "user2@example.com", "john.doe.2", "John", "Doe", "", "Password123!", roleID) if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil { t.Fatalf("mark email verified: %v", err) } secret, _, _, _ := svc.SetupTOTP(context.Background(), user.ID, user.Email) code, _ := totp.GenerateCode(secret, time.Now().UTC()) _, _ = svc.ConfirmTOTP(context.Background(), user.ID, code) challenge, _ := svc.CreateTOTPChallenge(context.Background(), user.ID) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/totp/verify", h.TOTPVerify) wrongPayload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_verify","attributes":{"challenge_token":"%s","code":"000000"}}}`, challenge)) wrongReq, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader(wrongPayload)) wrongReq.Header.Set("Content-Type", "application/json") wrongResp, err := app.Test(wrongReq) if err != nil { t.Fatalf("app.Test wrong attempt: %v", err) } if wrongResp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401 for wrong code, got %d", wrongResp.StatusCode) } wrongBody, _ := io.ReadAll(wrongResp.Body) if !strings.Contains(string(wrongBody), `"code":"USER_UNAUTHORIZED"`) { t.Fatalf("expected USER_UNAUTHORIZED code on wrong totp, got body=%s", string(wrongBody)) } okPayload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_verify","attributes":{"challenge_token":"%s","code":"%s"}}}`, challenge, code)) okReq, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader(okPayload)) okReq.Header.Set("Content-Type", "application/json") okResp, err := app.Test(okReq) if err != nil { t.Fatalf("app.Test correct attempt: %v", err) } if okResp.StatusCode != http.StatusOK { t.Fatalf("expected 200 after retry with same challenge, got %d", okResp.StatusCode) } } func TestAuthHandler_SetupSecurityPIN(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) userID := uuidv7.MustBytes() if err := repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}); err != nil { t.Fatalf("create user: %v", err) } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_setup","attributes":{"pin":"123456","confirm_pin":"123456"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if len(repo.users[string(userID)].SecurityPINHash) == 0 { t.Fatalf("expected encrypted PIN to be stored") } } func TestAuthHandler_VerifySecurityPIN(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) userID := uuidv7.MustBytes() if err := repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}); err != nil { t.Fatalf("create user: %v", err) } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{ SecurityPINActionTokenTTL: time.Minute, }}) if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil { t.Fatalf("set pin: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"123456","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte("action_token")) { t.Fatalf("expected action_token in response body, got %s", string(body)) } } func TestAuthHandler_ChangeSecurityPIN_InvalidCurrent(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) userID := uuidv7.MustBytes() if err := repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}); err != nil { t.Fatalf("create user: %v", err) } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}}) if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil { t.Fatalf("set pin: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"000000","new_pin":"654321","confirm_pin":"654321"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } } func TestAuthHandler_ForgotSecurityPIN_GenericResponse(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() cfg := config.AuthConfig{ SecurityPINResetURL: "https://app.example.com/reset-pin", SecurityPINResetTTL: 20 * time.Minute, ForgotSecurityPINCooldown: time.Minute, } svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg}) existing := &auth.User{ID: uuidv7.MustBytes(), Email: "exists@example.com", RoleID: uuidv7.MustBytes()} if err := repo.CreateUser(context.Background(), existing); err != nil { t.Fatalf("create user: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/pin/forgot", h.ForgotSecurityPIN) existingPayload := []byte(`{"data":{"type":"auth_forgot_pin","attributes":{"email":"exists@example.com"}}}`) missingPayload := []byte(`{"data":{"type":"auth_forgot_pin","attributes":{"email":"missing@example.com"}}}`) req1, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/forgot", bytes.NewReader(existingPayload)) req1.Header.Set("Content-Type", "application/json") resp1, err := app.Test(req1) if err != nil { t.Fatalf("first app.Test: %v", err) } req2, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/forgot", bytes.NewReader(missingPayload)) req2.Header.Set("Content-Type", "application/json") resp2, err := app.Test(req2) if err != nil { t.Fatalf("second app.Test: %v", err) } if resp1.StatusCode != http.StatusOK || resp2.StatusCode != http.StatusOK { t.Fatalf("expected both responses 200, got %d and %d", resp1.StatusCode, resp2.StatusCode) } body1, _ := io.ReadAll(resp1.Body) body2, _ := io.ReadAll(resp2.Body) if !bytes.Equal(body1, body2) { t.Fatalf("forgot-pin responses should match, got existing=%s missing=%s", string(body1), string(body2)) } } func TestAuthHandler_RequestSecurityPINReset(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{ SecurityPINResetURL: "https://app.example.com/reset-pin", SecurityPINResetTTL: 20 * time.Minute, ForgotSecurityPINCooldown: time.Minute, SecurityPINLockDuration: 0, PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, }}) h := NewAuthHandler(svc) user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", uuidv7.MustBytes()) if err != nil { t.Fatalf("register user: %v", err) } userID := user.ID payload := []byte(`{"data":{"type":"auth_pin_request_reset","attributes":{"password":"Password123!"}}}`) t.Run("missing auth context", func(t *testing.T) { app := fiber.New() app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("not blocked", func(t *testing.T) { app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"title":"PIN not blocked"`)) { t.Fatalf("expected PIN not blocked title, got %s", string(body)) } if !bytes.Contains(body, []byte(`"detail":"security PIN is not blocked"`)) { t.Fatalf("expected security PIN is not blocked detail, got %s", string(body)) } }) t.Run("blocked and reset requested", func(t *testing.T) { userIDStr, _ := uuidv7.BytesToString(userID) store.store["auth:security_pin:lock:"+userIDStr] = []byte("1") app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) t.Run("invalid password", func(t *testing.T) { userIDStr, _ := uuidv7.BytesToString(userID) store.store["auth:security_pin:lock:"+userIDStr] = []byte("1") delete(store.store, "auth:security_pin:cooldown:user@example.com") app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader([]byte(`{"data":{"type":"auth_pin_request_reset","attributes":{"password":"wrong-password"}}}`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) } func TestAuthHandler_ResetSecurityPIN(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, }}) user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", uuidv7.MustBytes()) if err != nil { t.Fatalf("register user: %v", err) } rawToken := "pin-reset-token" tokenHash := sha256.Sum256([]byte(rawToken)) repo.securityPINResetTokens[string(tokenHash[:])] = &auth.SecurityPINResetToken{ ID: uuidv7.MustBytes(), UserID: user.ID, TokenHash: tokenHash[:], ExpiresAt: time.Now().Add(10 * time.Minute), } h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN) payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"pin-reset-token","password":"Password123!","new_pin":"654321","confirm_pin":"654321"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } enc := repo.users[string(user.ID)].SecurityPINHash if len(enc) == 0 { t.Fatalf("expected hashed pin record on user") } if _, err := svc.VerifySecurityPINForAction(context.Background(), user.ID, "654321", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); err != nil { t.Fatalf("expected updated pin to verify, got %v", err) } } func TestAuthHandler_SetupSecurityPIN_Branches(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) t.Run("missing auth context", func(t *testing.T) { app := fiber.New() app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_setup","attributes":{"pin":"123456","confirm_pin":"123456"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("confirm mismatch", func(t *testing.T) { userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_setup","attributes":{"pin":"123456","confirm_pin":"123455"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) } func TestAuthHandler_VerifySecurityPIN_Branches(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{ SecurityPINMaxAttempts: 1, SecurityPINLockDuration: time.Minute, SecurityPINActionTokenTTL: time.Minute, }}) h := NewAuthHandler(svc) t.Run("missing auth context", func(t *testing.T) { app := fiber.New() app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"123456","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("pin not set", func(t *testing.T) { app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"123456","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } }) t.Run("locked response", func(t *testing.T) { if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil { t.Fatalf("set pin: %v", err) } _, _ = svc.VerifySecurityPINForAction(context.Background(), userID, "000000", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74") app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"123456","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusLocked { t.Fatalf("expected 423, got %d", resp.StatusCode) } }) } func TestAuthHandler_Sessions_Branches(t *testing.T) { t.Run("missing auth context", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, }}) h := NewAuthHandler(svc) app := fiber.New() app.Get("/api/v1/auth/sessions", h.Sessions) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/sessions", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("service error on invalid user id", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, }}) h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", []byte("short")) return c.Next() }) app.Get("/api/v1/auth/sessions", h.Sessions) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/sessions", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("success returns only current active session", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, }}) user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com", RoleID: uuidv7.MustBytes()} if err := repo.CreateUser(context.Background(), user); err != nil { t.Fatalf("create user: %v", err) } first, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Macintosh)", "127.0.0.1") if err != nil { t.Fatalf("issue first token: %v", err) } second, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Windows)", "127.0.0.2") if err != nil { t.Fatalf("issue second token: %v", err) } if _, err := svc.ParseAccessToken(context.Background(), first.AccessToken); !errors.Is(err, service.ErrInvalidToken) { t.Fatalf("expected first token invalid after second login, got %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return c.Next() }) app.Get("/api/v1/auth/sessions", h.Sessions) req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/sessions", nil) req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: second.AccessToken}) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } var payload map[string]any if err := json.Unmarshal(body, &payload); err != nil { t.Fatalf("unmarshal body: %v", err) } data := payload["data"].(map[string]any) attrs := data["attributes"].(map[string]any) sessions := attrs["sessions"].([]any) if len(sessions) != 1 { t.Fatalf("expected only one active session, got %d", len(sessions)) } session := sessions[0].(map[string]any) if session["id"] != second.SessionID || session["active_now"] != true { t.Fatalf("expected current session flagged active_now, got %#v", session) } }) } func TestAuthHandler_DeleteSession_Branches(t *testing.T) { newFixture := func(t *testing.T) (*memRepo, *service.AuthService, *auth.User, service.TokenPair, service.TokenPair) { t.Helper() repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, }}) user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com", RoleID: uuidv7.MustBytes()} if err := repo.CreateUser(context.Background(), user); err != nil { t.Fatalf("create user: %v", err) } first, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Macintosh)", "127.0.0.1") if err != nil { t.Fatalf("issue first token: %v", err) } second, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Windows)", "127.0.0.2") if err != nil { t.Fatalf("issue second token: %v", err) } return repo, svc, user, first, second } t.Run("missing auth context", func(t *testing.T) { _, svc, _, _, _ := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Delete("/api/v1/auth/sessions/:session_id", h.DeleteSession) req, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/"+mustUUIDStringForcesPresent(t, uuidv7.MustBytes()), nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("invalid session id", func(t *testing.T) { _, svc, user, _, _ := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return c.Next() }) app.Delete("/api/v1/auth/sessions/:session_id", h.DeleteSession) req, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/not-uuid", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("session not found", func(t *testing.T) { _, svc, user, _, _ := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return c.Next() }) app.Delete("/api/v1/auth/sessions/:session_id", h.DeleteSession) req, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/"+mustUUIDStringForcesPresent(t, uuidv7.MustBytes()), nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", resp.StatusCode) } }) t.Run("session belongs to another user", func(t *testing.T) { _, svc, _, _, second := newFixture(t) otherUser := &auth.User{ID: uuidv7.MustBytes(), Email: "other@example.com", RoleID: uuidv7.MustBytes()} repo := newMemRepo() _ = repo h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", otherUser.ID) return c.Next() }) app.Delete("/api/v1/auth/sessions/:session_id", h.DeleteSession) req, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/"+second.SessionID, nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("success deletes one session", func(t *testing.T) { _, svc, user, first, second := newFixture(t) h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", user.ID) return c.Next() }) app.Delete("/api/v1/auth/sessions/:session_id", h.DeleteSession) req, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/"+second.SessionID, nil) req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: first.AccessToken}) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if _, err := svc.RefreshTokens(context.Background(), second.RefreshToken); !errors.Is(err, service.ErrInvalidToken) { t.Fatalf("expected revoked session refresh token invalid, got %v", err) } }) } func TestAuthHandler_ChangeSecurityPIN_Branches(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) t.Run("missing auth context", func(t *testing.T) { app := fiber.New() app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"123456","new_pin":"654321","confirm_pin":"654321"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("confirm mismatch", func(t *testing.T) { userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"123456","new_pin":"654321","confirm_pin":"654322"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) } func TestAuthHandler_ForgotAndResetSecurityPIN_Branches(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) t.Run("forgot invalid json", func(t *testing.T) { app := fiber.New() app.Post("/api/v1/auth/pin/forgot", h.ForgotSecurityPIN) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/forgot", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("reset invalid json", func(t *testing.T) { app := fiber.New() app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("reset invalid link", func(t *testing.T) { key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) svc2 := service.NewAuthService(newMemRepo(), newMemRepo(), newMemTokenStore(), nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}}) h2 := NewAuthHandler(svc2) app := fiber.New() app.Post("/api/v1/auth/pin/reset", h2.ResetSecurityPIN) payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"missing","password":"Password123!","new_pin":"123456","confirm_pin":"123456"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("reset invalid password", func(t *testing.T) { key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) repo2 := newMemRepo() svc2 := service.NewAuthService(repo2, repo2, newMemTokenStore(), nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, }}) user, err := svc2.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", uuidv7.MustBytes()) if err != nil { t.Fatalf("register user: %v", err) } hash := sha256.Sum256([]byte("token-invalid-password")) repo2.securityPINResetTokens[string(hash[:])] = &auth.SecurityPINResetToken{ ID: uuidv7.MustBytes(), UserID: user.ID, TokenHash: hash[:], ExpiresAt: time.Now().Add(time.Minute), } h2 := NewAuthHandler(svc2) app := fiber.New() app.Post("/api/v1/auth/pin/reset", h2.ResetSecurityPIN) payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"token-invalid-password","password":"WrongPassword","new_pin":"123456","confirm_pin":"123456"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("reset service error returns 500", func(t *testing.T) { key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) repo2 := newMemRepo() svc2 := service.NewAuthService(repo2, repo2, newMemTokenStore(), nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{ PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, }}) user, err := svc2.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", uuidv7.MustBytes()) if err != nil { t.Fatalf("register user: %v", err) } repo2.consumePINResetErr = errors.New("db fail") hash := sha256.Sum256([]byte("token-err")) repo2.securityPINResetTokens[string(hash[:])] = &auth.SecurityPINResetToken{ ID: uuidv7.MustBytes(), UserID: user.ID, TokenHash: hash[:], ExpiresAt: time.Now().Add(time.Minute), } h2 := NewAuthHandler(svc2) app := fiber.New() app.Post("/api/v1/auth/pin/reset", h2.ResetSecurityPIN) payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"token-err","password":"Password123!","new_pin":"123456","confirm_pin":"123456"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", resp.StatusCode) } }) } func TestAuthHandler_SetupSecurityPIN_AlreadySet(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}}) if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil { t.Fatalf("set initial pin: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_setup","attributes":{"pin":"123456","confirm_pin":"123456"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } } func TestAuthHandler_VerifySecurityPIN_InvalidPIN(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{ SecurityPINMaxAttempts: 5, SecurityPINLockDuration: time.Minute, SecurityPINActionTokenTTL: time.Minute, }}) if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil { t.Fatalf("set pin: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"000000","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } } func TestAuthHandler_ChangeSecurityPIN_NotSet(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"123456","new_pin":"654321","confirm_pin":"654321"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } } func TestAuthHandler_ResetSecurityPIN_ValidationBranches(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) t.Run("confirm mismatch", func(t *testing.T) { app := fiber.New() app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN) payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"x","password":"Password123!","new_pin":"123456","confirm_pin":"123455"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("missing token for password method", func(t *testing.T) { app := fiber.New() app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN) payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"password":"Password123!","new_pin":"123456","confirm_pin":"123456"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("missing password", func(t *testing.T) { app := fiber.New() app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN) payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"x","password":"","new_pin":"123456","confirm_pin":"123456"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid pin format", func(t *testing.T) { app := fiber.New() app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN) payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"x","password":"Password123!","new_pin":"12345a","confirm_pin":"12345a"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("microsoft does not require token", func(t *testing.T) { userID := uuidv7.MustBytes() userIDStr, err := uuidv7.BytesToString(userID) if err != nil { t.Fatalf("user id to string: %v", err) } token := "reauth-direct" store.store["auth:microsoft_reauth:"+token] = []byte(userIDStr + "|pin_reset_request") _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) app := fiber.New() app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN) payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_reset_pin","attributes":{"method":"microsoft","reauth_token":"%s","new_pin":"123456","confirm_pin":"123456"}}}`, token)) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) } func TestAuthHandler_SetupSecurityPIN_ErrorBranches(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) t.Run("invalid json", func(t *testing.T) { app := fiber.New() userID := uuidv7.MustBytes() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("service default branch", func(t *testing.T) { userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_setup","attributes":{"pin":"123456","confirm_pin":"123456"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) } func TestAuthHandler_ChangeSecurityPIN_SuccessAndBranches(t *testing.T) { key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key)) t.Run("invalid json", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) _ = svc.SetSecurityPIN(context.Background(), userID, "123456") app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("locked branch", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{ SecurityPINMaxAttempts: 1, SecurityPINLockDuration: time.Minute, }}) h := NewAuthHandler(svc) userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) _ = svc.SetSecurityPIN(context.Background(), userID, "123456") _, _ = svc.VerifySecurityPINForAction(context.Background(), userID, "000000", "428fdf48-3f99-46ea-bc47-8fb2e312be08") app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"123456","new_pin":"654321","confirm_pin":"654321"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusLocked { t.Fatalf("expected 423, got %d", resp.StatusCode) } }) t.Run("success", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) _ = svc.SetSecurityPIN(context.Background(), userID, "123456") app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"123456","new_pin":"654321","confirm_pin":"654321"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) } func TestAuthHandler_VerifySecurityPIN_ErrorBranches(t *testing.T) { t.Run("invalid json", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader([]byte("{bad"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("validation error", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"","action":""}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("service default branch returns 400", func(t *testing.T) { repo := newMemRepo() userID := uuidv7.MustBytes() _ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}) svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{ SecurityPINActionTokenTTL: time.Minute, }}) if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil { t.Fatalf("set pin: %v", err) } h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN) payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"123456","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestAuthHandler_RequestSecurityPINReset_ErrorBranches(t *testing.T) { t.Run("rate limited after successful request", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{ SecurityPINResetURL: "https://app.example.com/reset-pin", SecurityPINResetTTL: 20 * time.Minute, ForgotSecurityPINCooldown: time.Minute, PasswordPepper: "pepper", HashTime: 1, HashMemoryKB: 64 * 1024, HashThreads: 4, HashKeyLen: 32, }}) h := NewAuthHandler(svc) user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", uuidv7.MustBytes()) if err != nil { t.Fatalf("register user: %v", err) } userID := user.ID userIDStr, _ := uuidv7.BytesToString(userID) store.store["auth:security_pin:lock:"+userIDStr] = []byte("1") payload := []byte(`{"data":{"type":"auth_pin_request_reset","attributes":{"password":"Password123!"}}}`) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset) req1, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader(payload)) req1.Header.Set("Content-Type", "application/json") resp1, err := app.Test(req1) if err != nil { t.Fatalf("first app.Test: %v", err) } if resp1.StatusCode != http.StatusOK { t.Fatalf("expected first request 200, got %d", resp1.StatusCode) } req2, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader(payload)) req2.Header.Set("Content-Type", "application/json") resp2, err := app.Test(req2) if err != nil { t.Fatalf("second app.Test: %v", err) } if resp2.StatusCode != http.StatusTooManyRequests { t.Fatalf("expected second request 429, got %d", resp2.StatusCode) } }) t.Run("default branch returns 500", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", []byte("bad")) return c.Next() }) app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader([]byte(`{"data":{"type":"auth_pin_request_reset","attributes":{"password":"Password123!"}}}`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", resp.StatusCode) } }) } func TestAuthHandler_WebAuthnRegisterBegin(t *testing.T) { validPayload := []byte(`{"data":{"type":"auth_webauthn_register_begin"}}`) t.Run("missing auth context", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader(validPayload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("invalid json", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader([]byte(`{"data":`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("validation error", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader([]byte(`{"data":{"type":"wrong"}}`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("not configured", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader(validPayload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("service default branch", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", []byte("short")); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader(validPayload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("success", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader(validPayload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"challenge_token"`)) || !bytes.Contains(body, []byte(`"public_key"`)) { t.Fatalf("unexpected response body: %s", string(body)) } }) } func TestAuthHandler_WebAuthnRegisterFinish(t *testing.T) { validPayload := func(token string, credential []byte, name string) []byte { return []byte(fmt.Sprintf(`{"data":{"type":"auth_webauthn_register_finish","attributes":{"challenge_token":"%s","credential":%s,"name":"%s"}}}`, token, string(credential), name)) } t.Run("missing auth context", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok", []byte(`{}`), ""))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("invalid json", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader([]byte(`{"data":`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("validation error", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish) payload := []byte(`{"data":{"type":"auth_webauthn_register_finish","attributes":{"challenge_token":"","credential":null}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("not configured", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok", []byte(`{}`), ""))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("invalid challenge", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("missing", []byte(testWebAuthnCreationResponseJSON), ""))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("payload invalid", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) putWebAuthnChallenge(t, store, "tok-payload", "registration", userID, webauthnlib.SessionData{ Challenge: testWebAuthnRegistrationChallenge, RelyingPartyID: "webauthn.io", UserID: userID, Expires: time.Now().UTC().Add(time.Minute), CredParams: webauthnlib.CredentialParametersDefault(), }) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok-payload", []byte(`{}`), ""))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("verification failed", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) putWebAuthnChallenge(t, store, "tok-verify", "registration", userID, webauthnlib.SessionData{ Challenge: "invalid-challenge", RelyingPartyID: "webauthn.io", UserID: userID, Expires: time.Now().UTC().Add(time.Minute), CredParams: webauthnlib.CredentialParametersDefault(), }) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok-verify", []byte(testWebAuthnCreationResponseJSON), ""))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("credential exists", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) seedWebAuthnRegistrationExistingCredential(t, repo, userID) putWebAuthnChallenge(t, store, "tok-exists", "registration", userID, webauthnlib.SessionData{ Challenge: testWebAuthnRegistrationChallenge, RelyingPartyID: "webauthn.io", UserID: userID, Expires: time.Now().UTC().Add(time.Minute), CredParams: webauthnlib.CredentialParametersDefault(), }) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok-exists", []byte(testWebAuthnCreationResponseJSON), ""))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } }) t.Run("service default branch", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", []byte("short")); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok", []byte(testWebAuthnCreationResponseJSON), ""))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("success", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) putWebAuthnChallenge(t, store, "tok-success", "registration", userID, webauthnlib.SessionData{ Challenge: testWebAuthnRegistrationChallenge, RelyingPartyID: "webauthn.io", UserID: userID, Expires: time.Now().UTC().Add(time.Minute), CredParams: webauthnlib.CredentialParametersDefault(), }) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() }) app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok-success", []byte(testWebAuthnCreationResponseJSON), "Device A"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"registered":true`)) { t.Fatalf("unexpected response body: %s", string(body)) } }) } func TestAuthHandler_WebAuthnLoginBegin(t *testing.T) { validPayload := func(email string) []byte { return []byte(fmt.Sprintf(`{"data":{"type":"auth_webauthn_login_begin","attributes":{"email":"%s"}}}`, email)) } t.Run("invalid json", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader([]byte(`{"data":`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("validation error", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader([]byte(`{"data":{"type":"auth_webauthn_login_begin","attributes":{"email":"invalid"}}}`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid credentials", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("missing@example.com"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("email not verified", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) seedWebAuthnUser(repo, "user@example.com", false) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("user@example.com"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("credential unavailable", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) seedWebAuthnUser(repo, "user@example.com", true) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("user@example.com"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } }) t.Run("not configured", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}}) h := NewAuthHandler(svc) seedWebAuthnUser(repo, "user@example.com", true) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("user@example.com"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("service default branch", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{ UserID: userID, CredentialID: []byte{1}, CredentialJSON: []byte("{bad"), }} app := fiber.New() app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("user@example.com"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("success", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) seedWebAuthnLoginCredential(t, repo, userID) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("user@example.com"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"challenge_token"`)) || !bytes.Contains(body, []byte(`"public_key"`)) { t.Fatalf("unexpected response body: %s", string(body)) } }) } func TestAuthHandler_WebAuthnLoginFinish(t *testing.T) { validPayload := func(token string, credential []byte) []byte { return []byte(fmt.Sprintf(`{"data":{"type":"auth_webauthn_login_finish","attributes":{"challenge_token":"%s","credential":%s}}}`, token, string(credential))) } t.Run("invalid json", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, true) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader([]byte(`{"data":`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("validation error", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, true) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader([]byte(`{"data":{"type":"auth_webauthn_login_finish","attributes":{"challenge_token":"","credential":null}}}`))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("not configured", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{ JWTAccessSecret: "access", JWTRefreshSecret: "refresh", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour, }}) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok", webAuthnAssertionPayloadForUser(uuidv7.MustBytes())))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("invalid challenge", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, true) h := NewAuthHandler(svc) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("missing", webAuthnAssertionPayloadForUser(uuidv7.MustBytes())))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("payload invalid", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, true) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) seedWebAuthnLoginCredential(t, repo, userID) putWebAuthnChallenge(t, store, "tok-payload", "login", userID, webauthnlib.SessionData{ Challenge: testWebAuthnLoginChallenge, RelyingPartyID: "webauthn.io", UserID: userID, Expires: time.Now().UTC().Add(time.Minute), }) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-payload", []byte(`{}`)))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid credentials", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, true) h := NewAuthHandler(svc) userID := uuidv7.MustBytes() putWebAuthnChallenge(t, store, "tok-invalid-credentials", "login", userID, webauthnlib.SessionData{ Challenge: testWebAuthnLoginChallenge, RelyingPartyID: "webauthn.io", UserID: userID, Expires: time.Now().UTC().Add(time.Minute), }) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-invalid-credentials", webAuthnAssertionPayloadForUser(userID)))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("credential unavailable", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, true) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) putWebAuthnChallenge(t, store, "tok-no-cred", "login", userID, webauthnlib.SessionData{ Challenge: testWebAuthnLoginChallenge, RelyingPartyID: "webauthn.io", UserID: userID, Expires: time.Now().UTC().Add(time.Minute), }) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-no-cred", webAuthnAssertionPayloadForUser(userID)))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } }) t.Run("verification failed", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, true) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) seedWebAuthnLoginCredential(t, repo, userID) putWebAuthnChallenge(t, store, "tok-verify", "login", userID, webauthnlib.SessionData{ Challenge: "wrong-challenge", RelyingPartyID: "webauthn.io", UserID: userID, Expires: time.Now().UTC().Add(time.Minute), }) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-verify", webAuthnAssertionPayloadForUser(userID)))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } }) t.Run("service default branch", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, true) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{ UserID: userID, CredentialID: []byte{1}, CredentialJSON: []byte("{bad"), }} putWebAuthnChallenge(t, store, "tok-default", "login", userID, webauthnlib.SessionData{ Challenge: testWebAuthnLoginChallenge, RelyingPartyID: "webauthn.io", UserID: userID, Expires: time.Now().UTC().Add(time.Minute), }) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-default", webAuthnAssertionPayloadForUser(userID)))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("token issue failed", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, false) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) seedWebAuthnLoginCredential(t, repo, userID) putWebAuthnChallenge(t, store, "tok-token-fail", "login", userID, webauthnlib.SessionData{ Challenge: testWebAuthnLoginChallenge, RelyingPartyID: "webauthn.io", UserID: userID, Expires: time.Now().UTC().Add(time.Minute), }) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-token-fail", webAuthnAssertionPayloadForUser(userID)))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("success", func(t *testing.T) { repo := newMemRepo() store := newMemTokenStore() svc := newWebAuthnAuthService(repo, store, true) h := NewAuthHandler(svc) userID := seedWebAuthnUser(repo, "user@example.com", true) seedWebAuthnLoginCredential(t, repo, userID) putWebAuthnChallenge(t, store, "tok-success", "login", userID, webauthnlib.SessionData{ Challenge: testWebAuthnLoginChallenge, RelyingPartyID: "webauthn.io", UserID: userID, Expires: time.Now().UTC().Add(time.Minute), }) app := fiber.New() app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish) req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-success", webAuthnAssertionPayloadForUser(userID)))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"access_expires_in"`)) { t.Fatalf("unexpected response body: %s", string(body)) } if len(resp.Header.Values("Set-Cookie")) == 0 { t.Fatalf("expected auth cookies to be set") } }) }