6355 lines
227 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|