Files
fm_be/internal/transport/http/handlers/auth_handler_test.go
2026-07-16 22:16:45 +07:00

6355 lines
227 KiB
Go

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")
}
})
}