5618 lines
203 KiB
Go
5618 lines
203 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/pquerna/otp/totp"
|
|
|
|
"wucher/internal/config"
|
|
"wucher/internal/domain/auth"
|
|
"wucher/internal/queue"
|
|
tzpkg "wucher/internal/shared/pkg/timezone"
|
|
"wucher/internal/shared/pkg/uuidv7"
|
|
)
|
|
|
|
type authEmailBrandingResolverMock struct {
|
|
branding AuthEmailBranding
|
|
}
|
|
|
|
func (m authEmailBrandingResolverMock) ResolveEmailBranding(context.Context) AuthEmailBranding {
|
|
return m.branding
|
|
}
|
|
|
|
type mockTokenStore struct {
|
|
store map[string][]byte
|
|
lastKey string
|
|
setErr error
|
|
getErr error
|
|
setCount int
|
|
setDelay time.Duration
|
|
}
|
|
|
|
func newMockTokenStore() *mockTokenStore {
|
|
return &mockTokenStore{store: map[string][]byte{}}
|
|
}
|
|
|
|
func (m *mockTokenStore) Set(_ context.Context, key string, value []byte, _ time.Duration) error {
|
|
if m.setErr != nil {
|
|
return m.setErr
|
|
}
|
|
if m.setDelay > 0 {
|
|
time.Sleep(m.setDelay)
|
|
}
|
|
m.store[key] = value
|
|
m.lastKey = key
|
|
m.setCount++
|
|
return nil
|
|
}
|
|
|
|
func (m *mockTokenStore) SetIfNotExists(_ context.Context, key string, value []byte, _ time.Duration) (bool, error) {
|
|
if m.setErr != nil {
|
|
return false, m.setErr
|
|
}
|
|
if _, ok := m.store[key]; ok {
|
|
return false, nil
|
|
}
|
|
m.store[key] = value
|
|
m.lastKey = key
|
|
m.setCount++
|
|
return true, nil
|
|
}
|
|
|
|
func (m *mockTokenStore) Get(_ context.Context, key string) ([]byte, error) {
|
|
if m.getErr != nil {
|
|
return nil, m.getErr
|
|
}
|
|
v, ok := m.store[key]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (m *mockTokenStore) Delete(_ context.Context, key string) error {
|
|
delete(m.store, key)
|
|
return nil
|
|
}
|
|
|
|
type mockAuthRepo 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
|
|
lastResetToken *auth.PasswordResetToken
|
|
lastSecurityPINResetToken *auth.SecurityPINResetToken
|
|
emailOutboxMessages []*auth.EmailOutboxMessage
|
|
lastUpdateTimezoneUserID []byte
|
|
lastUpdateTimezone string
|
|
createErr error
|
|
getByIDErr error
|
|
getByMailErr error
|
|
updateTimezoneErr error
|
|
setPasswordErr error
|
|
markVerifiedErr error
|
|
createResetTokenErr error
|
|
invalidateResetTokensErr error
|
|
consumeResetErr error
|
|
createPINResetTokenErr error
|
|
invalidatePINResetTokensErr error
|
|
consumePINResetErr error
|
|
deleteTOTPErr error
|
|
deleteWebAuthnErr error
|
|
outboxErr error
|
|
msOAuthTokens map[string]*auth.UserMicrosoftOAuthToken
|
|
}
|
|
|
|
func newMockAuthRepo() *mockAuthRepo {
|
|
return &mockAuthRepo{
|
|
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{},
|
|
msOAuthTokens: map[string]*auth.UserMicrosoftOAuthToken{},
|
|
}
|
|
}
|
|
|
|
func (r *mockAuthRepo) CreateUser(_ context.Context, user *auth.User) error {
|
|
if user == nil {
|
|
return errors.New("nil user")
|
|
}
|
|
if r.createErr != nil {
|
|
return r.createErr
|
|
}
|
|
if !user.IsActive {
|
|
user.IsActive = true
|
|
}
|
|
key := string(user.ID)
|
|
r.users[key] = user
|
|
if user.Email != "" {
|
|
r.usersByMail[user.Email] = user
|
|
}
|
|
if user.Username != nil && strings.TrimSpace(*user.Username) != "" {
|
|
r.usersByUsername[strings.TrimSpace(*user.Username)] = user
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) WithTransaction(ctx context.Context, fn func(repo auth.Repository, outbox auth.EmailOutboxWriter) error) error {
|
|
if fn == nil {
|
|
return nil
|
|
}
|
|
return fn(r, r)
|
|
}
|
|
|
|
func (r *mockAuthRepo) CreateEmailOutboxMessage(_ context.Context, message *auth.EmailOutboxMessage) error {
|
|
if r.outboxErr != nil {
|
|
return r.outboxErr
|
|
}
|
|
if message == nil {
|
|
return errors.New("nil outbox message")
|
|
}
|
|
cp := *message
|
|
cp.ID = append([]byte(nil), message.ID...)
|
|
cp.Body = append([]byte(nil), message.Body...)
|
|
cp.Attributes = append([]byte(nil), message.Attributes...)
|
|
r.emailOutboxMessages = append(r.emailOutboxMessages, &cp)
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) GetUserByID(_ context.Context, id []byte) (*auth.User, error) {
|
|
if r.getByIDErr != nil {
|
|
return nil, r.getByIDErr
|
|
}
|
|
return r.users[string(id)], nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) GetUserByEmail(_ context.Context, email string) (*auth.User, error) {
|
|
if r.getByMailErr != nil {
|
|
return nil, r.getByMailErr
|
|
}
|
|
return r.usersByMail[email], nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) GetUserBySSOEmail(_ context.Context, ssoEmail string) (*auth.User, error) {
|
|
if r.getByMailErr != nil {
|
|
return nil, r.getByMailErr
|
|
}
|
|
return r.usersByMail[ssoEmail], nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) GetUserByUsername(_ context.Context, username string) (*auth.User, error) {
|
|
if r.getByMailErr != nil {
|
|
return nil, r.getByMailErr
|
|
}
|
|
return r.usersByUsername[username], nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) UpdateUserTimezone(_ context.Context, id []byte, timezoneName string) error {
|
|
if r.updateTimezoneErr != nil {
|
|
return r.updateTimezoneErr
|
|
}
|
|
u := r.users[string(id)]
|
|
if u == nil {
|
|
return errors.New("not found")
|
|
}
|
|
u.Timezone = timezoneName
|
|
r.lastUpdateTimezoneUserID = append([]byte(nil), id...)
|
|
r.lastUpdateTimezone = timezoneName
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) SetUserPassword(_ context.Context, id []byte, passwordHash, salt []byte) error {
|
|
if r.setPasswordErr != nil {
|
|
return r.setPasswordErr
|
|
}
|
|
u := r.users[string(id)]
|
|
if u == nil {
|
|
return errors.New("not found")
|
|
}
|
|
u.PasswordHash = passwordHash
|
|
u.SaltValue = salt
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) SetUserSecurityPIN(_ context.Context, id []byte, pinHash []byte, setAt time.Time) error {
|
|
u := r.users[string(id)]
|
|
if u == nil {
|
|
return errors.New("not found")
|
|
}
|
|
u.SecurityPINHash = append([]byte(nil), pinHash...)
|
|
ts := setAt
|
|
u.SecurityPINSetAt = &ts
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) MarkEmailVerified(_ context.Context, id []byte) error {
|
|
if r.markVerifiedErr != nil {
|
|
return r.markVerifiedErr
|
|
}
|
|
u := r.users[string(id)]
|
|
if u == nil {
|
|
return errors.New("not found")
|
|
}
|
|
now := time.Now().UTC()
|
|
u.EmailVerifiedAt = &now
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) CreatePasswordResetToken(_ context.Context, token *auth.PasswordResetToken) error {
|
|
if r.createResetTokenErr != nil {
|
|
return r.createResetTokenErr
|
|
}
|
|
if token == nil {
|
|
return errors.New("nil token")
|
|
}
|
|
cp := *token
|
|
if len(cp.ID) == 0 {
|
|
cp.ID = uuidv7.MustBytes()
|
|
}
|
|
r.resetTokens[string(cp.TokenHash)] = &cp
|
|
r.lastResetToken = &cp
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) InvalidateActivePasswordResetTokensByUserID(_ context.Context, userID []byte, usedAt time.Time) error {
|
|
if r.invalidateResetTokensErr != nil {
|
|
return r.invalidateResetTokensErr
|
|
}
|
|
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 *mockAuthRepo) 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
|
|
}
|
|
if r.setPasswordErr != nil {
|
|
return nil, false, r.setPasswordErr
|
|
}
|
|
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 *mockAuthRepo) CreateSecurityPINResetToken(_ context.Context, token *auth.SecurityPINResetToken) error {
|
|
if r.createPINResetTokenErr != nil {
|
|
return r.createPINResetTokenErr
|
|
}
|
|
if token == nil {
|
|
return errors.New("nil token")
|
|
}
|
|
cp := *token
|
|
if len(cp.ID) == 0 {
|
|
cp.ID = uuidv7.MustBytes()
|
|
}
|
|
r.securityPINResetTokens[string(cp.TokenHash)] = &cp
|
|
r.lastSecurityPINResetToken = &cp
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) InvalidateActiveSecurityPINResetTokensByUserID(_ context.Context, userID []byte, usedAt time.Time) error {
|
|
if r.invalidatePINResetTokensErr != nil {
|
|
return r.invalidatePINResetTokensErr
|
|
}
|
|
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 *mockAuthRepo) 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 *mockAuthRepo) 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...)
|
|
tsSet := usedAt
|
|
u.SecurityPINSetAt = &tsSet
|
|
|
|
ts := usedAt
|
|
rec.UsedAt = &ts
|
|
for _, other := range r.securityPINResetTokens {
|
|
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 *mockAuthRepo) UpsertIdentity(_ context.Context, identity *auth.UserIdentity) error {
|
|
key := identity.Provider + ":" + identity.ProviderSubject
|
|
r.identities[key] = identity
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) CreateIdentity(_ context.Context, identity *auth.UserIdentity) error {
|
|
key := identity.Provider + ":" + identity.ProviderSubject
|
|
r.identities[key] = identity
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) GetIdentityByProviderSubject(_ context.Context, provider, subject string) (*auth.UserIdentity, error) {
|
|
return r.identities[provider+":"+subject], nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) 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 *mockAuthRepo) 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 *mockAuthRepo) GetUserTOTP(_ context.Context, userID []byte) (*auth.UserTOTP, error) {
|
|
return r.totp[string(userID)], nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) UpsertUserTOTP(_ context.Context, totpRec *auth.UserTOTP) error {
|
|
r.totp[string(totpRec.UserID)] = totpRec
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) DisableUserTOTP(_ context.Context, userID []byte) error {
|
|
rec := r.totp[string(userID)]
|
|
if rec == nil {
|
|
return nil
|
|
}
|
|
rec.Enabled = false
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) DeleteUserTOTPSetup(_ context.Context, userID []byte) error {
|
|
if r.deleteTOTPErr != nil {
|
|
return r.deleteTOTPErr
|
|
}
|
|
delete(r.totp, string(userID))
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) UpdateUserTOTPLastUsed(_ context.Context, userID []byte) error {
|
|
rec := r.totp[string(userID)]
|
|
if rec == nil {
|
|
return nil
|
|
}
|
|
now := time.Now().UTC()
|
|
rec.LastUsedAt = &now
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) 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 *mockAuthRepo) 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 *mockAuthRepo) CreateUserEmailLoginOTP(_ context.Context, otp *auth.UserEmailLoginOTP) error {
|
|
if otp == nil {
|
|
return errors.New("nil otp")
|
|
}
|
|
cp := *otp
|
|
if len(cp.ID) == 0 {
|
|
cp.ID = uuidv7.MustBytes()
|
|
}
|
|
if cp.CreatedAt.IsZero() {
|
|
cp.CreatedAt = time.Now().UTC()
|
|
}
|
|
r.emailLoginOTPs[string(cp.UserID)] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) InvalidateActiveUserEmailLoginOTPs(_ context.Context, userID []byte, usedAt time.Time) error {
|
|
rec := r.emailLoginOTPs[string(userID)]
|
|
if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(usedAt) {
|
|
return nil
|
|
}
|
|
ts := usedAt
|
|
rec.UsedAt = &ts
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) 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 *mockAuthRepo) 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 *mockAuthRepo) CreateUserWebAuthnCredential(_ context.Context, credential *auth.UserWebAuthnCredential) error {
|
|
if credential == nil {
|
|
return errors.New("nil credential")
|
|
}
|
|
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 *mockAuthRepo) 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 *mockAuthRepo) DeleteAllUserWebAuthnCredentials(_ context.Context, userID []byte) error {
|
|
if r.deleteWebAuthnErr != nil {
|
|
return r.deleteWebAuthnErr
|
|
}
|
|
delete(r.webauthnCredentials, string(userID))
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) UpsertMicrosoftOAuthToken(_ context.Context, token *auth.UserMicrosoftOAuthToken) error {
|
|
if token == nil {
|
|
return errors.New("nil microsoft oauth token")
|
|
}
|
|
key := string(token.UserID) + ":" + strings.ToLower(strings.TrimSpace(token.Provider))
|
|
cp := *token
|
|
cp.UserID = append([]byte(nil), token.UserID...)
|
|
cp.RefreshTokenEncrypted = append([]byte(nil), token.RefreshTokenEncrypted...)
|
|
cp.AccessTokenEncrypted = append([]byte(nil), token.AccessTokenEncrypted...)
|
|
cp.IDTokenEncrypted = append([]byte(nil), token.IDTokenEncrypted...)
|
|
r.msOAuthTokens[key] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) GetMicrosoftOAuthTokenByUserIDProvider(_ context.Context, userID []byte, provider string) (*auth.UserMicrosoftOAuthToken, error) {
|
|
key := string(userID) + ":" + strings.ToLower(strings.TrimSpace(provider))
|
|
rec := r.msOAuthTokens[key]
|
|
if rec == nil {
|
|
return nil, nil
|
|
}
|
|
cp := *rec
|
|
cp.UserID = append([]byte(nil), rec.UserID...)
|
|
cp.RefreshTokenEncrypted = append([]byte(nil), rec.RefreshTokenEncrypted...)
|
|
cp.AccessTokenEncrypted = append([]byte(nil), rec.AccessTokenEncrypted...)
|
|
cp.IDTokenEncrypted = append([]byte(nil), rec.IDTokenEncrypted...)
|
|
return &cp, nil
|
|
}
|
|
|
|
func (r *mockAuthRepo) DeleteMicrosoftOAuthTokenByUserIDProvider(_ context.Context, userID []byte, provider string) (bool, error) {
|
|
key := string(userID) + ":" + strings.ToLower(strings.TrimSpace(provider))
|
|
if _, ok := r.msOAuthTokens[key]; !ok {
|
|
return false, nil
|
|
}
|
|
delete(r.msOAuthTokens, key)
|
|
return true, nil
|
|
}
|
|
|
|
type mockRoleRepo struct {
|
|
rolesByName map[string]*auth.Role
|
|
rolesByID map[string]*auth.Role
|
|
hasRolePermissionRet bool
|
|
hasRolePermissionByRole map[string]bool
|
|
hasRolePermissionErr error
|
|
permissionByKey map[string]*auth.Permission
|
|
getPermissionByKeyErr error
|
|
updatePermissionErr error
|
|
listPermissionsByRoleIDRet []auth.Permission
|
|
listPermissionsByRole map[string][]auth.Permission
|
|
listPermissionsByRoleIDErr error
|
|
}
|
|
|
|
func newMockRoleRepo() *mockRoleRepo {
|
|
return &mockRoleRepo{
|
|
rolesByName: map[string]*auth.Role{},
|
|
rolesByID: map[string]*auth.Role{},
|
|
hasRolePermissionRet: true,
|
|
hasRolePermissionByRole: map[string]bool{},
|
|
permissionByKey: map[string]*auth.Permission{},
|
|
listPermissionsByRoleIDRet: []auth.Permission{},
|
|
listPermissionsByRole: map[string][]auth.Permission{},
|
|
}
|
|
}
|
|
|
|
func (r *mockRoleRepo) CreateRole(_ context.Context, role *auth.Role) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *mockRoleRepo) UpdateRole(_ context.Context, role *auth.Role) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *mockRoleRepo) DeleteRole(_ context.Context, id []byte) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *mockRoleRepo) GetRoleByID(_ context.Context, id []byte) (*auth.Role, error) {
|
|
return r.rolesByID[string(id)], nil
|
|
}
|
|
|
|
func (r *mockRoleRepo) GetRoleByName(_ context.Context, name string) (*auth.Role, error) {
|
|
return r.rolesByName[name], nil
|
|
}
|
|
|
|
func (r *mockRoleRepo) ListRoles(_ context.Context, _ string, _ string, _ int, _ int) ([]auth.Role, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
func (r *mockRoleRepo) ListPermissions(_ context.Context, _ string, _ string, _ int, _ int) ([]auth.Permission, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
func (r *mockRoleRepo) ListPermissionsByRoleID(_ context.Context, roleID []byte) ([]auth.Permission, error) {
|
|
if r.listPermissionsByRoleIDErr != nil {
|
|
return nil, r.listPermissionsByRoleIDErr
|
|
}
|
|
if perms, ok := r.listPermissionsByRole[string(roleID)]; ok {
|
|
return perms, nil
|
|
}
|
|
return r.listPermissionsByRoleIDRet, nil
|
|
}
|
|
func (r *mockRoleRepo) HasRolePermission(_ context.Context, roleID []byte, _ string) (bool, error) {
|
|
if r.hasRolePermissionErr != nil {
|
|
return false, r.hasRolePermissionErr
|
|
}
|
|
if allowed, ok := r.hasRolePermissionByRole[string(roleID)]; ok {
|
|
return allowed, nil
|
|
}
|
|
return r.hasRolePermissionRet, nil
|
|
}
|
|
func (r *mockRoleRepo) GetPermissionByID(_ context.Context, _ []byte) (*auth.Permission, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *mockRoleRepo) GetPermissionByKey(_ context.Context, key string) (*auth.Permission, error) {
|
|
if r.getPermissionByKeyErr != nil {
|
|
return nil, r.getPermissionByKeyErr
|
|
}
|
|
return r.permissionByKey[key], nil
|
|
}
|
|
func (r *mockRoleRepo) UpdatePermission(_ context.Context, _ *auth.Permission) error {
|
|
return r.updatePermissionErr
|
|
}
|
|
func (r *mockRoleRepo) AssignPermission(_ context.Context, roleID, permissionID []byte) (*auth.RolePermission, error) {
|
|
return &auth.RolePermission{RoleID: roleID, PermissionID: permissionID}, nil
|
|
}
|
|
func (r *mockRoleRepo) RemovePermission(_ context.Context, _ []byte, _ []byte) error { return nil }
|
|
|
|
type mockEmailQueue struct {
|
|
last *EmailJob
|
|
err error
|
|
}
|
|
|
|
func (q *mockEmailQueue) Enqueue(_ context.Context, job EmailJob) error {
|
|
if q.err != nil {
|
|
return q.err
|
|
}
|
|
normalized := job.Canonical()
|
|
normalized.Body = normalized.TextBody
|
|
q.last = &normalized
|
|
return nil
|
|
}
|
|
|
|
func (q *mockEmailQueue) Dequeue(_ context.Context) (*EmailJob, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func queueTestSerializer() queue.MessageSerializer {
|
|
return queue.NewJSONSerializer("v1", true, queue.QueueTypeStandard, "")
|
|
}
|
|
|
|
type errorProtector struct {
|
|
encryptErr error
|
|
decryptErr error
|
|
}
|
|
|
|
func (p *errorProtector) Encrypt(_ []byte) ([]byte, error) {
|
|
if p.encryptErr != nil {
|
|
return nil, p.encryptErr
|
|
}
|
|
return []byte("cipher"), nil
|
|
}
|
|
|
|
func (p *errorProtector) Decrypt(_ []byte) ([]byte, error) {
|
|
if p.decryptErr != nil {
|
|
return nil, p.decryptErr
|
|
}
|
|
return []byte("123456"), nil
|
|
}
|
|
|
|
func TestAuthService_RegisterAndLogin(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
adminID := uuidv7.MustBytes()
|
|
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "admin"}
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
EmailVerifyTTL: time.Hour,
|
|
EmailVerifyURL: "http://localhost/verify",
|
|
SSOStateTTL: time.Minute,
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
DefaultRoleName: "admin",
|
|
}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID)
|
|
if err != nil {
|
|
t.Fatalf("register failed: %v", err)
|
|
}
|
|
if user.RoleID == nil || string(user.RoleID) != string(adminID) {
|
|
t.Fatalf("expected role admin")
|
|
}
|
|
if user.Username == nil || *user.Username != "john.doe" {
|
|
t.Fatalf("expected username john.doe")
|
|
}
|
|
if len(user.PasswordHash) == 0 || len(user.SaltValue) == 0 {
|
|
t.Fatalf("password not hashed")
|
|
}
|
|
if user.Timezone != tzpkg.Default {
|
|
t.Fatalf("expected default timezone %q, got %q", tzpkg.Default, user.Timezone)
|
|
}
|
|
if !user.IsActive {
|
|
t.Fatalf("expected registered user to be active")
|
|
}
|
|
|
|
// not verified yet
|
|
if _, err := svc.LoginWithEmailPassword(context.Background(), "user@example.com", "Password123!"); err != ErrEmailNotVerified {
|
|
t.Fatalf("expected ErrEmailNotVerified")
|
|
}
|
|
|
|
// verify email
|
|
if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
|
|
t.Fatalf("mark verified: %v", err)
|
|
}
|
|
|
|
if _, err := svc.LoginWithEmailPassword(context.Background(), "user@example.com", "Password123!"); err != nil {
|
|
t.Fatalf("login failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_Register_ErrorCases(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
if _, err := svc.RegisterWithEmail(context.Background(), "", "john.doe", "John", "Doe", "", "Password123!", []byte{1}); err == nil {
|
|
t.Fatalf("expected error for empty email")
|
|
}
|
|
if _, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "", []byte{1}); err == nil {
|
|
t.Fatalf("expected error for empty password")
|
|
}
|
|
if _, err := svc.RegisterWithEmail(context.Background(), "user@example.com", " ", "John", "Doe", "", "Password123!", []byte{1}); err == nil {
|
|
t.Fatalf("expected error for empty username")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_InviteUser(t *testing.T) {
|
|
t.Run("success normalizes email and stores invite token", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{
|
|
InviteTTL: time.Minute,
|
|
SetPasswordURL: "http://localhost/set-password",
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
roleID := uuidv7.MustBytes()
|
|
user, err := svc.InviteUser(context.Background(), " Invitee@Example.COM ", "invitee", "John", "Doe", "08123", roleID)
|
|
if err != nil {
|
|
t.Fatalf("invite failed: %v", err)
|
|
}
|
|
if user == nil {
|
|
t.Fatalf("expected user")
|
|
}
|
|
if user.Email != "invitee@example.com" {
|
|
t.Fatalf("expected normalized email, got %q", user.Email)
|
|
}
|
|
if user.Username == nil || *user.Username != "invitee" {
|
|
t.Fatalf("expected username invitee")
|
|
}
|
|
if len(user.PasswordHash) != 0 || len(user.SaltValue) != 0 {
|
|
t.Fatalf("invite should not set password hash/salt")
|
|
}
|
|
if string(user.RoleID) != string(roleID) {
|
|
t.Fatalf("unexpected role id")
|
|
}
|
|
if user.Timezone != tzpkg.Default {
|
|
t.Fatalf("expected default timezone %q, got %q", tzpkg.Default, user.Timezone)
|
|
}
|
|
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:invite:") {
|
|
t.Fatalf("expected invite token stored, got key=%q", tokens.lastKey)
|
|
}
|
|
if string(tokens.store[tokens.lastKey]) != string(user.ID) {
|
|
t.Fatalf("token payload should contain invited user id")
|
|
}
|
|
if repo.usersByMail["invitee@example.com"] == nil {
|
|
t.Fatalf("user should be persisted by normalized email")
|
|
}
|
|
})
|
|
|
|
t.Run("empty email", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
if _, err := svc.InviteUser(context.Background(), " ", "invitee", "John", "Doe", "", uuidv7.MustBytes()); err == nil {
|
|
t.Fatalf("expected error for empty email")
|
|
}
|
|
})
|
|
|
|
t.Run("get by email error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.getByMailErr = errors.New("lookup failed")
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
_, err := svc.InviteUser(context.Background(), "invitee@example.com", "invitee", "John", "Doe", "", uuidv7.MustBytes())
|
|
if !errors.Is(err, repo.getByMailErr) {
|
|
t.Fatalf("expected get by email error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("email already registered", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.usersByMail["invitee@example.com"] = &auth.User{ID: uuidv7.MustBytes(), Email: "invitee@example.com"}
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
if _, err := svc.InviteUser(context.Background(), "INVITEE@EXAMPLE.COM", "invitee", "John", "Doe", "", uuidv7.MustBytes()); err == nil {
|
|
t.Fatalf("expected duplicate email error")
|
|
}
|
|
if len(repo.users) != 0 {
|
|
t.Fatalf("create user should not be called for duplicate email")
|
|
}
|
|
})
|
|
|
|
t.Run("username already registered", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
username := "invitee"
|
|
repo.usersByUsername[username] = &auth.User{ID: uuidv7.MustBytes(), Username: &username}
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
if _, err := svc.InviteUser(context.Background(), "invitee@example.com", username, "John", "Doe", "", uuidv7.MustBytes()); err == nil {
|
|
t.Fatalf("expected duplicate username error")
|
|
}
|
|
})
|
|
|
|
t.Run("create user error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.createErr = errors.New("db error")
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
_, err := svc.InviteUser(context.Background(), "invitee@example.com", "invitee", "John", "Doe", "", uuidv7.MustBytes())
|
|
if !errors.Is(err, repo.createErr) {
|
|
t.Fatalf("expected create user error, got %v", err)
|
|
}
|
|
if tokens.lastKey != "" {
|
|
t.Fatalf("invite token should not be stored when create fails")
|
|
}
|
|
})
|
|
|
|
t.Run("invite token storage error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
tokens.setErr = errors.New("token store error")
|
|
cfg := config.AuthConfig{
|
|
InviteTTL: time.Minute,
|
|
SetPasswordURL: "http://localhost/set-password",
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
_, err := svc.InviteUser(context.Background(), "invitee@example.com", "invitee", "John", "Doe", "", uuidv7.MustBytes())
|
|
if !errors.Is(err, tokens.setErr) {
|
|
t.Fatalf("expected token store error, got %v", err)
|
|
}
|
|
if repo.usersByMail["invitee@example.com"] != nil {
|
|
t.Fatalf("user should not be created when invite token generation fails")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_UpdateUserTimezone(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
user := &auth.User{
|
|
ID: uuidv7.MustBytes(),
|
|
Email: "user@example.com",
|
|
Timezone: tzpkg.Default,
|
|
}
|
|
repo.users[string(user.ID)] = user
|
|
repo.usersByMail[user.Email] = user
|
|
|
|
t.Run("success normalizes timezone", func(t *testing.T) {
|
|
got, err := svc.UpdateUserTimezone(context.Background(), user.ID, "Asia/Jakarta")
|
|
if err != nil {
|
|
t.Fatalf("UpdateUserTimezone failed: %v", err)
|
|
}
|
|
if got != "Asia/Jakarta" {
|
|
t.Fatalf("unexpected normalized timezone: %q", got)
|
|
}
|
|
if user.Timezone != "Asia/Jakarta" {
|
|
t.Fatalf("expected timezone updated on user, got %q", user.Timezone)
|
|
}
|
|
})
|
|
|
|
t.Run("reject invalid timezone", func(t *testing.T) {
|
|
_, err := svc.UpdateUserTimezone(context.Background(), user.ID, "UTC+7")
|
|
if !errors.Is(err, tzpkg.ErrInvalid) {
|
|
t.Fatalf("expected invalid timezone error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("reject empty timezone", func(t *testing.T) {
|
|
_, err := svc.UpdateUserTimezone(context.Background(), user.ID, " ")
|
|
if !errors.Is(err, tzpkg.ErrEmpty) {
|
|
t.Fatalf("expected empty timezone error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("reject empty user id", func(t *testing.T) {
|
|
_, err := svc.UpdateUserTimezone(context.Background(), nil, "Asia/Jakarta")
|
|
if err == nil {
|
|
t.Fatalf("expected error for empty user id")
|
|
}
|
|
})
|
|
|
|
t.Run("repo update timezone error", func(t *testing.T) {
|
|
repo.updateTimezoneErr = errors.New("update failed")
|
|
_, err := svc.UpdateUserTimezone(context.Background(), user.ID, "Asia/Jakarta")
|
|
if !errors.Is(err, repo.updateTimezoneErr) {
|
|
t.Fatalf("expected update timezone error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_SetPasswordWithInviteToken(t *testing.T) {
|
|
baseCfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
}
|
|
|
|
t.Run("success sets password verifies email and consumes token", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "invitee@example.com"}
|
|
repo.users[string(user.ID)] = user
|
|
token := "invite-token"
|
|
key := inviteKey(token)
|
|
tokens.store[key] = user.ID
|
|
|
|
if err := svc.SetPasswordWithInviteToken(context.Background(), token, "Password123!"); err != nil {
|
|
t.Fatalf("set password failed: %v", err)
|
|
}
|
|
if len(user.PasswordHash) == 0 || len(user.SaltValue) == 0 {
|
|
t.Fatalf("expected password hash and salt to be stored")
|
|
}
|
|
if user.EmailVerifiedAt == nil {
|
|
t.Fatalf("expected invited user email to be marked verified")
|
|
}
|
|
if _, ok := tokens.store[key]; ok {
|
|
t.Fatalf("invite token should be deleted after success")
|
|
}
|
|
})
|
|
|
|
t.Run("token and password are required", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.SetPasswordWithInviteToken(context.Background(), "", "Password123!"); err == nil {
|
|
t.Fatalf("expected error for empty token")
|
|
}
|
|
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", ""); err == nil {
|
|
t.Fatalf("expected error for empty password")
|
|
}
|
|
})
|
|
|
|
t.Run("token store not configured", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", "Password123!"); err != ErrInvalidToken {
|
|
t.Fatalf("expected ErrInvalidToken, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid invite token from store error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
tokens.getErr = errors.New("token store get failed")
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", "Password123!"); err != ErrInvalidToken {
|
|
t.Fatalf("expected ErrInvalidToken, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid invite token payload length", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
tokens.store[inviteKey("token")] = []byte("short")
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", "Password123!"); err != ErrInvalidToken {
|
|
t.Fatalf("expected ErrInvalidToken, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("set password repo error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
tokens.store[inviteKey("token")] = uuidv7.MustBytes()
|
|
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", "Password123!"); err == nil {
|
|
t.Fatalf("expected set password error")
|
|
}
|
|
})
|
|
|
|
t.Run("mark email verified error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.markVerifiedErr = errors.New("mark verify failed")
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "invitee@example.com"}
|
|
repo.users[string(user.ID)] = user
|
|
key := inviteKey("token")
|
|
tokens.store[key] = user.ID
|
|
|
|
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", "Password123!"); !errors.Is(err, repo.markVerifiedErr) {
|
|
t.Fatalf("expected mark verify error, got %v", err)
|
|
}
|
|
if _, ok := tokens.store[key]; !ok {
|
|
t.Fatalf("token should remain when mark email verified fails")
|
|
}
|
|
})
|
|
|
|
t.Run("jwt invite success", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := baseCfg
|
|
cfg.JWTAccessSecret = "access"
|
|
cfg.InviteTTL = time.Minute
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "invitee@example.com"}
|
|
repo.users[string(user.ID)] = user
|
|
repo.usersByMail[user.Email] = user
|
|
|
|
token, jti, err := svc.issueInviteJWT(user)
|
|
if err != nil {
|
|
t.Fatalf("issue invite jwt: %v", err)
|
|
}
|
|
tokens.store[inviteKey(jti)] = user.ID
|
|
|
|
if err := svc.SetPasswordWithInviteToken(context.Background(), token, "Password123!"); err != nil {
|
|
t.Fatalf("set password with jwt invite failed: %v", err)
|
|
}
|
|
if len(user.PasswordHash) == 0 || user.EmailVerifiedAt == nil {
|
|
t.Fatalf("expected password and email verification updated")
|
|
}
|
|
if _, ok := tokens.store[inviteKey(jti)]; ok {
|
|
t.Fatalf("jwt invite token should be deleted after success")
|
|
}
|
|
})
|
|
|
|
t.Run("jwt invite token store mismatch", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := baseCfg
|
|
cfg.JWTAccessSecret = "access"
|
|
cfg.InviteTTL = time.Minute
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "invitee@example.com"}
|
|
repo.users[string(user.ID)] = user
|
|
token, jti, err := svc.issueInviteJWT(user)
|
|
if err != nil {
|
|
t.Fatalf("issue invite jwt: %v", err)
|
|
}
|
|
tokens.store[inviteKey(jti)] = uuidv7.MustBytes()
|
|
|
|
if err := svc.SetPasswordWithInviteToken(context.Background(), token, "Password123!"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token on jwt store mismatch, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthServiceDurationGetters(t *testing.T) {
|
|
cfg := config.AuthConfig{
|
|
JWTRefreshTOTPTTL: 2 * time.Hour,
|
|
SecurityPINActionTokenTTL: 15 * time.Minute,
|
|
}
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if svc.RefreshTOTPTTL() != 2*time.Hour {
|
|
t.Fatalf("expected RefreshTOTPTTL getter to return configured ttl")
|
|
}
|
|
if svc.SecurityPINTokenTTL() != 15*time.Minute {
|
|
t.Fatalf("expected SecurityPINTokenTTL getter to return configured ttl")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_GetRoleIDByName_NotFound(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if _, err := svc.GetRoleIDByName(context.Background(), "missing"); err == nil {
|
|
t.Fatalf("expected error when role not found")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_Login_InvalidPassword(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
roleID := uuidv7.MustBytes()
|
|
roles.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
DefaultRoleName: "admin",
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
|
|
repo.MarkEmailVerified(context.Background(), user.ID)
|
|
|
|
if _, err := svc.LoginWithEmailPassword(context.Background(), "user@example.com", "WrongPass"); err != ErrInvalidCredentials {
|
|
t.Fatalf("expected invalid credentials")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_IssueTokens_ConfigMissing(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
repo.CreateUser(context.Background(), user)
|
|
if _, err := svc.IssueTokens(context.Background(), user); err == nil {
|
|
t.Fatalf("expected error without jwt secrets")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_Register_CreateUserError(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.createErr = errors.New("db error")
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
adminID := uuidv7.MustBytes()
|
|
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "admin"}
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
DefaultRoleName: "admin",
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if _, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID); err == nil {
|
|
t.Fatalf("expected create user error")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_Login_UserNotFound(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if _, err := svc.LoginWithEmailPassword(context.Background(), "missing@example.com", "pass"); err != ErrInvalidCredentials {
|
|
t.Fatalf("expected invalid credentials")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_Login_InactiveUser(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
user := &auth.User{
|
|
ID: uuidv7.MustBytes(),
|
|
Email: "inactive@example.com",
|
|
FirstName: "Jane",
|
|
LastName: "Doe",
|
|
RoleID: uuidv7.MustBytes(),
|
|
IsActive: true,
|
|
}
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
hash, salt, err := svc.hashPassword("Password123!")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
user.PasswordHash = hash
|
|
user.SaltValue = salt
|
|
now := time.Now().UTC()
|
|
user.EmailVerifiedAt = &now
|
|
user.IsActive = false
|
|
|
|
if _, err := svc.LoginWithEmailPassword(context.Background(), user.Email, "Password123!"); err != ErrInvalidCredentials {
|
|
t.Fatalf("expected invalid credentials for inactive user, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ParseAccessToken_Invalid(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{JWTAccessSecret: "access"}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
if _, err := svc.ParseAccessToken(context.Background(), "bad"); err == nil {
|
|
t.Fatalf("expected invalid token error")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ParseAccessToken_WrongType(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
repo.CreateUser(context.Background(), user)
|
|
pair, _ := svc.IssueTokens(context.Background(), user)
|
|
|
|
if _, err := svc.ParseAccessToken(context.Background(), pair.RefreshToken); err == nil {
|
|
t.Fatalf("expected invalid token type")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ParseAccessToken_Empty(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{JWTAccessSecret: "access"}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if _, err := svc.ParseAccessToken(context.Background(), ""); err == nil {
|
|
t.Fatalf("expected error for empty token")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_CreateConsumeTOTPChallenge_Invalid(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if _, err := svc.ConsumeTOTPChallenge(context.Background(), "missing"); err == nil {
|
|
t.Fatalf("expected invalid challenge")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_CreateTOTPChallenge_TokenStoreError(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
tokens.setErr = errors.New("store error")
|
|
cfg := config.AuthConfig{TOTPChallengeTTL: time.Minute}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if _, err := svc.CreateTOTPChallenge(context.Background(), uuidv7.MustBytes()); err == nil {
|
|
t.Fatalf("expected token store error")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_IsTOTPEnabled_NoRecord(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
enabled, err := svc.IsTOTPEnabled(context.Background(), uuidv7.MustBytes())
|
|
if err != nil || enabled {
|
|
t.Fatalf("expected disabled without error")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_VerifyEmail_InvalidToken(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svc.VerifyEmail(context.Background(), "bad"); err == nil {
|
|
t.Fatalf("expected invalid token")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_VerifyEmail_NoTokenStore(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
cfg := config.AuthConfig{}
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if err := svc.VerifyEmail(context.Background(), "token"); err == nil {
|
|
t.Fatalf("expected invalid token without store")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_VerifyEmail_EmptyToken(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svc.VerifyEmail(context.Background(), ""); err == nil {
|
|
t.Fatalf("expected invalid token")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_SSOConfigMissing(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if _, err := svc.BeginMicrosoftLogin(context.Background()); err == nil {
|
|
t.Fatalf("expected sso not configured")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_BeginMicrosoftLogin_NoTokenStore(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
|
|
|
|
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
|
|
if _, err := svc.BeginMicrosoftLogin(context.Background()); err == nil {
|
|
t.Fatalf("expected error without token store")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_CompleteMicrosoftLogin_SSOServerError(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
sso := &mockSSO{err: errors.New("exchange failed")}
|
|
|
|
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
|
|
state := "state1"
|
|
_ = tokens.Set(context.Background(), "auth:sso_state:"+state, []byte("1"), time.Minute)
|
|
if _, _, err := svc.CompleteMicrosoftLogin(context.Background(), "code", state); err == nil {
|
|
t.Fatalf("expected exchange error")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_CompleteMicrosoftLogin_ConsumesStateOnFailure(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
|
|
|
|
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
|
|
state := "state1"
|
|
_ = tokens.Set(context.Background(), "auth:sso_state:"+state, []byte("1"), time.Minute)
|
|
if _, _, err := svc.CompleteMicrosoftLogin(context.Background(), "code", state); !errors.Is(err, ErrSSONotLinked) {
|
|
t.Fatalf("expected ErrSSONotLinked, got %v", err)
|
|
}
|
|
if _, ok := tokens.store["auth:sso_state:"+state]; ok {
|
|
t.Fatalf("expected state token to be consumed on failure")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LoginWithMicrosoft_EmailNotFound(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
|
|
if _, _, err := svc.LoginWithMicrosoft(context.Background(), "code"); !errors.Is(err, ErrSSONotLinked) {
|
|
t.Fatalf("expected ErrSSONotLinked, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_BeginMicrosoftLogin_TokenStoreError(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
tokens.setErr = errors.New("store error")
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
|
|
|
|
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
|
|
if _, err := svc.BeginMicrosoftLogin(context.Background()); err == nil {
|
|
t.Fatalf("expected token store error")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_BeginMicrosoftSessionCheck(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
sso := &mockSSO{authURL: "http://example.com?state=%s&prompt=select_account"}
|
|
|
|
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
|
|
urlStr, err := svc.BeginMicrosoftSessionCheck(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("begin microsoft session check: %v", err)
|
|
}
|
|
if !strings.Contains(urlStr, "prompt=none") {
|
|
t.Fatalf("expected prompt=none in url, got %s", urlStr)
|
|
}
|
|
if !strings.HasPrefix(tokens.lastKey, "auth:sso_state:") {
|
|
t.Fatalf("expected sso state stored, got key %s", tokens.lastKey)
|
|
}
|
|
state := strings.TrimPrefix(tokens.lastKey, "auth:sso_state:")
|
|
if state == "" {
|
|
t.Fatalf("expected state in key")
|
|
}
|
|
mode, err := svc.MicrosoftStateMode(context.Background(), state)
|
|
if err != nil {
|
|
t.Fatalf("microsoft state mode: %v", err)
|
|
}
|
|
if mode != "login_check" {
|
|
t.Fatalf("expected login_check mode, got %s", mode)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_BeginMicrosoftLogin_UsesPromptSelectAccount(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
sso := &mockSSO{authURL: "http://example.com?state=%s&prompt=select_account"}
|
|
|
|
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
|
|
urlStr, err := svc.BeginMicrosoftLogin(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("begin microsoft login: %v", err)
|
|
}
|
|
if !strings.Contains(urlStr, "prompt=select_account") {
|
|
t.Fatalf("expected prompt=select_account in url, got %s", urlStr)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_CompleteMicrosoftLogin_StateNotFound(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
|
|
|
|
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
|
|
if _, _, err := svc.CompleteMicrosoftLogin(context.Background(), "code", "missing"); !errors.Is(err, ErrInvalidSSOState) {
|
|
t.Fatalf("expected ErrInvalidSSOState, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_CompleteMicrosoftLogin_InvalidState(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
|
|
|
|
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
|
|
if _, _, err := svc.CompleteMicrosoftLogin(context.Background(), "code", ""); !errors.Is(err, ErrInvalidSSOState) {
|
|
t.Fatalf("expected ErrInvalidSSOState, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LoginWithMicrosoft_ExistingIdentity(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
roleID := uuidv7.MustBytes()
|
|
existing := &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: existing.UserID,
|
|
Email: "user@example.com",
|
|
RoleID: roleID,
|
|
Timezone: tzpkg.WithDefault(""),
|
|
}); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
repo.UpsertIdentity(context.Background(), existing)
|
|
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
identity, _, err := svc.LoginWithMicrosoft(context.Background(), "code")
|
|
if err != nil || identity == nil {
|
|
t.Fatalf("expected existing identity")
|
|
}
|
|
if string(identity.UserID) != string(existing.UserID) {
|
|
t.Fatalf("should return existing identity")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LoginWithMicrosoft_MatchesUserByEmailAndCreatesIdentity(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
roleID := uuidv7.MustBytes()
|
|
userID := uuidv7.MustBytes()
|
|
if err := repo.CreateUser(context.Background(), &auth.User{
|
|
ID: userID,
|
|
Email: "marco@micro.com",
|
|
RoleID: roleID,
|
|
Timezone: tzpkg.WithDefault(""),
|
|
}); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub-new", Email: "marco@micro.com", Name: "Marco"}}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
identity, _, err := svc.LoginWithMicrosoft(context.Background(), "code")
|
|
if err != nil || identity == nil {
|
|
t.Fatalf("expected identity, got err=%v", err)
|
|
}
|
|
if string(identity.UserID) != string(userID) {
|
|
t.Fatalf("expected login matched by email to existing user")
|
|
}
|
|
saved, _ := repo.GetIdentityByProviderSubject(context.Background(), "microsoft", "sub-new")
|
|
if saved == nil || string(saved.UserID) != string(userID) {
|
|
t.Fatalf("expected identity to be upserted")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LoginWithMicrosoft_RejectsSubjectLinkedToAnotherUser(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
roleID := uuidv7.MustBytes()
|
|
userID := uuidv7.MustBytes()
|
|
otherUserID := uuidv7.MustBytes()
|
|
_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "marco@micro.com", RoleID: roleID, Timezone: tzpkg.WithDefault("")})
|
|
_ = repo.CreateUser(context.Background(), &auth.User{ID: otherUserID, Email: "other@micro.com", RoleID: roleID, Timezone: tzpkg.WithDefault("")})
|
|
_ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{
|
|
ID: uuidv7.MustBytes(),
|
|
UserID: otherUserID,
|
|
Provider: "microsoft",
|
|
ProviderSubject: "sub-conflict",
|
|
Email: "other@micro.com",
|
|
})
|
|
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub-conflict", Email: "marco@micro.com", Name: "Marco"}}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
if _, _, err := svc.LoginWithMicrosoft(context.Background(), "code"); !errors.Is(err, ErrSSOLinkedToAnotherUser) {
|
|
t.Fatalf("expected ErrSSOLinkedToAnotherUser, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_SSORedirectGetter(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{SSOSuccessRedirectURL: "http://localhost/cb"}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if svc.SSOSuccessRedirectURL() == "" {
|
|
t.Fatalf("expected redirect url")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_MicrosoftLogoutURL(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{SSOSuccessRedirectURL: "http://localhost/after-logout"}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: &mockSSO{}, Protector: nil, Config: cfg})
|
|
got := svc.MicrosoftLogoutURL()
|
|
if !strings.Contains(got, "http://example.com/logout") || !strings.Contains(got, "post_logout_redirect_uri=") {
|
|
t.Fatalf("unexpected microsoft logout url: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ConfirmTOTP_InvalidCode(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
key := make([]byte, 32)
|
|
for i := range key {
|
|
key[i] = byte(i + 1)
|
|
}
|
|
protector, _ := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
|
|
|
|
cfg := config.AuthConfig{TOTPIssuer: "Wucher"}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
|
|
|
|
userID := uuidv7.MustBytes()
|
|
_, _, _, _ = svc.SetupTOTP(context.Background(), userID, "user@example.com")
|
|
ok, err := svc.ConfirmTOTP(context.Background(), userID, "000000")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if ok {
|
|
t.Fatalf("expected confirm false")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ConfirmTOTP_NoRecord(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if ok, err := svc.ConfirmTOTP(context.Background(), uuidv7.MustBytes(), "123456"); err == nil || ok {
|
|
t.Fatalf("expected error when record missing")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_SetupTOTP_NoProtector(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if _, _, _, err := svc.SetupTOTP(context.Background(), uuidv7.MustBytes(), "user@example.com"); err == nil {
|
|
t.Fatalf("expected error without protector")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_EmailQueuePath(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
queue := &mockEmailQueue{}
|
|
|
|
adminID := uuidv7.MustBytes()
|
|
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "admin"}
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
EmailVerifyURL: "http://localhost/verify",
|
|
EmailVerifyTTL: time.Minute,
|
|
DefaultRoleName: "admin",
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
_, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID)
|
|
if err != nil {
|
|
t.Fatalf("register failed: %v", err)
|
|
}
|
|
if queue.last == nil {
|
|
t.Fatalf("expected email queued")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RegisterWithEmail_OutboxPath(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
queue := &mockEmailQueue{err: errors.New("queue failed")}
|
|
|
|
adminID := uuidv7.MustBytes()
|
|
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "admin"}
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
EmailVerifyURL: "http://localhost/verify",
|
|
EmailVerifyTTL: time.Minute,
|
|
DefaultRoleName: "admin",
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
svc.SetEmailOutbox(repo, queueTestSerializer())
|
|
|
|
user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID)
|
|
if err != nil {
|
|
t.Fatalf("register failed: %v", err)
|
|
}
|
|
if user == nil {
|
|
t.Fatalf("expected user")
|
|
}
|
|
if queue.last != nil {
|
|
t.Fatalf("direct queue should not be used when outbox is configured")
|
|
}
|
|
if len(repo.emailOutboxMessages) != 1 {
|
|
t.Fatalf("expected one outbox message, got %d", len(repo.emailOutboxMessages))
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LegacyConstructorWithoutQueue(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
adminID := uuidv7.MustBytes()
|
|
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "admin"}
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
EmailVerifyURL: "http://localhost/verify",
|
|
EmailVerifyTTL: time.Minute,
|
|
DefaultRoleName: "admin",
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID)
|
|
if err != nil {
|
|
t.Fatalf("register failed: %v", err)
|
|
}
|
|
if user == nil {
|
|
t.Fatalf("expected registered user")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_GetRoleByID_NoRepo(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, nil, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if _, err := svc.GetRoleByID(context.Background(), uuidv7.MustBytes()); err == nil {
|
|
t.Fatalf("expected error when role repo nil")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_HasPermission(t *testing.T) {
|
|
t.Run("auth repo not configured", func(t *testing.T) {
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(nil, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if allowed, err := svc.HasPermission(context.Background(), uuidv7.MustBytes(), "users.read"); err == nil || allowed {
|
|
t.Fatalf("expected auth repo not configured error")
|
|
}
|
|
})
|
|
|
|
t.Run("role repo not configured", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, nil, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if allowed, err := svc.HasPermission(context.Background(), uuidv7.MustBytes(), "users.read"); err == nil || allowed {
|
|
t.Fatalf("expected role repo not configured error")
|
|
}
|
|
})
|
|
|
|
t.Run("get user error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.getByIDErr = errors.New("db error")
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
_, err := svc.HasPermission(context.Background(), uuidv7.MustBytes(), "users.read")
|
|
if !errors.Is(err, repo.getByIDErr) {
|
|
t.Fatalf("expected get user error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("user not found", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
roles.hasRolePermissionErr = errors.New("should not be called")
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
allowed, err := svc.HasPermission(context.Background(), uuidv7.MustBytes(), "users.read")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if allowed {
|
|
t.Fatalf("expected no permission when user not found")
|
|
}
|
|
})
|
|
|
|
t.Run("delegates to role repo", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
roleID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID}
|
|
|
|
roles := newMockRoleRepo()
|
|
roles.hasRolePermissionRet = true
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
allowed, err := svc.HasPermission(context.Background(), userID, "users.read")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !allowed {
|
|
t.Fatalf("expected permission allowed")
|
|
}
|
|
})
|
|
|
|
t.Run("role repo returns error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
roleID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID}
|
|
|
|
roles := newMockRoleRepo()
|
|
roles.hasRolePermissionErr = errors.New("role repo error")
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
if _, err := svc.HasPermission(context.Background(), userID, "users.read"); !errors.Is(err, roles.hasRolePermissionErr) {
|
|
t.Fatalf("expected role repo error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("allows permission from additional role", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
primaryRoleID := uuidv7.MustBytes()
|
|
extraRoleID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{
|
|
ID: userID,
|
|
RoleID: primaryRoleID,
|
|
RoleIDs: [][]byte{primaryRoleID, extraRoleID},
|
|
}
|
|
|
|
roles := newMockRoleRepo()
|
|
roles.hasRolePermissionByRole[string(primaryRoleID)] = false
|
|
roles.hasRolePermissionByRole[string(extraRoleID)] = true
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
allowed, err := svc.HasPermission(context.Background(), userID, "users.read")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !allowed {
|
|
t.Fatalf("expected permission allowed from additional role")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_GetPermissionsByRoleID(t *testing.T) {
|
|
t.Run("role repo not configured", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, nil, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if _, err := svc.GetPermissionsByRoleID(context.Background(), uuidv7.MustBytes()); err == nil {
|
|
t.Fatalf("expected role repo not configured error")
|
|
}
|
|
})
|
|
|
|
t.Run("success", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
roles.listPermissionsByRoleIDRet = []auth.Permission{
|
|
{
|
|
ID: uuidv7.MustBytes(),
|
|
Name: "Users Read",
|
|
Key: "users.read",
|
|
},
|
|
}
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
perms, err := svc.GetPermissionsByRoleID(context.Background(), uuidv7.MustBytes())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(perms) != 1 || perms[0].Key != "users.read" {
|
|
t.Fatalf("unexpected permissions result: %+v", perms)
|
|
}
|
|
})
|
|
|
|
t.Run("role repo error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
roles.listPermissionsByRoleIDErr = errors.New("list permissions error")
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
if _, err := svc.GetPermissionsByRoleID(context.Background(), uuidv7.MustBytes()); !errors.Is(err, roles.listPermissionsByRoleIDErr) {
|
|
t.Fatalf("expected list permissions error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_GetPermissionsByUserID(t *testing.T) {
|
|
t.Run("auth repo not configured", func(t *testing.T) {
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(nil, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if _, err := svc.GetPermissionsByUserID(context.Background(), uuidv7.MustBytes()); err == nil {
|
|
t.Fatalf("expected auth repo not configured error")
|
|
}
|
|
})
|
|
|
|
t.Run("role repo not configured", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, nil, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if _, err := svc.GetPermissionsByUserID(context.Background(), uuidv7.MustBytes()); err == nil {
|
|
t.Fatalf("expected role repo not configured error")
|
|
}
|
|
})
|
|
|
|
t.Run("deduplicates permissions across roles", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
primaryRoleID := uuidv7.MustBytes()
|
|
extraRoleID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{
|
|
ID: userID,
|
|
RoleID: primaryRoleID,
|
|
RoleIDs: [][]byte{primaryRoleID, extraRoleID},
|
|
}
|
|
|
|
readPerm := auth.Permission{ID: uuidv7.MustBytes(), Name: "Read User", Key: "user.read"}
|
|
updatePerm := auth.Permission{ID: uuidv7.MustBytes(), Name: "Update User", Key: "user.update"}
|
|
|
|
roles := newMockRoleRepo()
|
|
roles.listPermissionsByRole[string(primaryRoleID)] = []auth.Permission{readPerm}
|
|
roles.listPermissionsByRole[string(extraRoleID)] = []auth.Permission{readPerm, updatePerm}
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
perms, err := svc.GetPermissionsByUserID(context.Background(), userID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(perms) != 2 {
|
|
t.Fatalf("expected 2 merged permissions, got %d", len(perms))
|
|
}
|
|
if perms[0].Key != "user.read" || perms[1].Key != "user.update" {
|
|
t.Fatalf("unexpected permissions order/content: %+v", perms)
|
|
}
|
|
})
|
|
|
|
t.Run("role repo error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
roleID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID, RoleIDs: [][]byte{roleID}}
|
|
|
|
roles := newMockRoleRepo()
|
|
roles.listPermissionsByRoleIDErr = errors.New("list permissions error")
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
if _, err := svc.GetPermissionsByUserID(context.Background(), userID); !errors.Is(err, roles.listPermissionsByRoleIDErr) {
|
|
t.Fatalf("expected list permissions error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_CreateChallenge_NoTokenStore(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if _, err := svc.CreateTOTPChallenge(context.Background(), uuidv7.MustBytes()); err == nil {
|
|
t.Fatalf("expected error when token store nil")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_SplitName(t *testing.T) {
|
|
if a, b := splitName(""); a != "User" || b != "" {
|
|
t.Fatalf("unexpected split for empty")
|
|
}
|
|
if a, b := splitName("John"); a != "John" || b != "" {
|
|
t.Fatalf("unexpected split for single")
|
|
}
|
|
if a, b := splitName("John Doe"); a != "John" || b != "Doe" {
|
|
t.Fatalf("unexpected split for two")
|
|
}
|
|
}
|
|
func TestAuthService_TOTPFlow(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
key := make([]byte, 32)
|
|
for i := range key {
|
|
key[i] = byte(i + 1)
|
|
}
|
|
protector, err := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
|
|
if err != nil {
|
|
t.Fatalf("protector: %v", err)
|
|
}
|
|
|
|
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",
|
|
}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
|
|
|
|
userID := uuidv7.MustBytes()
|
|
secret, _, _, err := svc.SetupTOTP(context.Background(), userID, "user@example.com")
|
|
if err != nil {
|
|
t.Fatalf("setup totp: %v", err)
|
|
}
|
|
|
|
code, err := totp.GenerateCode(secret, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("generate code: %v", err)
|
|
}
|
|
ok, err := svc.ConfirmTOTP(context.Background(), userID, code)
|
|
if err != nil || !ok {
|
|
t.Fatalf("confirm totp failed")
|
|
}
|
|
|
|
enabled, _ := svc.IsTOTPEnabled(context.Background(), userID)
|
|
if !enabled {
|
|
t.Fatalf("expected totp enabled")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_TOTPChallenge_InvalidEmpty(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if _, err := svc.ConsumeTOTPChallenge(context.Background(), ""); err == nil {
|
|
t.Fatalf("expected error for empty challenge")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_TOTPChallenge_RetryAndAttemptLimit(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{TOTPChallengeTTL: time.Minute}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
userID := uuidv7.MustBytes()
|
|
token, err := svc.CreateTOTPChallenge(context.Background(), userID)
|
|
if err != nil {
|
|
t.Fatalf("create challenge failed: %v", err)
|
|
}
|
|
|
|
got, err := svc.ResolveTOTPChallenge(context.Background(), token)
|
|
if err != nil || string(got) != string(userID) {
|
|
t.Fatalf("resolve challenge failed: %v", err)
|
|
}
|
|
|
|
for i := 1; i < svc.TOTPChallengeMaxAttempts(); i++ {
|
|
remaining, regErr := svc.RegisterFailedTOTPChallengeAttempt(context.Background(), token)
|
|
if regErr != nil {
|
|
t.Fatalf("unexpected attempt err at #%d: %v", i, regErr)
|
|
}
|
|
if remaining != svc.TOTPChallengeMaxAttempts()-i {
|
|
t.Fatalf("unexpected remaining attempts at #%d: got %d", i, remaining)
|
|
}
|
|
}
|
|
|
|
if _, regErr := svc.RegisterFailedTOTPChallengeAttempt(context.Background(), token); !errors.Is(regErr, ErrTOTPChallengeAttemptLimit) {
|
|
t.Fatalf("expected attempt limit error, got %v", regErr)
|
|
}
|
|
if _, err := svc.ResolveTOTPChallenge(context.Background(), token); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token after attempt limit, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_IssueTokens_NoTokenStore(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
repo.CreateUser(context.Background(), user)
|
|
|
|
if _, err := svc.IssueTokens(context.Background(), user); err != nil {
|
|
t.Fatalf("issue tokens should not require token store: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_TokenRefresh(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
repo.CreateUser(context.Background(), user)
|
|
|
|
pair, err := svc.IssueTokens(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("issue tokens: %v", err)
|
|
}
|
|
|
|
refreshed, err := svc.RefreshTokens(context.Background(), pair.RefreshToken)
|
|
if err != nil {
|
|
t.Fatalf("refresh tokens: %v", err)
|
|
}
|
|
|
|
duplicate, err := svc.RefreshTokens(context.Background(), pair.RefreshToken)
|
|
if err != nil {
|
|
t.Fatalf("duplicate refresh tokens: %v", err)
|
|
}
|
|
if duplicate.AccessToken != refreshed.AccessToken || duplicate.RefreshToken != refreshed.RefreshToken {
|
|
t.Fatalf("expected duplicate refresh to return same token pair")
|
|
}
|
|
|
|
// the refreshed token must remain usable after the replay window handles the stale one
|
|
if _, err := svc.RefreshTokens(context.Background(), refreshed.RefreshToken); err != nil {
|
|
t.Fatalf("expected refreshed token to remain valid, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_TokenRefresh_WaitsForReplayDuringSlowRotation(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "slow-replay@example.com"}
|
|
repo.CreateUser(context.Background(), user)
|
|
|
|
pair, err := svc.IssueTokens(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("issue tokens: %v", err)
|
|
}
|
|
|
|
tokens.setDelay = 1500 * time.Millisecond
|
|
defer func() { tokens.setDelay = 0 }()
|
|
|
|
type refreshResult struct {
|
|
pair TokenPair
|
|
err error
|
|
}
|
|
firstCh := make(chan refreshResult, 1)
|
|
secondCh := make(chan refreshResult, 1)
|
|
|
|
go func() {
|
|
p, err := svc.RefreshTokens(context.Background(), pair.RefreshToken)
|
|
firstCh <- refreshResult{pair: p, err: err}
|
|
}()
|
|
time.Sleep(50 * time.Millisecond)
|
|
go func() {
|
|
p, err := svc.RefreshTokens(context.Background(), pair.RefreshToken)
|
|
secondCh <- refreshResult{pair: p, err: err}
|
|
}()
|
|
|
|
first := <-firstCh
|
|
if first.err != nil {
|
|
t.Fatalf("first refresh: %v", first.err)
|
|
}
|
|
second := <-secondCh
|
|
if second.err != nil {
|
|
t.Fatalf("second refresh: %v", second.err)
|
|
}
|
|
if second.pair.AccessToken != first.pair.AccessToken || second.pair.RefreshToken != first.pair.RefreshToken {
|
|
t.Fatalf("expected replayed refresh pair to match first refresh")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ReplaceSessionForSameDeviceType(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: 10 * time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
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; Intel Mac OS X)", "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("first issue tokens: %v", err)
|
|
}
|
|
if _, err := svc.ParseAccessToken(context.Background(), first.AccessToken); err != nil {
|
|
t.Fatalf("first access token should be valid, got %v", err)
|
|
}
|
|
|
|
second, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "127.0.0.2")
|
|
if err != nil {
|
|
t.Fatalf("second issue tokens: %v", err)
|
|
}
|
|
|
|
if _, err := svc.ParseAccessToken(context.Background(), first.AccessToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected first access token invalid after second login, got %v", err)
|
|
}
|
|
if _, err := svc.RefreshTokens(context.Background(), first.RefreshToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected first refresh token invalid after second login, got %v", err)
|
|
}
|
|
if _, err := svc.ParseAccessToken(context.Background(), second.AccessToken); err != nil {
|
|
t.Fatalf("second access token should remain valid, got %v", err)
|
|
}
|
|
if _, err := svc.RefreshTokens(context.Background(), second.RefreshToken); err != nil {
|
|
t.Fatalf("expected second refresh token to stay valid, got %v", err)
|
|
}
|
|
|
|
sessions, err := svc.ListUserSessions(context.Background(), user.ID, second.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("list sessions: %v", err)
|
|
}
|
|
if len(sessions) != 1 || sessions[0].ID != second.SessionID || !sessions[0].ActiveNow {
|
|
t.Fatalf("expected only latest session to remain active, got %#v", sessions)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_SessionListAndRevoke(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: 10 * time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
|
|
pair, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Macintosh; Intel Mac OS X)", "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("issue tokens: %v", err)
|
|
}
|
|
|
|
sessions, err := svc.ListUserSessions(context.Background(), user.ID, pair.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("list sessions: %v", err)
|
|
}
|
|
if len(sessions) != 1 || sessions[0].ID != pair.SessionID || !sessions[0].ActiveNow {
|
|
t.Fatalf("expected current session only, got %#v", sessions)
|
|
}
|
|
|
|
if err := svc.RevokeUserSession(context.Background(), user.ID, pair.SessionID); err != nil {
|
|
t.Fatalf("revoke session: %v", err)
|
|
}
|
|
if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected refresh token invalid after session revoke, got %v", err)
|
|
}
|
|
if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected access token invalid after current session revoke, got %v", err)
|
|
}
|
|
|
|
sessions, err = svc.ListUserSessions(context.Background(), user.ID, "")
|
|
if err != nil {
|
|
t.Fatalf("list sessions after revoke: %v", err)
|
|
}
|
|
if len(sessions) != 0 {
|
|
t.Fatalf("expected no sessions after revoke, got %#v", sessions)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RevokeAllUserSessions(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: 10 * time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
first, err := svc.IssueTokens(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("issue first tokens: %v", err)
|
|
}
|
|
second, err := svc.IssueTokens(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("issue second tokens: %v", err)
|
|
}
|
|
|
|
if err := svc.RevokeAllUserSessions(context.Background(), user.ID); err != nil {
|
|
t.Fatalf("revoke all sessions: %v", err)
|
|
}
|
|
if _, err := svc.ParseAccessToken(context.Background(), first.AccessToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected first access invalid after revoke all, got %v", err)
|
|
}
|
|
if _, err := svc.ParseAccessToken(context.Background(), second.AccessToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected second access invalid after revoke all, got %v", err)
|
|
}
|
|
if _, err := svc.RefreshTokens(context.Background(), first.RefreshToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected first refresh invalid after revoke all, got %v", err)
|
|
}
|
|
if _, err := svc.RefreshTokens(context.Background(), second.RefreshToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected second refresh invalid after revoke all, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_DeviceSlotPolicy_ReplacePerType(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: 10 * time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
|
|
desktop1, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Macintosh; Intel Mac OS X)", "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("issue desktop1: %v", err)
|
|
}
|
|
mobile1, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile", "127.0.0.2")
|
|
if err != nil {
|
|
t.Fatalf("issue mobile1: %v", err)
|
|
}
|
|
|
|
// second desktop login replaces previous desktop only.
|
|
desktop2, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "127.0.0.3")
|
|
if err != nil {
|
|
t.Fatalf("issue desktop2: %v", err)
|
|
}
|
|
|
|
if _, err := svc.ParseAccessToken(context.Background(), desktop1.AccessToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected first desktop access invalid after replacement, got %v", err)
|
|
}
|
|
if _, err := svc.ParseAccessToken(context.Background(), mobile1.AccessToken); err != nil {
|
|
t.Fatalf("expected mobile session to remain valid, got %v", err)
|
|
}
|
|
if _, err := svc.ParseAccessToken(context.Background(), desktop2.AccessToken); err != nil {
|
|
t.Fatalf("expected second desktop session valid, got %v", err)
|
|
}
|
|
|
|
sessions, err := svc.ListUserSessions(context.Background(), user.ID, desktop2.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("list sessions: %v", err)
|
|
}
|
|
if len(sessions) != 2 {
|
|
t.Fatalf("expected exactly 2 sessions (desktop+mobile), got %d", len(sessions))
|
|
}
|
|
}
|
|
|
|
func TestAuthService_SessionHelpersAndBranches(t *testing.T) {
|
|
newSessionService := func() (*mockAuthRepo, *mockTokenStore, *AuthService, *auth.User) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: 10 * time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
JWTRefreshTOTPTTL: 2 * time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
_ = repo.CreateUser(context.Background(), user)
|
|
return repo, tokens, svc, user
|
|
}
|
|
|
|
t.Run("issue tokens after totp success and invalid user", func(t *testing.T) {
|
|
_, _, svc, user := newSessionService()
|
|
pair, err := svc.IssueTokensAfterTOTPWithClient(context.Background(), user, "Mozilla/5.0 (X11; Linux) Firefox/120.0", "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("issue after totp: %v", err)
|
|
}
|
|
if pair.RefreshTTL != 2*time.Hour {
|
|
t.Fatalf("expected refresh ttl from totp config, got %v", pair.RefreshTTL)
|
|
}
|
|
if _, err := svc.IssueTokensAfterTOTPWithClient(context.Background(), &auth.User{}, "", ""); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for invalid user, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("issue login tokens helper invalid user", func(t *testing.T) {
|
|
_, _, svc, _ := newSessionService()
|
|
if _, err := svc.issueLoginTokensWithRefreshTTLAndClient(context.Background(), &auth.User{}, time.Hour, false, "", "", "", "", ""); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("parse access token session id success and error", func(t *testing.T) {
|
|
_, _, svc, user := newSessionService()
|
|
pair, err := svc.IssueTokens(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("issue tokens: %v", err)
|
|
}
|
|
sid, err := svc.ParseAccessTokenSessionID(context.Background(), pair.AccessToken)
|
|
if err != nil || sid != pair.SessionID {
|
|
t.Fatalf("expected session id %q, got %q err=%v", pair.SessionID, sid, err)
|
|
}
|
|
if _, err := svc.ParseAccessTokenSessionID(context.Background(), "bad"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("refresh tokens branches", func(t *testing.T) {
|
|
repo, tokens, svc, user := newSessionService()
|
|
pair, err := svc.IssueTokensAfterTOTPWithClient(context.Background(), user, "Mozilla/5.0 (Windows NT 10.0) Edg/120", "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("issue tokens: %v", err)
|
|
}
|
|
refreshed, err := svc.RefreshTokensWithClient(context.Background(), pair.RefreshToken, "", "")
|
|
if err != nil {
|
|
t.Fatalf("refresh tokens: %v", err)
|
|
}
|
|
if refreshed.RefreshTTL != svc.cfg.JWTRefreshTOTPTTL {
|
|
t.Fatalf("expected totp refresh ttl preserved, got %v", refreshed.RefreshTTL)
|
|
}
|
|
|
|
sub, err := uuidv7.BytesToString(user.ID)
|
|
if err != nil {
|
|
t.Fatalf("uuid to string: %v", err)
|
|
}
|
|
claims := jwt.MapClaims{
|
|
"sub": sub,
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"type": "refresh",
|
|
"sid": pair.SessionID,
|
|
}
|
|
tokenMissingJTI, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(svc.cfg.JWTRefreshSecret))
|
|
if _, err := svc.RefreshTokensWithClient(context.Background(), tokenMissingJTI, "", ""); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for missing jti, got %v", err)
|
|
}
|
|
|
|
pair2, err := svc.IssueTokens(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("issue second token: %v", err)
|
|
}
|
|
tokens.store[sessionKey(pair2.SessionID)] = []byte("not-json")
|
|
if _, err := svc.RefreshTokensWithClient(context.Background(), pair2.RefreshToken, "", ""); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for bad session payload, got %v", err)
|
|
}
|
|
|
|
pair3, err := svc.IssueTokens(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("issue third token: %v", err)
|
|
}
|
|
delete(repo.users, string(user.ID))
|
|
delete(repo.usersByMail, user.Email)
|
|
if _, err := svc.RefreshTokensWithClient(context.Background(), pair3.RefreshToken, "", ""); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token when user missing, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("refresh microsoft session validates provider connectivity", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
ms := &mockTokenSSO{
|
|
mockSSO: mockSSO{
|
|
claims: MicrosoftClaims{
|
|
Subject: "sub",
|
|
Email: "user@example.com",
|
|
TenantID: "tenant",
|
|
ObjectID: "oid",
|
|
},
|
|
},
|
|
}
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 1,
|
|
HashKeyLen: 32,
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{
|
|
SSO: ms,
|
|
Protector: &errorProtector{},
|
|
Config: cfg,
|
|
})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com", IsActive: true}
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
|
|
t.Fatalf("mark email verified: %v", err)
|
|
}
|
|
if err := svc.storeMicrosoftOAuthToken(context.Background(), user.ID, ms.claims, MicrosoftTokenSet{
|
|
AccessToken: "old_access",
|
|
RefreshToken: "old_refresh",
|
|
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
|
|
TokenType: "Bearer",
|
|
Scope: "openid profile email",
|
|
ExpiresIn: 3600,
|
|
}); err != nil {
|
|
t.Fatalf("store ms token: %v", err)
|
|
}
|
|
|
|
pair, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
|
|
if err != nil {
|
|
t.Fatalf("issue provider token: %v", err)
|
|
}
|
|
|
|
ms.refreshSet = MicrosoftTokenSet{
|
|
AccessToken: "new_access",
|
|
RefreshToken: "new_refresh",
|
|
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
|
|
TokenType: "Bearer",
|
|
Scope: "openid profile email",
|
|
ExpiresIn: 3600,
|
|
}
|
|
if _, err := svc.RefreshTokensWithClient(context.Background(), pair.RefreshToken, "", ""); err != nil {
|
|
t.Fatalf("expected microsoft session refresh success, got %v", err)
|
|
}
|
|
|
|
pair2, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
|
|
if err != nil {
|
|
t.Fatalf("issue provider token second: %v", err)
|
|
}
|
|
ms.refreshErr = errors.New("token exchange failed: invalid_grant")
|
|
if _, err := svc.RefreshTokensWithClient(context.Background(), pair2.RefreshToken, "", ""); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token when microsoft disconnected, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("refresh microsoft session survives transient provider errors", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
ms := &mockTokenSSO{
|
|
mockSSO: mockSSO{
|
|
claims: MicrosoftClaims{
|
|
Subject: "sub",
|
|
Email: "user@example.com",
|
|
TenantID: "tenant",
|
|
ObjectID: "oid",
|
|
},
|
|
},
|
|
}
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 1,
|
|
HashKeyLen: 32,
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{
|
|
SSO: ms,
|
|
Protector: &errorProtector{},
|
|
Config: cfg,
|
|
})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com", IsActive: true}
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
|
|
t.Fatalf("mark email verified: %v", err)
|
|
}
|
|
if err := svc.storeMicrosoftOAuthToken(context.Background(), user.ID, ms.claims, MicrosoftTokenSet{
|
|
AccessToken: "old_access",
|
|
RefreshToken: "old_refresh",
|
|
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
|
|
TokenType: "Bearer",
|
|
Scope: "openid profile email",
|
|
ExpiresIn: 3600,
|
|
}); err != nil {
|
|
t.Fatalf("store ms token: %v", err)
|
|
}
|
|
|
|
pair, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
|
|
if err != nil {
|
|
t.Fatalf("issue provider token: %v", err)
|
|
}
|
|
|
|
// A transient Microsoft failure (network/5xx/throttle/circuit breaker)
|
|
// must NOT revoke the app session: the user stays logged in and we
|
|
// retry Microsoft on the next refresh.
|
|
ms.refreshErr = errors.New("microsoft sso unavailable: server_error (503)")
|
|
if _, err := svc.RefreshTokensWithClient(context.Background(), pair.RefreshToken, "", ""); err != nil {
|
|
t.Fatalf("expected transient microsoft error to keep the session, got %v", err)
|
|
}
|
|
|
|
// The session must still be usable: once Microsoft recovers, a normal
|
|
// refresh succeeds.
|
|
ms.refreshErr = nil
|
|
ms.refreshSet = MicrosoftTokenSet{
|
|
AccessToken: "new_access",
|
|
RefreshToken: "new_refresh",
|
|
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
|
|
TokenType: "Bearer",
|
|
Scope: "openid profile email",
|
|
ExpiresIn: 3600,
|
|
}
|
|
pair2, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
|
|
if err != nil {
|
|
t.Fatalf("issue provider token second: %v", err)
|
|
}
|
|
if _, err := svc.RefreshTokensWithClient(context.Background(), pair2.RefreshToken, "", ""); err != nil {
|
|
t.Fatalf("expected refresh success after microsoft recovery, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("microsoft session refresh ttl follows microsoft refresh token", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
ms := &mockTokenSSO{
|
|
mockSSO: mockSSO{
|
|
claims: MicrosoftClaims{Subject: "sub", Email: "user@example.com", TenantID: "tenant", ObjectID: "oid"},
|
|
},
|
|
}
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 1,
|
|
HashKeyLen: 32,
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour, // short baseline that must be overridden for SSO
|
|
JWTSSORefreshTTL: 90 * 24 * time.Hour, // fallback when microsoft omits the expiry
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{
|
|
SSO: ms,
|
|
Protector: &errorProtector{},
|
|
Config: cfg,
|
|
})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com", IsActive: true}
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
|
|
t.Fatalf("mark email verified: %v", err)
|
|
}
|
|
|
|
// Microsoft reports a 30-day refresh token lifetime: the app session
|
|
// must follow it, not the 1h JWTRefreshTTL baseline.
|
|
const msRefreshTTL = 30 * 24 * time.Hour
|
|
if err := svc.storeMicrosoftOAuthToken(context.Background(), user.ID, ms.claims, MicrosoftTokenSet{
|
|
AccessToken: "access",
|
|
RefreshToken: "refresh",
|
|
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
|
|
TokenType: "Bearer",
|
|
Scope: "openid profile email",
|
|
ExpiresIn: 3600,
|
|
RefreshTokenExpiresIn: int64(msRefreshTTL.Seconds()),
|
|
}); err != nil {
|
|
t.Fatalf("store ms token: %v", err)
|
|
}
|
|
|
|
pair, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
|
|
if err != nil {
|
|
t.Fatalf("issue provider token: %v", err)
|
|
}
|
|
if pair.RefreshTTL <= time.Hour {
|
|
t.Fatalf("expected sso refresh ttl to follow microsoft (~30d), got %s", pair.RefreshTTL)
|
|
}
|
|
if diff := msRefreshTTL - pair.RefreshTTL; diff < 0 || diff > 5*time.Minute {
|
|
t.Fatalf("expected refresh ttl ~= microsoft 30d, got %s (diff %s)", pair.RefreshTTL, diff)
|
|
}
|
|
|
|
// When Microsoft does not report an expiry, fall back to the SSO default.
|
|
repo2 := newMockAuthRepo()
|
|
svc2 := NewAuthService(repo2, roles, newMockTokenStore(), nil, AuthServiceDependencies{
|
|
SSO: ms,
|
|
Protector: &errorProtector{},
|
|
Config: cfg,
|
|
})
|
|
user2 := &auth.User{ID: uuidv7.MustBytes(), Email: "user2@example.com", IsActive: true}
|
|
if err := repo2.CreateUser(context.Background(), user2); err != nil {
|
|
t.Fatalf("create user2: %v", err)
|
|
}
|
|
if err := repo2.MarkEmailVerified(context.Background(), user2.ID); err != nil {
|
|
t.Fatalf("mark email verified user2: %v", err)
|
|
}
|
|
if err := svc2.storeMicrosoftOAuthToken(context.Background(), user2.ID, ms.claims, MicrosoftTokenSet{
|
|
AccessToken: "access",
|
|
RefreshToken: "refresh",
|
|
IDToken: testMicrosoftIDToken("user2@example.com", "sub", "tenant", "oid"),
|
|
TokenType: "Bearer",
|
|
Scope: "openid profile email",
|
|
ExpiresIn: 3600,
|
|
}); err != nil {
|
|
t.Fatalf("store ms token user2: %v", err)
|
|
}
|
|
pair2, err := svc2.IssueTokensWithClientForProvider(context.Background(), user2, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
|
|
if err != nil {
|
|
t.Fatalf("issue provider token user2: %v", err)
|
|
}
|
|
if diff := cfg.JWTSSORefreshTTL - pair2.RefreshTTL; diff < 0 || diff > 5*time.Minute {
|
|
t.Fatalf("expected fallback ttl ~= sso default (90d), got %s", pair2.RefreshTTL)
|
|
}
|
|
})
|
|
|
|
t.Run("revoke refresh token with session branches", func(t *testing.T) {
|
|
_, tokens, svc, user := newSessionService()
|
|
if err := svc.revokeRefreshTokenWithSession(context.Background(), ""); err != nil {
|
|
t.Fatalf("expected nil for empty token, got %v", err)
|
|
}
|
|
if err := svc.revokeRefreshTokenWithSession(context.Background(), "bad"); err != nil {
|
|
t.Fatalf("expected nil for invalid token parse, got %v", err)
|
|
}
|
|
|
|
claims := jwt.MapClaims{
|
|
"sub": "not-a-uuid",
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"jti": "jti-1",
|
|
"type": "refresh",
|
|
"sid": "sid-1",
|
|
}
|
|
tokenBadSub, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(svc.cfg.JWTRefreshSecret))
|
|
tokens.store[refreshKey("jti-1")] = []byte("sid-1")
|
|
if err := svc.revokeRefreshTokenWithSession(context.Background(), tokenBadSub); err != nil {
|
|
t.Fatalf("expected nil for bad sub revoke, got %v", err)
|
|
}
|
|
if _, ok := tokens.store[refreshKey("jti-1")]; ok {
|
|
t.Fatalf("expected fallback delete when sub is invalid")
|
|
}
|
|
|
|
sub, err := uuidv7.BytesToString(user.ID)
|
|
if err != nil {
|
|
t.Fatalf("uuid to string: %v", err)
|
|
}
|
|
claimsNoSID := jwt.MapClaims{
|
|
"sub": sub,
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"jti": "jti-2",
|
|
"type": "refresh",
|
|
}
|
|
tokenNoSID, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsNoSID).SignedString([]byte(svc.cfg.JWTRefreshSecret))
|
|
tokens.store[refreshKey("jti-2")] = []byte("sid-2")
|
|
if err := svc.revokeRefreshTokenWithSession(context.Background(), tokenNoSID); err != nil {
|
|
t.Fatalf("expected nil for fallback delete, got %v", err)
|
|
}
|
|
if _, ok := tokens.store[refreshKey("jti-2")]; ok {
|
|
t.Fatalf("expected refresh key deleted")
|
|
}
|
|
})
|
|
|
|
t.Run("list user sessions branches", func(t *testing.T) {
|
|
_, tokens, svc, user := newSessionService()
|
|
if _, err := svc.ListUserSessions(context.Background(), []byte("short"), ""); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for invalid user id, got %v", err)
|
|
}
|
|
|
|
svcNoTokens := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
sessions, err := svcNoTokens.ListUserSessions(context.Background(), uuidv7.MustBytes(), "")
|
|
if err != nil || len(sessions) != 0 {
|
|
t.Fatalf("expected empty sessions without token store, got len=%d err=%v", len(sessions), err)
|
|
}
|
|
|
|
tokens.getErr = errors.New("get failed")
|
|
if _, err := svc.ListUserSessions(context.Background(), user.ID, ""); !errors.Is(err, tokens.getErr) {
|
|
t.Fatalf("expected get err, got %v", err)
|
|
}
|
|
tokens.getErr = nil
|
|
|
|
tokens.store[userSessionsKey(user.ID)] = []byte(`["sid-a","sid-b","sid-c"]`)
|
|
tokens.store[sessionKey("sid-a")] = []byte("not-json")
|
|
otherUserID := uuidv7.MustBytes()
|
|
recordB := &authSessionRecord{SessionID: "sid-b", UserID: bytesToString(otherUserID), LastActiveAt: time.Now()}
|
|
recordC := &authSessionRecord{SessionID: "sid-c", UserID: bytesToString(user.ID), DeviceName: "Chrome on macOS", LastActiveAt: time.Now().Add(time.Minute)}
|
|
_ = svc.saveSessionRecord(context.Background(), recordB, time.Hour)
|
|
_ = svc.saveSessionRecord(context.Background(), recordC, time.Hour)
|
|
list, err := svc.ListUserSessions(context.Background(), user.ID, "sid-c")
|
|
if err != nil {
|
|
t.Fatalf("list sessions: %v", err)
|
|
}
|
|
if len(list) != 1 || list[0].ID != "sid-c" || !list[0].ActiveNow {
|
|
t.Fatalf("expected only valid matching session, got %#v", list)
|
|
}
|
|
})
|
|
|
|
t.Run("revoke user session branches", func(t *testing.T) {
|
|
_, _, svc, user := newSessionService()
|
|
if err := svc.RevokeUserSession(context.Background(), []byte("short"), "sid"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for bad user id, got %v", err)
|
|
}
|
|
if err := svc.RevokeUserSession(context.Background(), user.ID, " "); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for empty session id, got %v", err)
|
|
}
|
|
|
|
svcNoTokens := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svcNoTokens.RevokeUserSession(context.Background(), user.ID, "sid"); err != nil {
|
|
t.Fatalf("expected nil without token store, got %v", err)
|
|
}
|
|
|
|
_, tokens, svc2, user2 := newSessionService()
|
|
tokens.getErr = errors.New("get failed")
|
|
if err := svc2.RevokeUserSession(context.Background(), user2.ID, "sid"); !errors.Is(err, tokens.getErr) {
|
|
t.Fatalf("expected get err, got %v", err)
|
|
}
|
|
tokens.getErr = nil
|
|
if err := svc2.RevokeUserSession(context.Background(), user2.ID, "sid"); !errors.Is(err, ErrSessionNotFound) {
|
|
t.Fatalf("expected session not found, got %v", err)
|
|
}
|
|
|
|
record := &authSessionRecord{SessionID: "sid", UserID: bytesToString(uuidv7.MustBytes()), CurrentJTI: "jti"}
|
|
_ = svc2.saveSessionRecord(context.Background(), record, time.Hour)
|
|
if err := svc2.RevokeUserSession(context.Background(), user2.ID, "sid"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for other user session, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("is session active branches", func(t *testing.T) {
|
|
_, _, _, user := newSessionService()
|
|
svcNoTokens := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if svcNoTokens.isSessionActive(context.Background(), user.ID, "sid") {
|
|
t.Fatalf("expected inactive when token store unavailable")
|
|
}
|
|
|
|
_, tokens, svc2, user2 := newSessionService()
|
|
tokens.getErr = errors.New("get failed")
|
|
if svc2.isSessionActive(context.Background(), user2.ID, "sid") {
|
|
t.Fatalf("expected inactive on token store error")
|
|
}
|
|
tokens.getErr = nil
|
|
if svc2.isSessionActive(context.Background(), user2.ID, "missing") {
|
|
t.Fatalf("expected inactive when no record")
|
|
}
|
|
record := &authSessionRecord{SessionID: "sid", UserID: bytesToString(uuidv7.MustBytes())}
|
|
_ = svc2.saveSessionRecord(context.Background(), record, time.Hour)
|
|
if svc2.isSessionActive(context.Background(), user2.ID, "sid") {
|
|
t.Fatalf("expected inactive for other user session")
|
|
}
|
|
record.UserID = bytesToString(user2.ID)
|
|
_ = svc2.saveSessionRecord(context.Background(), record, time.Hour)
|
|
_ = svc2.replaceUserSessionIDs(context.Background(), user2.ID, []string{"sid"})
|
|
if !svc2.isSessionActive(context.Background(), user2.ID, "sid") {
|
|
t.Fatalf("expected active for matching session")
|
|
}
|
|
})
|
|
|
|
t.Run("save session record branches", func(t *testing.T) {
|
|
_, _, svc, _ := newSessionService()
|
|
if err := svc.saveSessionRecord(context.Background(), nil, time.Hour); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for nil session, got %v", err)
|
|
}
|
|
if err := svc.saveSessionRecord(context.Background(), &authSessionRecord{}, time.Hour); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for empty session id, got %v", err)
|
|
}
|
|
|
|
_, tokens, svc2, user := newSessionService()
|
|
record := &authSessionRecord{SessionID: "sid", UserID: bytesToString(user.ID)}
|
|
if err := svc2.saveSessionRecord(context.Background(), record, time.Hour); err != nil {
|
|
t.Fatalf("unexpected save error: %v", err)
|
|
}
|
|
if _, ok := tokens.store[sessionKey("sid")]; !ok {
|
|
t.Fatalf("expected session payload saved")
|
|
}
|
|
})
|
|
|
|
t.Run("add user session id branches", func(t *testing.T) {
|
|
_, _, svc, user := newSessionService()
|
|
if err := svc.addUserSessionID(context.Background(), user.ID, " "); err != nil {
|
|
t.Fatalf("expected nil for empty session id, got %v", err)
|
|
}
|
|
|
|
_, tokens, svc2, user2 := newSessionService()
|
|
tokens.getErr = errors.New("get failed")
|
|
if err := svc2.addUserSessionID(context.Background(), user2.ID, "sid"); !errors.Is(err, tokens.getErr) {
|
|
t.Fatalf("expected get err, got %v", err)
|
|
}
|
|
tokens.getErr = nil
|
|
tokens.store[userSessionsKey(user2.ID)] = []byte(`["sid"]`)
|
|
setCountBefore := tokens.setCount
|
|
if err := svc2.addUserSessionID(context.Background(), user2.ID, "sid"); err != nil {
|
|
t.Fatalf("unexpected duplicate add error: %v", err)
|
|
}
|
|
if tokens.setCount != setCountBefore {
|
|
t.Fatalf("expected duplicate session id not to be written again")
|
|
}
|
|
if err := svc2.addUserSessionID(context.Background(), user2.ID, "sid-2"); err != nil {
|
|
t.Fatalf("unexpected add error: %v", err)
|
|
}
|
|
ids, err := svc2.loadUserSessionIDs(context.Background(), user2.ID)
|
|
if err != nil || len(ids) != 2 {
|
|
t.Fatalf("expected two session ids, got %#v err=%v", ids, err)
|
|
}
|
|
})
|
|
|
|
t.Run("derive device name variants", func(t *testing.T) {
|
|
cases := map[string]string{
|
|
"": "Unknown device",
|
|
"Mozilla/5.0 (iPhone) Safari/605.1": "Safari on iPhone",
|
|
"Mozilla/5.0 (iPad) Safari/605.1": "Safari on iPad",
|
|
"Mozilla/5.0 (Linux; Android 14) Chrome/120.0": "Chrome on Android",
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X) Firefox/120.0": "Firefox on macOS",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Edg/120.0": "Edge on Windows",
|
|
"Mozilla/5.0 (X11; Linux x86_64)": "Unknown browser on Linux",
|
|
}
|
|
for ua, want := range cases {
|
|
if got := deriveDeviceName(ua); got != want {
|
|
t.Fatalf("deriveDeviceName(%q) = %q, want %q", ua, got, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_RefreshTokens_InvalidToken(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{
|
|
JWTRefreshSecret: "refresh",
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if _, err := svc.RefreshTokens(context.Background(), "bad"); err == nil {
|
|
t.Fatalf("expected invalid token")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RefreshTokens_NoTokenStore(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
repo.CreateUser(context.Background(), user)
|
|
pair, _ := svc.IssueTokens(context.Background(), user)
|
|
if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token without token store, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RefreshTokens_MissingJTI(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{
|
|
JWTRefreshSecret: "refresh",
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{
|
|
|
|
// create token without jti
|
|
SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
claims := map[string]any{
|
|
"sub": "invalid",
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"type": "refresh",
|
|
}
|
|
tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(cfg.JWTRefreshSecret))
|
|
if _, err := svc.RefreshTokens(context.Background(), tok); err == nil {
|
|
t.Fatalf("expected invalid token (missing jti)")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RefreshTokens_MissingSub(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{
|
|
JWTRefreshSecret: "refresh",
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
claims := map[string]any{
|
|
"jti": "jti1",
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"type": "refresh",
|
|
}
|
|
tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(cfg.JWTRefreshSecret))
|
|
if _, err := svc.RefreshTokens(context.Background(), tok); err == nil {
|
|
t.Fatalf("expected invalid token (missing sub)")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RefreshTokens_TokenStoreMissing(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
repo.CreateUser(context.Background(), user)
|
|
pair, _ := svc.IssueTokens(context.Background(), user)
|
|
|
|
// clear token store to simulate missing jti
|
|
tokens.store = map[string][]byte{}
|
|
|
|
if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); err == nil {
|
|
t.Fatalf("expected invalid token when jti missing in store")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RefreshTokens_UserNotFound(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{
|
|
|
|
// Create token with valid jti stored but user missing
|
|
SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
jti := "jti2"
|
|
_ = tokens.Set(context.Background(), "auth:refresh:"+jti, []byte("1"), time.Hour)
|
|
claims := map[string]any{
|
|
"jti": jti,
|
|
"sub": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d",
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"type": "refresh",
|
|
}
|
|
tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(cfg.JWTRefreshSecret))
|
|
if _, err := svc.RefreshTokens(context.Background(), tok); err == nil {
|
|
t.Fatalf("expected invalid token when user not found")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_VerifyEmail(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
adminID := uuidv7.MustBytes()
|
|
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "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 := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID)
|
|
if err != nil {
|
|
t.Fatalf("register failed: %v", err)
|
|
}
|
|
|
|
if tokens.lastKey == "" {
|
|
t.Fatalf("expected email verify token in store")
|
|
}
|
|
|
|
token := tokens.lastKey[len("auth:email_verify:"):]
|
|
if err := svc.VerifyEmail(context.Background(), token); err != nil {
|
|
t.Fatalf("verify email failed: %v", err)
|
|
}
|
|
if user.EmailVerifiedAt == nil {
|
|
t.Fatalf("email not verified")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_TOTPChallenge(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
TOTPChallengeTTL: time.Minute,
|
|
}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
userID := uuidv7.MustBytes()
|
|
token, err := svc.CreateTOTPChallenge(context.Background(), userID)
|
|
if err != nil || token == "" {
|
|
t.Fatalf("create challenge failed")
|
|
}
|
|
|
|
got, err := svc.ConsumeTOTPChallenge(context.Background(), token)
|
|
if err != nil {
|
|
t.Fatalf("consume challenge failed: %v", err)
|
|
}
|
|
if string(got) != string(userID) {
|
|
t.Fatalf("unexpected user id")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RandomToken_Uniqueness(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
TOTPChallengeTTL: time.Minute,
|
|
}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
userID := uuidv7.MustBytes()
|
|
token1, err := svc.CreateTOTPChallenge(context.Background(), userID)
|
|
if err != nil || token1 == "" {
|
|
t.Fatalf("create challenge 1 failed")
|
|
}
|
|
token2, err := svc.CreateTOTPChallenge(context.Background(), userID)
|
|
if err != nil || token2 == "" {
|
|
t.Fatalf("create challenge 2 failed")
|
|
}
|
|
if token1 == token2 {
|
|
t.Fatalf("expected different tokens")
|
|
}
|
|
if tokens.setCount < 2 {
|
|
t.Fatalf("expected token store set twice")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RandomToken_Error(t *testing.T) {
|
|
orig := rand.Reader
|
|
rand.Reader = errReader{}
|
|
defer func() { rand.Reader = orig }()
|
|
|
|
if _, err := randomToken(16); err == nil {
|
|
t.Fatalf("expected randomToken error")
|
|
}
|
|
}
|
|
|
|
type errReader struct{}
|
|
|
|
func (e errReader) Read(p []byte) (int, error) {
|
|
return 0, io.ErrUnexpectedEOF
|
|
}
|
|
|
|
func TestAuthService_ParseAccessToken(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
repo.CreateUser(context.Background(), user)
|
|
pair, err := svc.IssueTokens(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("issue tokens: %v", err)
|
|
}
|
|
|
|
id, err := svc.ParseAccessToken(context.Background(), pair.AccessToken)
|
|
if err != nil || string(id) != string(user.ID) {
|
|
t.Fatalf("parse access token failed")
|
|
}
|
|
|
|
user.IsActive = false
|
|
if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected inactive user access token to be rejected, got %v", err)
|
|
}
|
|
}
|
|
|
|
type mockSSO struct {
|
|
claims MicrosoftClaims
|
|
err error
|
|
authURL string
|
|
}
|
|
|
|
type mockTokenSSO struct {
|
|
mockSSO
|
|
refreshSet MicrosoftTokenSet
|
|
refreshErr error
|
|
}
|
|
|
|
func (m *mockSSO) AuthCodeURL(state string) string {
|
|
if strings.TrimSpace(m.authURL) != "" {
|
|
return fmt.Sprintf(m.authURL, state)
|
|
}
|
|
return "http://example.com?state=" + state
|
|
}
|
|
func (m *mockSSO) 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 *mockSSO) ExchangeCode(ctx context.Context, code string) (MicrosoftClaims, error) {
|
|
if m.err != nil {
|
|
return MicrosoftClaims{}, m.err
|
|
}
|
|
return m.claims, nil
|
|
}
|
|
|
|
func (m *mockTokenSSO) ExchangeCodeAndTokens(ctx context.Context, code, codeVerifier string) (MicrosoftTokenSet, MicrosoftClaims, error) {
|
|
if m.err != nil {
|
|
return MicrosoftTokenSet{}, MicrosoftClaims{}, m.err
|
|
}
|
|
return MicrosoftTokenSet{
|
|
AccessToken: "ms_access",
|
|
RefreshToken: "ms_refresh",
|
|
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
|
|
TokenType: "Bearer",
|
|
Scope: "openid profile email",
|
|
ExpiresIn: 3600,
|
|
}, m.claims, nil
|
|
}
|
|
|
|
func (m *mockTokenSSO) RefreshAccessToken(ctx context.Context, refreshToken, codeVerifier string) (MicrosoftTokenSet, error) {
|
|
if m.refreshErr != nil {
|
|
return MicrosoftTokenSet{}, m.refreshErr
|
|
}
|
|
if m.refreshSet.IDToken == "" {
|
|
m.refreshSet.IDToken = testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid")
|
|
}
|
|
if m.refreshSet.AccessToken == "" {
|
|
m.refreshSet.AccessToken = "ms_access_refreshed"
|
|
}
|
|
return m.refreshSet, nil
|
|
}
|
|
|
|
func testMicrosoftIDToken(email, sub, tid, oid string) string {
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
|
payload := fmt.Sprintf(`{"sub":"%s","tid":"%s","oid":"%s","email":"%s","upn":"%s"}`, sub, tid, oid, email, email)
|
|
body := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
|
return header + "." + body + "."
|
|
}
|
|
|
|
func TestAuthService_LinkSSOForUser(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
userID := uuidv7.MustBytes()
|
|
userRoleID := uuidv7.MustBytes()
|
|
if err := repo.CreateUser(context.Background(), &auth.User{
|
|
ID: userID,
|
|
Email: "marco@example.com",
|
|
Timezone: tzpkg.WithDefault(""),
|
|
RoleID: userRoleID,
|
|
}); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "marco@example.com", Name: "Jane Doe"}}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
|
|
identity, err := svc.LinkSSOForUser(context.Background(), userID, "microsoft", "code")
|
|
if err != nil || identity == nil {
|
|
t.Fatalf("link sso failed: %v", err)
|
|
}
|
|
if !bytes.Equal(identity.UserID, userID) {
|
|
t.Fatalf("expected identity linked to user")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LinkSSOForUser_RejectWhenLinkedToAnotherUser(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
userRoleID := uuidv7.MustBytes()
|
|
userID := uuidv7.MustBytes()
|
|
otherUserID := uuidv7.MustBytes()
|
|
if err := repo.CreateUser(context.Background(), &auth.User{
|
|
ID: userID,
|
|
Email: "user@example.com",
|
|
Timezone: tzpkg.WithDefault(""),
|
|
RoleID: userRoleID,
|
|
}); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
if err := repo.CreateUser(context.Background(), &auth.User{
|
|
ID: otherUserID,
|
|
Email: "other@example.com",
|
|
Timezone: tzpkg.WithDefault(""),
|
|
RoleID: userRoleID,
|
|
}); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
_ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{
|
|
ID: uuidv7.MustBytes(),
|
|
UserID: otherUserID,
|
|
Provider: "microsoft",
|
|
ProviderSubject: "sub",
|
|
Email: "other@example.com",
|
|
})
|
|
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
if _, err := svc.LinkSSOForUser(context.Background(), userID, "microsoft", "code"); !errors.Is(err, ErrSSOLinkedToAnotherUser) {
|
|
t.Fatalf("expected ErrSSOLinkedToAnotherUser, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_BeginCompleteMicrosoftLogin(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
userRoleID := uuidv7.MustBytes()
|
|
roles.rolesByName["user"] = &auth.Role{ID: userRoleID, Name: "user"}
|
|
userID := uuidv7.MustBytes()
|
|
if err := repo.CreateUser(context.Background(), &auth.User{
|
|
ID: userID,
|
|
Email: "user@example.com",
|
|
Timezone: tzpkg.WithDefault(""),
|
|
RoleID: userRoleID,
|
|
}); 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",
|
|
})
|
|
|
|
cfg := config.AuthConfig{
|
|
SSOStateTTL: time.Minute,
|
|
}
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
|
|
authURL, err := svc.BeginMicrosoftLogin(context.Background())
|
|
if err != nil || authURL == "" {
|
|
t.Fatalf("expected auth url")
|
|
}
|
|
|
|
// extract state from mock url
|
|
parsedURL, parseErr := url.Parse(authURL)
|
|
if parseErr != nil {
|
|
t.Fatalf("parse auth url: %v", parseErr)
|
|
}
|
|
state := parsedURL.Query().Get("state")
|
|
identity, _, err := svc.CompleteMicrosoftLogin(context.Background(), "code", state)
|
|
if err != nil || identity == nil {
|
|
t.Fatalf("complete login failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_BeginCompleteMicrosoftLink(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
userRoleID := uuidv7.MustBytes()
|
|
roles.rolesByName["user"] = &auth.Role{ID: userRoleID, Name: "user"}
|
|
userID := uuidv7.MustBytes()
|
|
if err := repo.CreateUser(context.Background(), &auth.User{
|
|
ID: userID,
|
|
Email: "user@example.com",
|
|
Timezone: tzpkg.WithDefault(""),
|
|
RoleID: userRoleID,
|
|
}); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
|
|
cfg := config.AuthConfig{
|
|
SSOStateTTL: time.Minute,
|
|
}
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub-link", Email: "user@example.com", Name: "Jane Doe"}}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
|
|
authURL, err := svc.BeginMicrosoftLink(context.Background(), userID)
|
|
if err != nil || authURL == "" {
|
|
t.Fatalf("expected auth url")
|
|
}
|
|
|
|
parsedURL, parseErr := url.Parse(authURL)
|
|
if parseErr != nil {
|
|
t.Fatalf("parse auth url: %v", parseErr)
|
|
}
|
|
state := parsedURL.Query().Get("state")
|
|
identity, err := svc.CompleteMicrosoftLink(context.Background(), "code", state)
|
|
if err != nil || identity == nil {
|
|
t.Fatalf("complete link failed: %v", err)
|
|
}
|
|
if identity.Provider != "microsoft" || identity.ProviderSubject != "sub-link" {
|
|
t.Fatalf("unexpected linked identity: %+v", identity)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RevokeRefreshToken(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
repo.CreateUser(context.Background(), user)
|
|
|
|
pair, err := svc.IssueTokens(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("issue tokens: %v", err)
|
|
}
|
|
|
|
if err := svc.RevokeRefreshToken(context.Background(), pair.RefreshToken); err != nil {
|
|
t.Fatalf("revoke failed: %v", err)
|
|
}
|
|
if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); err == nil {
|
|
t.Fatalf("expected refresh to fail after revoke")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RevokeRefreshToken_Invalid(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
cfg := config.AuthConfig{JWTRefreshSecret: "refresh"}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if err := svc.RevokeRefreshToken(context.Background(), "bad"); err != nil {
|
|
t.Fatalf("expected nil error")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_VerifyAndDisableTOTP(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
key := make([]byte, 32)
|
|
for i := range key {
|
|
key[i] = byte(i + 1)
|
|
}
|
|
protector, err := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
|
|
if err != nil {
|
|
t.Fatalf("protector: %v", err)
|
|
}
|
|
|
|
cfg := config.AuthConfig{
|
|
TOTPChallengeTTL: time.Minute,
|
|
TOTPIssuer: "Wucher",
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
|
|
|
|
userID := uuidv7.MustBytes()
|
|
secret, _, _, err := svc.SetupTOTP(context.Background(), userID, "user@example.com")
|
|
if err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
code, _ := totp.GenerateCode(secret, time.Now().UTC())
|
|
ok, err := svc.ConfirmTOTP(context.Background(), userID, code)
|
|
if err != nil || !ok {
|
|
t.Fatalf("confirm failed")
|
|
}
|
|
|
|
ok, err = svc.VerifyTOTP(context.Background(), userID, code)
|
|
if err != nil || !ok {
|
|
t.Fatalf("verify failed")
|
|
}
|
|
rec, _ := repo.GetUserTOTP(context.Background(), userID)
|
|
if rec == nil || rec.LastUsedAt == nil {
|
|
t.Fatalf("last_used_at not updated")
|
|
}
|
|
|
|
if err := svc.DisableTOTP(context.Background(), userID); err != nil {
|
|
t.Fatalf("disable failed: %v", err)
|
|
}
|
|
enabled, _ := svc.IsTOTPEnabled(context.Background(), userID)
|
|
if enabled {
|
|
t.Fatalf("expected disabled")
|
|
}
|
|
|
|
secretAgain, _, _, err := svc.SetupTOTP(context.Background(), userID, "user@example.com")
|
|
if err != nil {
|
|
t.Fatalf("setup again: %v", err)
|
|
}
|
|
if secretAgain != secret {
|
|
t.Fatalf("expected existing secret to be reused after disable")
|
|
}
|
|
|
|
code2, _ := totp.GenerateCode(secretAgain, time.Now().UTC())
|
|
ok, err = svc.ConfirmTOTP(context.Background(), userID, code2)
|
|
if err != nil || !ok {
|
|
t.Fatalf("confirm after disable failed")
|
|
}
|
|
enabled, _ = svc.IsTOTPEnabled(context.Background(), userID)
|
|
if !enabled {
|
|
t.Fatalf("expected enabled after reconfirm")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_SetAuditLogger(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
if svc.audit != nil {
|
|
t.Fatalf("expected nil audit logger by default")
|
|
}
|
|
|
|
auditSvc := &AuditLogService{}
|
|
svc.SetAuditLogger(auditSvc)
|
|
if svc.audit != auditSvc {
|
|
t.Fatalf("expected audit logger to be set")
|
|
}
|
|
|
|
svc.SetAuditLogger(nil)
|
|
if svc.audit != nil {
|
|
t.Fatalf("expected audit logger to be cleared")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_logServiceAudit(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
|
|
// Should no-op when audit logger is not configured.
|
|
svc.logServiceAudit(context.Background(), "auth.test.noop", nil, false, "noop", nil)
|
|
|
|
auditRepo := &mockAuditRepo{}
|
|
auditSvc := NewAuditLogService(auditRepo, AuditLogServiceDependencies{QueueSize: 8, Workers: 1})
|
|
svc.SetAuditLogger(auditSvc)
|
|
|
|
userID := uuidv7.MustBytes()
|
|
svc.logServiceAudit(context.Background(), "auth.test.action", userID, true, "ok", map[string]any{
|
|
"source": "unit-test",
|
|
})
|
|
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
auditRepo.mu.Lock()
|
|
n := len(auditRepo.entries)
|
|
auditRepo.mu.Unlock()
|
|
if n > 0 {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
auditRepo.mu.Lock()
|
|
defer auditRepo.mu.Unlock()
|
|
if len(auditRepo.entries) != 1 {
|
|
t.Fatalf("expected 1 audit entry, got %d", len(auditRepo.entries))
|
|
}
|
|
entry := auditRepo.entries[0]
|
|
if entry.Layer != "service" {
|
|
t.Fatalf("expected layer service, got %q", entry.Layer)
|
|
}
|
|
if entry.Action != "auth.test.action" {
|
|
t.Fatalf("expected action auth.test.action, got %q", entry.Action)
|
|
}
|
|
if !entry.Success {
|
|
t.Fatalf("expected success=true")
|
|
}
|
|
if entry.Message != "ok" {
|
|
t.Fatalf("expected message ok, got %q", entry.Message)
|
|
}
|
|
if string(entry.ActorUserID) != string(userID) {
|
|
t.Fatalf("expected actor user id to match")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_Getters(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
roleID := uuidv7.MustBytes()
|
|
roles.rolesByID[string(roleID)] = &auth.Role{ID: roleID, Name: "admin"}
|
|
|
|
cfg := config.AuthConfig{
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
JWTCookieDomain: "example.com",
|
|
JWTCookieSecure: true,
|
|
JWTCookieSameSite: "Lax",
|
|
JWTAccessCookieName: "at",
|
|
JWTRefreshCookieName: "rt",
|
|
ForgotPasswordIPRateMax: 7,
|
|
ForgotPasswordIPRateWindow: 2 * time.Minute,
|
|
DefaultRoleName: "admin",
|
|
DisableRegister: true,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
_ = svc.AccessTTL()
|
|
_ = svc.RefreshTTL()
|
|
_ = svc.CookieDomain()
|
|
_ = svc.CookieSecure()
|
|
_ = svc.CookieSameSite()
|
|
_ = svc.AccessCookieName()
|
|
_ = svc.RefreshCookieName()
|
|
_ = svc.DefaultRoleName()
|
|
_ = svc.RegisterDisabled()
|
|
if got := svc.ForgotPasswordIPRateMax(); got != cfg.ForgotPasswordIPRateMax {
|
|
t.Fatalf("expected ForgotPasswordIPRateMax=%d, got %d", cfg.ForgotPasswordIPRateMax, got)
|
|
}
|
|
if got := svc.ForgotPasswordIPRateWindow(); got != cfg.ForgotPasswordIPRateWindow {
|
|
t.Fatalf("expected ForgotPasswordIPRateWindow=%s, got %s", cfg.ForgotPasswordIPRateWindow, got)
|
|
}
|
|
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
|
|
repo.CreateUser(context.Background(), user)
|
|
if u, _ := svc.GetUserByID(context.Background(), user.ID); u == nil {
|
|
t.Fatalf("get user failed")
|
|
}
|
|
if r, _ := svc.GetRoleByID(context.Background(), roleID); r == nil {
|
|
t.Fatalf("get role failed")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ForgotPassword_StoresOnlyTokenHash(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
emailQueue := &mockEmailQueue{}
|
|
|
|
user := &auth.User{
|
|
ID: uuidv7.MustBytes(),
|
|
Email: "user@example.com",
|
|
FirstName: "John",
|
|
LastName: "Doe",
|
|
RoleID: uuidv7.MustBytes(),
|
|
}
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordResetURL: "https://app.example.com/reset-password",
|
|
PasswordResetTTL: 20 * time.Minute,
|
|
ForgotPasswordEmailCooldown: 0,
|
|
ForgotPasswordMinDuration: 0,
|
|
}
|
|
svc := NewAuthService(repo, roles, nil, emailQueue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
svc.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "test-agent")
|
|
|
|
if repo.lastResetToken == nil {
|
|
t.Fatalf("expected password reset token to be persisted")
|
|
}
|
|
if len(repo.lastResetToken.TokenHash) != sha256.Size {
|
|
t.Fatalf("expected token hash length %d, got %d", sha256.Size, len(repo.lastResetToken.TokenHash))
|
|
}
|
|
if emailQueue.last == nil {
|
|
t.Fatalf("expected reset email to be enqueued")
|
|
}
|
|
|
|
rawToken := extractTokenValue(emailQueue.last.Body)
|
|
if rawToken == "" {
|
|
t.Fatalf("expected raw token in email body")
|
|
}
|
|
if bytes.Equal([]byte(rawToken), repo.lastResetToken.TokenHash) {
|
|
t.Fatalf("raw token must not be stored in DB")
|
|
}
|
|
if !bytes.Equal(hashToken(rawToken), repo.lastResetToken.TokenHash) {
|
|
t.Fatalf("stored hash does not match hashed raw token")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ResetPassword_ExpiredTokenRejected(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
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)
|
|
}
|
|
|
|
token := "expired-token"
|
|
tokenHash := hashToken(token)
|
|
repo.resetTokens[string(tokenHash)] = &auth.PasswordResetToken{
|
|
ID: uuidv7.MustBytes(),
|
|
UserID: user.ID,
|
|
TokenHash: tokenHash,
|
|
ExpiresAt: time.Now().UTC().Add(-time.Minute),
|
|
}
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
PasswordResetTTL: 20 * time.Minute,
|
|
}
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
if err := svc.ResetPassword(context.Background(), token, "NewPassword123!"); !errors.Is(err, ErrInvalidResetLink) {
|
|
t.Fatalf("expected ErrInvalidResetLink, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ResetPassword_SingleUseAndSessionInvalidation(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
JWTAccessSecret: "access",
|
|
JWTRefreshSecret: "refresh",
|
|
JWTAccessTTL: time.Minute,
|
|
JWTRefreshTTL: time.Hour,
|
|
PasswordResetTTL: 20 * time.Minute,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
|
|
origHash, origSalt, err := svc.hashPassword("OldPassword123!")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
user := &auth.User{
|
|
ID: uuidv7.MustBytes(),
|
|
Email: "user@example.com",
|
|
PasswordHash: origHash,
|
|
SaltValue: origSalt,
|
|
RoleID: uuidv7.MustBytes(),
|
|
}
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
|
|
pair, err := svc.IssueTokens(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("issue tokens: %v", err)
|
|
}
|
|
|
|
tokenA := "reset-token-a"
|
|
tokenB := "reset-token-b"
|
|
hashA := hashToken(tokenA)
|
|
hashB := hashToken(tokenB)
|
|
repo.resetTokens[string(hashA)] = &auth.PasswordResetToken{
|
|
ID: uuidv7.MustBytes(),
|
|
UserID: user.ID,
|
|
TokenHash: hashA,
|
|
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
|
|
}
|
|
repo.resetTokens[string(hashB)] = &auth.PasswordResetToken{
|
|
ID: uuidv7.MustBytes(),
|
|
UserID: user.ID,
|
|
TokenHash: hashB,
|
|
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
|
|
}
|
|
|
|
if err := svc.ResetPassword(context.Background(), tokenA, "NewPassword123!Long"); err != nil {
|
|
t.Fatalf("reset password: %v", err)
|
|
}
|
|
if bytes.Equal(user.PasswordHash, origHash) {
|
|
t.Fatalf("expected password hash to change")
|
|
}
|
|
if !svc.verifyPassword("NewPassword123!Long", user.PasswordHash, user.SaltValue) {
|
|
t.Fatalf("expected new password to verify")
|
|
}
|
|
if repo.resetTokens[string(hashA)].UsedAt == nil {
|
|
t.Fatalf("used token should be marked used")
|
|
}
|
|
if repo.resetTokens[string(hashB)].UsedAt == nil {
|
|
t.Fatalf("other active tokens should be invalidated")
|
|
}
|
|
|
|
if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected old access token to be invalid after reset, got %v", err)
|
|
}
|
|
if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected old refresh token to be invalid after reset, got %v", err)
|
|
}
|
|
if err := svc.ResetPassword(context.Background(), tokenA, "AnotherPassword123!"); !errors.Is(err, ErrInvalidResetLink) {
|
|
t.Fatalf("expected token reuse to fail, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ForgotPassword_InvalidatesPreviousTokensAndAppliesCooldown(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
user := &auth.User{
|
|
ID: uuidv7.MustBytes(),
|
|
Email: "user@example.com",
|
|
FirstName: "John",
|
|
LastName: "Doe",
|
|
RoleID: uuidv7.MustBytes(),
|
|
}
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
|
|
cfgNoCooldown := config.AuthConfig{
|
|
PasswordResetURL: "https://app.example.com/reset-password",
|
|
PasswordResetTTL: 20 * time.Minute,
|
|
ForgotPasswordEmailCooldown: 0,
|
|
ForgotPasswordMinDuration: 0,
|
|
}
|
|
svcNoCooldown := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfgNoCooldown})
|
|
svcNoCooldown.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "ua-1")
|
|
first := repo.lastResetToken
|
|
if first == nil {
|
|
t.Fatalf("expected first token")
|
|
}
|
|
svcNoCooldown.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "ua-2")
|
|
second := repo.lastResetToken
|
|
if second == nil || bytes.Equal(first.TokenHash, second.TokenHash) {
|
|
t.Fatalf("expected second distinct token")
|
|
}
|
|
if rec := repo.resetTokens[string(first.TokenHash)]; rec == nil || rec.UsedAt == nil {
|
|
t.Fatalf("previous active token should be invalidated by new request")
|
|
}
|
|
|
|
cfgCooldown := config.AuthConfig{
|
|
PasswordResetURL: "https://app.example.com/reset-password",
|
|
PasswordResetTTL: 20 * time.Minute,
|
|
ForgotPasswordEmailCooldown: 2 * time.Minute,
|
|
ForgotPasswordMinDuration: 0,
|
|
}
|
|
repo2 := newMockAuthRepo()
|
|
if err := repo2.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user for cooldown: %v", err)
|
|
}
|
|
svcCooldown := NewAuthService(repo2, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfgCooldown})
|
|
svcCooldown.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "ua")
|
|
countAfterFirst := len(repo2.resetTokens)
|
|
svcCooldown.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "ua")
|
|
if len(repo2.resetTokens) != countAfterFirst {
|
|
t.Fatalf("expected second request to be throttled by email cooldown")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ForgotPassword_AdditionalBranches(t *testing.T) {
|
|
t.Run("empty email returns without issuing token", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
ForgotPasswordMinDuration: 0,
|
|
}})
|
|
|
|
svc.ForgotPassword(context.Background(), " ", "127.0.0.1", "ua")
|
|
if repo.lastResetToken != nil {
|
|
t.Fatalf("did not expect reset token for empty email")
|
|
}
|
|
})
|
|
|
|
t.Run("lookup error applies cooldown and does not issue token", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.getByMailErr = errors.New("db lookup failed")
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
ForgotPasswordEmailCooldown: time.Minute,
|
|
ForgotPasswordMinDuration: 0,
|
|
}})
|
|
|
|
svc.ForgotPassword(context.Background(), "user@example.com", "127.0.0.1", "ua")
|
|
|
|
if repo.lastResetToken != nil {
|
|
t.Fatalf("did not expect reset token when lookup fails")
|
|
}
|
|
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:forgot_password:cooldown:") {
|
|
t.Fatalf("expected cooldown key to be stored, got %q", tokens.lastKey)
|
|
}
|
|
})
|
|
|
|
t.Run("unknown email is hidden and still applies cooldown", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
ForgotPasswordEmailCooldown: time.Minute,
|
|
ForgotPasswordMinDuration: 0,
|
|
}})
|
|
|
|
svc.ForgotPassword(context.Background(), "missing@example.com", "127.0.0.1", "ua")
|
|
|
|
if repo.lastResetToken != nil {
|
|
t.Fatalf("did not expect reset token for unknown account")
|
|
}
|
|
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:forgot_password:cooldown:") {
|
|
t.Fatalf("expected cooldown key to be stored, got %q", tokens.lastKey)
|
|
}
|
|
})
|
|
|
|
t.Run("issue failure path still returns and applies cooldown", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.createResetTokenErr = errors.New("insert failed")
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
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)
|
|
}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
PasswordResetURL: "https://app.example.com/reset-password",
|
|
PasswordResetTTL: 20 * time.Minute,
|
|
ForgotPasswordEmailCooldown: time.Minute,
|
|
ForgotPasswordMinDuration: 0,
|
|
}})
|
|
|
|
svc.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "ua")
|
|
|
|
if repo.lastResetToken != nil {
|
|
t.Fatalf("did not expect reset token persisted on create error")
|
|
}
|
|
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:forgot_password:cooldown:") {
|
|
t.Fatalf("expected cooldown key to be stored, got %q", tokens.lastKey)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_ResetPassword_AdditionalBranches(t *testing.T) {
|
|
baseCfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
PasswordResetTTL: 20 * time.Minute,
|
|
}
|
|
|
|
t.Run("missing token or password", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.ResetPassword(context.Background(), "", "NewPassword123!"); !errors.Is(err, ErrInvalidResetLink) {
|
|
t.Fatalf("expected ErrInvalidResetLink for empty token, got %v", err)
|
|
}
|
|
if err := svc.ResetPassword(context.Background(), "token", ""); !errors.Is(err, ErrInvalidResetLink) {
|
|
t.Fatalf("expected ErrInvalidResetLink for empty password, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("consume repository error is returned", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.consumeResetErr = errors.New("consume failed")
|
|
roles := newMockRoleRepo()
|
|
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
err := svc.ResetPassword(context.Background(), "token", "NewPassword123!")
|
|
if !errors.Is(err, repo.consumeResetErr) {
|
|
t.Fatalf("expected consume error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("session version bump failure is ignored", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
tokens.getErr = errors.New("token store unavailable")
|
|
|
|
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)
|
|
}
|
|
token := "reset-token"
|
|
tokenHash := hashToken(token)
|
|
repo.resetTokens[string(tokenHash)] = &auth.PasswordResetToken{
|
|
ID: uuidv7.MustBytes(),
|
|
UserID: user.ID,
|
|
TokenHash: tokenHash,
|
|
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
|
|
}
|
|
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
if err := svc.ResetPassword(context.Background(), token, "NewPassword123!"); err != nil {
|
|
t.Fatalf("expected reset to succeed even when bump session version fails, got %v", err)
|
|
}
|
|
if !svc.verifyPassword("NewPassword123!", user.PasswordHash, user.SaltValue) {
|
|
t.Fatalf("expected password to be updated")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_sendInviteSetPassword_Branches(t *testing.T) {
|
|
user := &auth.User{
|
|
ID: uuidv7.MustBytes(),
|
|
Email: "invitee@example.com",
|
|
}
|
|
|
|
t.Run("skip when token store is nil", func(t *testing.T) {
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
SetPasswordURL: "https://app.example.com/set-password",
|
|
InviteTTL: time.Minute,
|
|
}})
|
|
if err := svc.sendInviteSetPassword(context.Background(), user); err != nil {
|
|
t.Fatalf("expected nil error when token store is nil, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("skip when set password url is empty", func(t *testing.T) {
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
InviteTTL: time.Minute,
|
|
}})
|
|
if err := svc.sendInviteSetPassword(context.Background(), user); err != nil {
|
|
t.Fatalf("expected nil error when url is empty, got %v", err)
|
|
}
|
|
if tokens.setCount != 0 {
|
|
t.Fatalf("expected no token stored when url is empty")
|
|
}
|
|
})
|
|
|
|
t.Run("random token generation error", func(t *testing.T) {
|
|
orig := rand.Reader
|
|
rand.Reader = errReader{}
|
|
defer func() { rand.Reader = orig }()
|
|
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
SetPasswordURL: "https://app.example.com/set-password",
|
|
InviteTTL: time.Minute,
|
|
}})
|
|
if err := svc.sendInviteSetPassword(context.Background(), user); !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
t.Fatalf("expected random token error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("token store error", func(t *testing.T) {
|
|
tokens := newMockTokenStore()
|
|
tokens.setErr = errors.New("token set failed")
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
SetPasswordURL: "https://app.example.com/set-password",
|
|
InviteTTL: time.Minute,
|
|
}})
|
|
if err := svc.sendInviteSetPassword(context.Background(), user); !errors.Is(err, tokens.setErr) {
|
|
t.Fatalf("expected token store error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("queue enqueue error", func(t *testing.T) {
|
|
tokens := newMockTokenStore()
|
|
queue := &mockEmailQueue{err: errors.New("queue failed")}
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
SetPasswordURL: "https://app.example.com/set-password",
|
|
InviteTTL: time.Minute,
|
|
}})
|
|
if err := svc.sendInviteSetPassword(context.Background(), user); !errors.Is(err, queue.err) {
|
|
t.Fatalf("expected queue error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("queue success", func(t *testing.T) {
|
|
tokens := newMockTokenStore()
|
|
queue := &mockEmailQueue{}
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
SetPasswordURL: "https://app.example.com/set-password",
|
|
InviteTTL: time.Minute,
|
|
}})
|
|
if err := svc.sendInviteSetPassword(context.Background(), user); err != nil {
|
|
t.Fatalf("expected nil error, got %v", err)
|
|
}
|
|
if queue.last == nil {
|
|
t.Fatalf("expected invite email to be queued")
|
|
}
|
|
if !strings.Contains(queue.last.Body, "token=") {
|
|
t.Fatalf("expected invite email body to include token")
|
|
}
|
|
})
|
|
|
|
t.Run("no queue and no mailer returns nil", func(t *testing.T) {
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
SetPasswordURL: "https://app.example.com/set-password",
|
|
InviteTTL: time.Minute,
|
|
}})
|
|
if err := svc.sendInviteSetPassword(context.Background(), user); err != nil {
|
|
t.Fatalf("expected nil error, got %v", err)
|
|
}
|
|
if tokens.setCount != 1 {
|
|
t.Fatalf("expected invite token to be stored once")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_ResetSetupAndInviteJWT_Branches(t *testing.T) {
|
|
newProtectedAuthService := func(t *testing.T) (*mockAuthRepo, *AuthService, *auth.User) {
|
|
t.Helper()
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
key := make([]byte, 32)
|
|
for i := range key {
|
|
key[i] = byte(i + 1)
|
|
}
|
|
protector, err := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
|
|
if err != nil {
|
|
t.Fatalf("protector: %v", err)
|
|
}
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
JWTAccessSecret: "access",
|
|
InviteTTL: time.Minute,
|
|
SetPasswordURL: "https://app.example.com/set-password",
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
|
|
user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com"}
|
|
hash, salt, err := svc.hashPassword("Password123!")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
user.PasswordHash = hash
|
|
user.SaltValue = salt
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
return repo, svc, user
|
|
}
|
|
|
|
t.Run("delete totp setup branches", func(t *testing.T) {
|
|
repo, svc, user := newProtectedAuthService(t)
|
|
if err := svc.DeleteTOTPSetup(context.Background(), []byte("short")); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token, got %v", err)
|
|
}
|
|
repo.totp[string(user.ID)] = &auth.UserTOTP{UserID: user.ID}
|
|
if err := svc.DeleteTOTPSetup(context.Background(), user.ID); err != nil {
|
|
t.Fatalf("unexpected delete totp error: %v", err)
|
|
}
|
|
if _, ok := repo.totp[string(user.ID)]; ok {
|
|
t.Fatalf("expected totp setup deleted")
|
|
}
|
|
repo.totp[string(user.ID)] = &auth.UserTOTP{UserID: user.ID}
|
|
repo.deleteTOTPErr = errors.New("delete totp failed")
|
|
if err := svc.DeleteTOTPSetup(context.Background(), user.ID); !errors.Is(err, repo.deleteTOTPErr) {
|
|
t.Fatalf("expected delete totp error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("delete webauthn setup branches", func(t *testing.T) {
|
|
repo, svc, user := newProtectedAuthService(t)
|
|
if err := svc.DeleteWebAuthnSetup(context.Background(), []byte("short")); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token, got %v", err)
|
|
}
|
|
repo.webauthnCredentials[string(user.ID)] = []auth.UserWebAuthnCredential{{ID: uuidv7.MustBytes(), UserID: user.ID}}
|
|
if err := svc.DeleteWebAuthnSetup(context.Background(), user.ID); err != nil {
|
|
t.Fatalf("unexpected delete webauthn error: %v", err)
|
|
}
|
|
if len(repo.webauthnCredentials[string(user.ID)]) != 0 {
|
|
t.Fatalf("expected webauthn credentials deleted")
|
|
}
|
|
repo.webauthnCredentials[string(user.ID)] = []auth.UserWebAuthnCredential{{ID: uuidv7.MustBytes(), UserID: user.ID}}
|
|
repo.deleteWebAuthnErr = errors.New("delete webauthn failed")
|
|
if err := svc.DeleteWebAuthnSetup(context.Background(), user.ID); !errors.Is(err, repo.deleteWebAuthnErr) {
|
|
t.Fatalf("expected delete webauthn error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("authorize with pin or password branches", func(t *testing.T) {
|
|
repo, svc, user := newProtectedAuthService(t)
|
|
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "unknown", "", ""); !errors.Is(err, ErrInvalidReauthMethod) {
|
|
t.Fatalf("expected invalid reauth method, got %v", err)
|
|
}
|
|
repo.getByIDErr = errors.New("get user failed")
|
|
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "password", "", "Password123!"); !errors.Is(err, repo.getByIDErr) {
|
|
t.Fatalf("expected repo get error, got %v", err)
|
|
}
|
|
repo.getByIDErr = nil
|
|
delete(repo.users, string(user.ID))
|
|
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "password", "", "Password123!"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token when user missing, got %v", err)
|
|
}
|
|
if err := repo.CreateUser(context.Background(), user); err != nil {
|
|
t.Fatalf("recreate user: %v", err)
|
|
}
|
|
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "password", "", "WrongPassword!"); !errors.Is(err, ErrInvalidCredentials) {
|
|
t.Fatalf("expected invalid credentials, got %v", err)
|
|
}
|
|
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "password", "", "Password123!"); err != nil {
|
|
t.Fatalf("expected password auth success, got %v", err)
|
|
}
|
|
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "pin", "123456", ""); !errors.Is(err, ErrSecurityPINNotSet) {
|
|
t.Fatalf("expected pin not set, got %v", err)
|
|
}
|
|
if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil {
|
|
t.Fatalf("set pin: %v", err)
|
|
}
|
|
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "pin", "000000", ""); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected invalid security pin, got %v", err)
|
|
}
|
|
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "pin", "123456", ""); err != nil {
|
|
t.Fatalf("expected pin auth success, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("reset totp setup branches", func(t *testing.T) {
|
|
repo, svc, user := newProtectedAuthService(t)
|
|
if err := svc.ResetTOTPSetup(context.Background(), []byte("short"), "password", "", "Password123!"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token, got %v", err)
|
|
}
|
|
repo.totp[string(user.ID)] = &auth.UserTOTP{UserID: user.ID}
|
|
if err := svc.ResetTOTPSetup(context.Background(), user.ID, "password", "", "Password123!"); err != nil {
|
|
t.Fatalf("expected reset totp success, got %v", err)
|
|
}
|
|
repo.totp[string(user.ID)] = &auth.UserTOTP{UserID: user.ID}
|
|
if err := svc.ResetTOTPSetup(context.Background(), user.ID, "password", "", "WrongPassword!"); !errors.Is(err, ErrInvalidCredentials) {
|
|
t.Fatalf("expected invalid credentials, got %v", err)
|
|
}
|
|
repo.deleteTOTPErr = errors.New("delete totp failed")
|
|
if err := svc.ResetTOTPSetup(context.Background(), user.ID, "password", "", "Password123!"); !errors.Is(err, repo.deleteTOTPErr) {
|
|
t.Fatalf("expected delete totp error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("reset webauthn setup branches", func(t *testing.T) {
|
|
repo, svc, user := newProtectedAuthService(t)
|
|
if err := svc.ResetWebAuthnSetup(context.Background(), []byte("short"), "password", "", "Password123!"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token, got %v", err)
|
|
}
|
|
repo.webauthnCredentials[string(user.ID)] = []auth.UserWebAuthnCredential{{ID: uuidv7.MustBytes(), UserID: user.ID}}
|
|
if err := svc.ResetWebAuthnSetup(context.Background(), user.ID, "password", "", "Password123!"); err != nil {
|
|
t.Fatalf("expected reset webauthn success, got %v", err)
|
|
}
|
|
repo.webauthnCredentials[string(user.ID)] = []auth.UserWebAuthnCredential{{ID: uuidv7.MustBytes(), UserID: user.ID}}
|
|
if err := svc.ResetWebAuthnSetup(context.Background(), user.ID, "password", "", "WrongPassword!"); !errors.Is(err, ErrInvalidCredentials) {
|
|
t.Fatalf("expected invalid credentials, got %v", err)
|
|
}
|
|
repo.deleteWebAuthnErr = errors.New("delete webauthn failed")
|
|
if err := svc.ResetWebAuthnSetup(context.Background(), user.ID, "password", "", "Password123!"); !errors.Is(err, repo.deleteWebAuthnErr) {
|
|
t.Fatalf("expected delete webauthn error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("issue invite jwt branches", func(t *testing.T) {
|
|
_, svc, user := newProtectedAuthService(t)
|
|
if _, _, err := svc.issueInviteJWT(nil); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for nil user, got %v", err)
|
|
}
|
|
if _, _, err := svc.issueInviteJWT(&auth.User{}); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for invalid user id, got %v", err)
|
|
}
|
|
orig := rand.Reader
|
|
rand.Reader = errReader{}
|
|
if _, _, err := svc.issueInviteJWT(user); !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
t.Fatalf("expected random token error, got %v", err)
|
|
}
|
|
rand.Reader = orig
|
|
token, jti, err := svc.issueInviteJWT(user)
|
|
if err != nil {
|
|
t.Fatalf("unexpected issue invite jwt error: %v", err)
|
|
}
|
|
if strings.TrimSpace(token) == "" || strings.TrimSpace(jti) == "" {
|
|
t.Fatalf("expected token and jti")
|
|
}
|
|
})
|
|
|
|
t.Run("parse invite jwt branches", func(t *testing.T) {
|
|
_, svc, user := newProtectedAuthService(t)
|
|
if _, _, _, err := svc.parseInviteJWT(""); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for empty token, got %v", err)
|
|
}
|
|
svcNoSecret := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if _, _, _, err := svcNoSecret.parseInviteJWT("token"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for missing secret, got %v", err)
|
|
}
|
|
if _, _, _, err := svc.parseInviteJWT("bad"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for malformed jwt, got %v", err)
|
|
}
|
|
|
|
validToken, _, err := svc.issueInviteJWT(user)
|
|
if err != nil {
|
|
t.Fatalf("issue invite jwt: %v", err)
|
|
}
|
|
gotUserID, gotJTI, gotEmail, err := svc.parseInviteJWT(validToken)
|
|
if err != nil {
|
|
t.Fatalf("parse valid invite jwt: %v", err)
|
|
}
|
|
if string(gotUserID) != string(user.ID) || strings.TrimSpace(gotJTI) == "" || gotEmail != strings.ToLower(user.Email) {
|
|
t.Fatalf("unexpected parsed invite jwt values")
|
|
}
|
|
|
|
claimsWrongType := jwt.MapClaims{
|
|
"type": "wrong",
|
|
"sub": mustUUIDStringAuth(t, user.ID),
|
|
"email": strings.ToLower(user.Email),
|
|
"jti": "jti-1",
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(time.Minute).Unix(),
|
|
}
|
|
wrongTypeToken, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsWrongType).SignedString([]byte(svc.cfg.JWTAccessSecret))
|
|
if _, _, _, err := svc.parseInviteJWT(wrongTypeToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for wrong type, got %v", err)
|
|
}
|
|
|
|
claimsMissing := jwt.MapClaims{
|
|
"type": "invite_set_password",
|
|
"sub": mustUUIDStringAuth(t, user.ID),
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(time.Minute).Unix(),
|
|
}
|
|
missingClaimsToken, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsMissing).SignedString([]byte(svc.cfg.JWTAccessSecret))
|
|
if _, _, _, err := svc.parseInviteJWT(missingClaimsToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for missing claims, got %v", err)
|
|
}
|
|
|
|
claimsBadSub := jwt.MapClaims{
|
|
"type": "invite_set_password",
|
|
"sub": "not-a-uuid",
|
|
"email": strings.ToLower(user.Email),
|
|
"jti": "jti-2",
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(time.Minute).Unix(),
|
|
}
|
|
badSubToken, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsBadSub).SignedString([]byte(svc.cfg.JWTAccessSecret))
|
|
if _, _, _, err := svc.parseInviteJWT(badSubToken); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for invalid sub, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func mustUUIDStringAuth(t *testing.T, id []byte) string {
|
|
t.Helper()
|
|
s, err := uuidv7.BytesToString(id)
|
|
if err != nil {
|
|
t.Fatalf("uuid to string: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestAuthService_createAndSendPasswordReset_Branches(t *testing.T) {
|
|
user := &auth.User{
|
|
ID: uuidv7.MustBytes(),
|
|
Email: "user@example.com",
|
|
}
|
|
|
|
baseCfg := config.AuthConfig{
|
|
PasswordResetURL: "https://app.example.com/reset-password",
|
|
PasswordResetTTL: 20 * time.Minute,
|
|
}
|
|
|
|
t.Run("skip when reset url is empty", func(t *testing.T) {
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
PasswordResetURL: "",
|
|
PasswordResetTTL: 20 * time.Minute,
|
|
}})
|
|
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
|
|
t.Fatalf("expected nil error when url is empty, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("random token generation error", func(t *testing.T) {
|
|
orig := rand.Reader
|
|
rand.Reader = errReader{}
|
|
defer func() { rand.Reader = orig }()
|
|
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
t.Fatalf("expected random token error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalidate active tokens error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.invalidateResetTokensErr = errors.New("invalidate failed")
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, repo.invalidateResetTokensErr) {
|
|
t.Fatalf("expected invalidate error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("create token error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.createResetTokenErr = errors.New("create reset failed")
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, repo.createResetTokenErr) {
|
|
t.Fatalf("expected create token error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid reset url fails before token is persisted", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
PasswordResetURL: "http://",
|
|
PasswordResetTTL: 20 * time.Minute,
|
|
}})
|
|
|
|
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); err == nil {
|
|
t.Fatalf("expected invalid password reset url error")
|
|
}
|
|
if repo.lastResetToken != nil {
|
|
t.Fatalf("token should not be persisted when reset url is invalid")
|
|
}
|
|
})
|
|
|
|
t.Run("queue enqueue error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
queue := &mockEmailQueue{err: errors.New("queue failed")}
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, queue.err) {
|
|
t.Fatalf("expected queue error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("queue success", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
queue := &mockEmailQueue{}
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
|
|
t.Fatalf("expected nil error, got %v", err)
|
|
}
|
|
if queue.last == nil {
|
|
t.Fatalf("expected password reset email to be queued")
|
|
}
|
|
if strings.TrimSpace(queue.last.HTMLBody) == "" {
|
|
t.Fatalf("expected non-empty html body in queued reset email")
|
|
}
|
|
})
|
|
|
|
t.Run("no queue and no mailer returns nil", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
|
|
t.Fatalf("expected nil error, got %v", err)
|
|
}
|
|
if repo.lastResetToken == nil {
|
|
t.Fatalf("expected reset token to be persisted")
|
|
}
|
|
})
|
|
}
|
|
|
|
func extractTokenValue(body string) string {
|
|
idx := strings.Index(body, "token=")
|
|
if idx == -1 {
|
|
return ""
|
|
}
|
|
token := body[idx+len("token="):]
|
|
if end := strings.IndexAny(token, " \n\r\t"); end >= 0 {
|
|
token = token[:end]
|
|
}
|
|
return strings.TrimSpace(token)
|
|
}
|
|
|
|
func TestAuthService_int64Claim(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input any
|
|
want int64
|
|
}{
|
|
{name: "nil", input: nil, want: 0},
|
|
{name: "float64", input: float64(123.9), want: 123},
|
|
{name: "float32", input: float32(45.7), want: 45},
|
|
{name: "int", input: int(12), want: 12},
|
|
{name: "int64", input: int64(34), want: 34},
|
|
{name: "int32", input: int32(56), want: 56},
|
|
{name: "uint", input: uint(78), want: 78},
|
|
{name: "uint64", input: uint64(90), want: 90},
|
|
{name: "uint32", input: uint32(21), want: 21},
|
|
{name: "string valid", input: "1234", want: 1234},
|
|
{name: "string invalid", input: "12x", want: 0},
|
|
{name: "default unsupported", input: true, want: 0},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := int64Claim(tc.input)
|
|
if got != tc.want {
|
|
t.Fatalf("expected %d, got %d", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthService_truncate(t *testing.T) {
|
|
t.Run("max less than or equal zero returns original", func(t *testing.T) {
|
|
in := "abcdef"
|
|
if got := truncate(in, 0); got != in {
|
|
t.Fatalf("expected %q, got %q", in, got)
|
|
}
|
|
})
|
|
|
|
t.Run("value shorter than max returns original", func(t *testing.T) {
|
|
in := "abc"
|
|
if got := truncate(in, 5); got != in {
|
|
t.Fatalf("expected %q, got %q", in, got)
|
|
}
|
|
})
|
|
|
|
t.Run("value longer than max is truncated", func(t *testing.T) {
|
|
if got := truncate("abcdef", 3); got != "abc" {
|
|
t.Fatalf("expected %q, got %q", "abc", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_sleepRemaining(t *testing.T) {
|
|
t.Run("min less than or equal zero returns quickly", func(t *testing.T) {
|
|
start := time.Now()
|
|
sleepRemaining(start, 0)
|
|
if elapsed := time.Since(start); elapsed > 20*time.Millisecond {
|
|
t.Fatalf("expected quick return, elapsed=%s", elapsed)
|
|
}
|
|
})
|
|
|
|
t.Run("elapsed already exceeds min so no sleep", func(t *testing.T) {
|
|
start := time.Now().Add(-30 * time.Millisecond)
|
|
before := time.Now()
|
|
sleepRemaining(start, 10*time.Millisecond)
|
|
if elapsed := time.Since(before); elapsed > 20*time.Millisecond {
|
|
t.Fatalf("expected no additional sleep, elapsed=%s", elapsed)
|
|
}
|
|
})
|
|
|
|
t.Run("sleeps remaining duration when elapsed below min", func(t *testing.T) {
|
|
start := time.Now()
|
|
min := 15 * time.Millisecond
|
|
sleepRemaining(start, min)
|
|
if elapsed := time.Since(start); elapsed < 10*time.Millisecond {
|
|
t.Fatalf("expected function to sleep, elapsed=%s", elapsed)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_shortEmailHash(t *testing.T) {
|
|
a := shortEmailHash(" User@Example.com ")
|
|
b := shortEmailHash("user@example.com")
|
|
if len(a) != 12 {
|
|
t.Fatalf("expected short email hash length 12, got %d", len(a))
|
|
}
|
|
if a != b {
|
|
t.Fatalf("expected normalized email hash to be equal, got %q vs %q", a, b)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_sanitizeIP(t *testing.T) {
|
|
t.Run("encrypts ipv4 and can be decrypted", func(t *testing.T) {
|
|
protector := newIPSecretProtector("pepper")
|
|
got1 := sanitizeIP("127.0.0.1", protector)
|
|
got2 := sanitizeIP("127.0.0.1", protector)
|
|
if got1 == got2 {
|
|
t.Fatalf("expected randomized ciphertext, got equal values %q", got1)
|
|
}
|
|
if got1 == "127.0.0.1" {
|
|
t.Fatalf("expected ciphertext, got plaintext %q", got1)
|
|
}
|
|
if plain := decryptStoredIP(got1, protector); plain != "127.0.0.1" {
|
|
t.Fatalf("expected decrypted ipv4 value, got %q", plain)
|
|
}
|
|
})
|
|
|
|
t.Run("normalizes ipv6 before encryption", func(t *testing.T) {
|
|
protector := newIPSecretProtector("pepper")
|
|
got1 := sanitizeIP("2001:0db8:0000:0000:0000:0000:0000:0001", protector)
|
|
got2 := sanitizeIP("2001:db8::1", protector)
|
|
if plain := decryptStoredIP(got1, protector); plain != "2001:db8::1" {
|
|
t.Fatalf("expected canonical decrypted ipv6 value, got %q", plain)
|
|
}
|
|
if plain := decryptStoredIP(got2, protector); plain != "2001:db8::1" {
|
|
t.Fatalf("expected canonical decrypted ipv6 value, got %q", plain)
|
|
}
|
|
})
|
|
|
|
t.Run("wrong key cannot decrypt", func(t *testing.T) {
|
|
enc := sanitizeIP("127.0.0.1", newIPSecretProtector("pepper-a"))
|
|
if plain := decryptStoredIP(enc, newIPSecretProtector("pepper-b")); plain == "127.0.0.1" {
|
|
t.Fatalf("expected decrypt with wrong key to fail")
|
|
}
|
|
})
|
|
|
|
t.Run("encrypts invalid ip input after truncation", func(t *testing.T) {
|
|
raw := strings.Repeat("a", 60)
|
|
protector := newIPSecretProtector("pepper")
|
|
enc := sanitizeIP(raw, protector)
|
|
plain := decryptStoredIP(enc, protector)
|
|
if len(plain) != 45 {
|
|
t.Fatalf("expected decrypted/truncated length 45, got %d", len(plain))
|
|
}
|
|
})
|
|
|
|
t.Run("empty input returns empty", func(t *testing.T) {
|
|
if got := sanitizeIP(" ", newIPSecretProtector("pepper")); got != "" {
|
|
t.Fatalf("expected empty result, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_SecurityPIN_SetVerifyChangeAndToken(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
|
|
key := make([]byte, 32)
|
|
for i := range key {
|
|
key[i] = byte(i + 1)
|
|
}
|
|
protector, err := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
|
|
if err != nil {
|
|
t.Fatalf("new protector: %v", err)
|
|
}
|
|
|
|
userID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{
|
|
ID: userID,
|
|
Email: "user@example.com",
|
|
}
|
|
|
|
cfg := config.AuthConfig{
|
|
SecurityPINActionTokenTTL: 5 * time.Minute,
|
|
SecurityPINMaxAttempts: 3,
|
|
SecurityPINLockDuration: 2 * time.Minute,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
|
|
|
|
if err := svc.SetSecurityPIN(context.Background(), userID, "12a456"); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected invalid pin format error, got %v", err)
|
|
}
|
|
|
|
if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
|
|
t.Fatalf("set pin: %v", err)
|
|
}
|
|
if len(repo.users[string(userID)].SecurityPINHash) == 0 {
|
|
t.Fatalf("expected hashed pin record to be persisted")
|
|
}
|
|
if err := svc.SetSecurityPIN(context.Background(), userID, "654321"); !errors.Is(err, ErrSecurityPINAlreadySet) {
|
|
t.Fatalf("expected already set error, got %v", err)
|
|
}
|
|
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "000000", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected invalid pin on first wrong attempt, got %v", err)
|
|
}
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "000000", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected invalid pin on second wrong attempt, got %v", err)
|
|
}
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "000000", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrSecurityPINAttemptLimit) {
|
|
t.Fatalf("expected attempt-limit error on third wrong attempt, got %v", err)
|
|
}
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrSecurityPINLocked) {
|
|
t.Fatalf("expected blocked error after max attempts, got %v", err)
|
|
}
|
|
|
|
if err := svc.clearSecurityPINLockState(context.Background(), userID); err != nil {
|
|
t.Fatalf("clear lock state: %v", err)
|
|
}
|
|
|
|
token, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74")
|
|
if err != nil {
|
|
t.Fatalf("verify pin for action: %v", err)
|
|
}
|
|
if strings.TrimSpace(token) == "" {
|
|
t.Fatalf("expected non-empty action token")
|
|
}
|
|
if err := svc.ValidateSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token); err != nil {
|
|
t.Fatalf("validate pin action token: %v", err)
|
|
}
|
|
if err := svc.ValidateSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token); err != nil {
|
|
t.Fatalf("validate pin action token second time: %v", err)
|
|
}
|
|
|
|
sessionBoundToken, err := svc.VerifySecurityPINForActionInSession(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", "sid-1")
|
|
if err != nil {
|
|
t.Fatalf("verify pin in session: %v", err)
|
|
}
|
|
if err := svc.ValidateSecurityPINActionTokenInSession(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", sessionBoundToken, "sid-1"); err != nil {
|
|
t.Fatalf("validate session-bound token: %v", err)
|
|
}
|
|
if err := svc.ValidateSecurityPINActionTokenInSession(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", sessionBoundToken, "sid-2"); !errors.Is(err, ErrInvalidPINAction) {
|
|
t.Fatalf("expected invalid pin action for mismatched session, got %v", err)
|
|
}
|
|
|
|
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, "428fdf48-3f99-46ea-bc47-8fb2e312be08", token); !errors.Is(err, ErrInvalidPINAction) {
|
|
t.Fatalf("expected invalid pin action for mismatched action, got %v", err)
|
|
}
|
|
|
|
token2, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74")
|
|
if err != nil {
|
|
t.Fatalf("verify pin second token: %v", err)
|
|
}
|
|
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token2); err != nil {
|
|
t.Fatalf("consume pin action token: %v", err)
|
|
}
|
|
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token2); !errors.Is(err, ErrInvalidPINAction) {
|
|
t.Fatalf("expected token to be one-time use, got %v", err)
|
|
}
|
|
|
|
if err := svc.ChangeSecurityPIN(context.Background(), userID, "111111", "654321"); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected invalid current pin, got %v", err)
|
|
}
|
|
if err := svc.clearSecurityPINLockState(context.Background(), userID); err != nil {
|
|
t.Fatalf("clear lock state after failed change: %v", err)
|
|
}
|
|
if err := svc.ChangeSecurityPIN(context.Background(), userID, "123456", "654321"); err != nil {
|
|
t.Fatalf("change pin: %v", err)
|
|
}
|
|
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected old pin invalid after change, got %v", err)
|
|
}
|
|
if err := svc.clearSecurityPINLockState(context.Background(), userID); err != nil {
|
|
t.Fatalf("clear lock state after old pin verify: %v", err)
|
|
}
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "654321", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); err != nil {
|
|
t.Fatalf("expected new pin to verify, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ForgotAndResetSecurityPIN(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
queue := &mockEmailQueue{}
|
|
|
|
key := make([]byte, 32)
|
|
for i := range key {
|
|
key[i] = byte(i + 1)
|
|
}
|
|
protector, err := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
|
|
if err != nil {
|
|
t.Fatalf("new protector: %v", err)
|
|
}
|
|
|
|
userID := uuidv7.MustBytes()
|
|
user := &auth.User{
|
|
ID: userID,
|
|
Email: "user@example.com",
|
|
}
|
|
repo.users[string(userID)] = user
|
|
repo.usersByMail[user.Email] = user
|
|
|
|
cfg := config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
SecurityPINResetURL: "https://app.example.com/reset-pin",
|
|
SecurityPINResetTTL: 20 * time.Minute,
|
|
ForgotSecurityPINCooldown: 2 * time.Minute,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, queue, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
|
|
hash, salt, err := svc.hashPassword("Password123!")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
user.PasswordHash = hash
|
|
user.SaltValue = salt
|
|
|
|
svc.ForgotSecurityPIN(context.Background(), user.Email, "127.0.0.1", "ua-test")
|
|
if repo.lastSecurityPINResetToken == nil {
|
|
t.Fatalf("expected security pin reset token to be created")
|
|
}
|
|
if queue.last == nil {
|
|
t.Fatalf("expected reset email to be enqueued")
|
|
}
|
|
rawToken := extractTokenValue(queue.last.Body)
|
|
if rawToken == "" {
|
|
t.Fatalf("expected token in reset email body")
|
|
}
|
|
|
|
if err := svc.ResetSecurityPIN(context.Background(), rawToken, "password", "Password123!", "", "12345"); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected invalid pin format error, got %v", err)
|
|
}
|
|
|
|
if err := svc.ResetSecurityPIN(context.Background(), rawToken, "password", "Password123!", "", "123456"); err != nil {
|
|
t.Fatalf("reset security pin: %v", err)
|
|
}
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); err != nil {
|
|
t.Fatalf("expected reset pin to verify, got %v", err)
|
|
}
|
|
|
|
if err := svc.ResetSecurityPIN(context.Background(), rawToken, "password", "Password123!", "", "654321"); !errors.Is(err, ErrInvalidResetLink) {
|
|
t.Fatalf("expected one-time token invalid after use, got %v", err)
|
|
}
|
|
|
|
countBefore := len(repo.securityPINResetTokens)
|
|
svc.ForgotSecurityPIN(context.Background(), "missing@example.com", "127.0.0.1", "ua-test")
|
|
if len(repo.securityPINResetTokens) != countBefore {
|
|
t.Fatalf("expected no new security pin token for missing account")
|
|
}
|
|
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:security_pin:cooldown:") {
|
|
t.Fatalf("expected security pin cooldown key to be set, got %q", tokens.lastKey)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RequestSecurityPINReset(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
queue := &mockEmailQueue{}
|
|
|
|
userID := uuidv7.MustBytes()
|
|
user := &auth.User{
|
|
ID: userID,
|
|
Email: "user@example.com",
|
|
}
|
|
repo.users[string(userID)] = user
|
|
repo.usersByMail[user.Email] = user
|
|
|
|
cfg := config.AuthConfig{
|
|
SecurityPINResetURL: "https://app.example.com/reset-pin",
|
|
SecurityPINResetTTL: 20 * time.Minute,
|
|
ForgotSecurityPINCooldown: 2 * time.Minute,
|
|
SecurityPINMaxAttempts: 5,
|
|
SecurityPINLockDuration: 0,
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
|
|
hash, salt, err := svc.hashPassword("Password123!")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
user.PasswordHash = hash
|
|
user.SaltValue = salt
|
|
|
|
tokens.store[securityPINLockKey(userID)] = []byte("1")
|
|
|
|
if err := svc.RequestSecurityPINReset(context.Background(), userID, "password", "Password123!", "", "127.0.0.1", "ua"); err != nil {
|
|
t.Fatalf("request security pin reset: %v", err)
|
|
}
|
|
if queue.last == nil {
|
|
t.Fatalf("expected reset email to be enqueued")
|
|
}
|
|
|
|
if err := svc.RequestSecurityPINReset(context.Background(), userID, "password", "Password123!", "", "127.0.0.1", "ua"); !errors.Is(err, ErrSecurityPINResetRatelimit) {
|
|
t.Fatalf("expected cooldown rate-limit error, got %v", err)
|
|
}
|
|
|
|
delete(tokens.store, forgotSecurityPINCooldownKey(user.Email))
|
|
if err := svc.RequestSecurityPINReset(context.Background(), userID, "password", "wrong-password", "", "127.0.0.1", "ua"); !errors.Is(err, ErrInvalidPINResetPassword) {
|
|
t.Fatalf("expected invalid password error, got %v", err)
|
|
}
|
|
|
|
delete(tokens.store, securityPINLockKey(userID))
|
|
if err := svc.RequestSecurityPINReset(context.Background(), userID, "password", "Password123!", "", "127.0.0.1", "ua"); !errors.Is(err, ErrSecurityPINNotBlocked) {
|
|
t.Fatalf("expected not blocked error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_MicrosoftReauthPINReset(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
roles := newMockRoleRepo()
|
|
tokens := newMockTokenStore()
|
|
queue := &mockEmailQueue{}
|
|
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
|
|
|
|
userRoleID := uuidv7.MustBytes()
|
|
roles.rolesByName["user"] = &auth.Role{ID: userRoleID, Name: "user"}
|
|
|
|
cfg := config.AuthConfig{
|
|
SecurityPINResetURL: "https://app.example.com/reset-pin",
|
|
SecurityPINResetTTL: 20 * time.Minute,
|
|
ForgotSecurityPINCooldown: 2 * time.Minute,
|
|
SecurityPINMaxAttempts: 5,
|
|
SecurityPINLockDuration: 0,
|
|
SSOStateTTL: time.Minute,
|
|
}
|
|
svc := NewAuthService(repo, roles, tokens, queue, 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",
|
|
Timezone: tzpkg.WithDefault(""),
|
|
RoleID: userRoleID,
|
|
}); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
_ = repo.CreateIdentity(context.Background(), identity)
|
|
tokens.store[securityPINLockKey(identity.UserID)] = []byte("1")
|
|
|
|
authURL, err := svc.BeginMicrosoftReauth(context.Background(), identity.UserID, microsoftReauthPurposePINResetRequest)
|
|
if err != nil {
|
|
t.Fatalf("begin reauth: %v", err)
|
|
}
|
|
parsedURL, parseErr := url.Parse(authURL)
|
|
if parseErr != nil {
|
|
t.Fatalf("parse auth url: %v", parseErr)
|
|
}
|
|
state := parsedURL.Query().Get("state")
|
|
reauthToken, purpose, err := svc.CompleteMicrosoftReauth(context.Background(), "code", state)
|
|
if err != nil {
|
|
t.Fatalf("complete reauth: %v", err)
|
|
}
|
|
if purpose != microsoftReauthPurposePINResetRequest {
|
|
t.Fatalf("unexpected purpose: %s", purpose)
|
|
}
|
|
|
|
if err := svc.RequestSecurityPINReset(context.Background(), identity.UserID, "microsoft", "", reauthToken, "127.0.0.1", "ua"); err != nil {
|
|
t.Fatalf("request pin reset with microsoft: %v", err)
|
|
}
|
|
if queue.last == nil {
|
|
t.Fatalf("expected reset email to be enqueued")
|
|
}
|
|
|
|
authURL, err = svc.BeginMicrosoftReauth(context.Background(), identity.UserID, microsoftReauthPurposePINResetRequest)
|
|
if err != nil {
|
|
t.Fatalf("begin pin reset direct reauth: %v", err)
|
|
}
|
|
parsedURL, parseErr = url.Parse(authURL)
|
|
if parseErr != nil {
|
|
t.Fatalf("parse auth url: %v", parseErr)
|
|
}
|
|
state = parsedURL.Query().Get("state")
|
|
reauthToken, purpose, err = svc.CompleteMicrosoftReauth(context.Background(), "code", state)
|
|
if err != nil {
|
|
t.Fatalf("complete pin reset direct reauth: %v", err)
|
|
}
|
|
if purpose != microsoftReauthPurposePINResetRequest {
|
|
t.Fatalf("unexpected direct reset purpose: %s", purpose)
|
|
}
|
|
if err := svc.ResetSecurityPIN(context.Background(), "", "microsoft", "", reauthToken, "123456"); err != nil {
|
|
t.Fatalf("reset pin with microsoft reauth: %v", err)
|
|
}
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), identity.UserID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); err != nil {
|
|
t.Fatalf("expected microsoft reset pin to verify, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ConsumeSecurityPINActionToken_Branches(t *testing.T) {
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
userID := uuidv7.MustBytes()
|
|
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", "x"); !errors.Is(err, ErrInvalidPINAction) {
|
|
t.Fatalf("expected invalid pin action when token store is nil, got %v", err)
|
|
}
|
|
|
|
tokens := newMockTokenStore()
|
|
svc = NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svc.ConsumeSecurityPINActionToken(context.Background(), []byte("short"), "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", "x"); !errors.Is(err, ErrInvalidPINAction) {
|
|
t.Fatalf("expected invalid user id error, got %v", err)
|
|
}
|
|
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, " ", "x"); !errors.Is(err, ErrInvalidPINAction) {
|
|
t.Fatalf("expected invalid action error, got %v", err)
|
|
}
|
|
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", ""); !errors.Is(err, ErrInvalidPINAction) {
|
|
t.Fatalf("expected invalid token error, got %v", err)
|
|
}
|
|
|
|
t.Run("session mismatch does not consume token", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
tokens := newMockTokenStore()
|
|
svc := NewAuthService(repo, newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
SecurityPINActionTokenTTL: time.Minute,
|
|
}})
|
|
|
|
uid := uuidv7.MustBytes()
|
|
pinHash, err := svc.hashSecurityPIN("123456")
|
|
if err != nil {
|
|
t.Fatalf("hash pin: %v", err)
|
|
}
|
|
repo.users[string(uid)] = &auth.User{ID: uid, SecurityPINHash: pinHash}
|
|
|
|
token, err := svc.VerifySecurityPINForActionInSession(context.Background(), uid, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", "sid-1")
|
|
if err != nil {
|
|
t.Fatalf("issue action token: %v", err)
|
|
}
|
|
if err := svc.ConsumeSecurityPINActionTokenInSession(context.Background(), uid, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token, "sid-2"); !errors.Is(err, ErrInvalidPINAction) {
|
|
t.Fatalf("expected session mismatch invalid action, got %v", err)
|
|
}
|
|
if err := svc.ConsumeSecurityPINActionTokenInSession(context.Background(), uid, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token, "sid-1"); err != nil {
|
|
t.Fatalf("expected token still valid after mismatch, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_buildSecurityPINResetEmail(t *testing.T) {
|
|
resolver := authEmailBrandingResolverMock{
|
|
branding: AuthEmailBranding{
|
|
CompanyName: "Acme Air Rescue",
|
|
LogoContentID: "branding-email-logo",
|
|
LogoAttachment: &queue.EmailAttachment{
|
|
Filename: "email-logo.png",
|
|
ContentType: "image/png",
|
|
ContentID: "branding-email-logo",
|
|
Content: []byte("png"),
|
|
Inline: true,
|
|
},
|
|
},
|
|
}
|
|
email := buildSecurityPINResetEmail(context.Background(), resolver, "Marco", "https://app.example.com/reset-pin?token=abc", 20*time.Minute)
|
|
if !strings.Contains(email.Subject, "security PIN") {
|
|
t.Fatalf("unexpected subject: %q", email.Subject)
|
|
}
|
|
if !strings.Contains(email.TextBody, "6-digit PIN") {
|
|
t.Fatalf("expected 6-digit instruction in text body")
|
|
}
|
|
if !strings.Contains(email.HTMLBody, "Reset Security PIN") {
|
|
t.Fatalf("expected reset button text in html body")
|
|
}
|
|
if !strings.Contains(email.HTMLBody, "cid:"+resolver.branding.LogoContentID) {
|
|
t.Fatalf("expected cid logo reference in html body")
|
|
}
|
|
if !strings.Contains(email.HTMLBody, resolver.branding.CompanyName) {
|
|
t.Fatalf("expected branding company name in html body")
|
|
}
|
|
if len(email.Attachments) != 1 || !email.Attachments[0].Inline || email.Attachments[0].ContentID != resolver.branding.LogoContentID {
|
|
t.Fatalf("expected inline logo attachment, got %#v", email.Attachments)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_validateSecurityPIN(t *testing.T) {
|
|
if err := validateSecurityPIN("123456"); err != nil {
|
|
t.Fatalf("expected valid pin, got %v", err)
|
|
}
|
|
if err := validateSecurityPIN("12345"); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected invalid length error, got %v", err)
|
|
}
|
|
if err := validateSecurityPIN("12345a"); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected invalid char error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_SecurityPIN_Branches(t *testing.T) {
|
|
t.Run("set pin invalid user id", func(t *testing.T) {
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svc.SetSecurityPIN(context.Background(), []byte("short"), "123456"); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected invalid pin error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("set pin without protector", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
|
|
t.Fatalf("expected set pin to work without protector, got %v", err)
|
|
}
|
|
if len(repo.users[string(userID)].SecurityPINHash) == 0 {
|
|
t.Fatalf("expected hashed pin to be persisted")
|
|
}
|
|
})
|
|
|
|
t.Run("set pin user not found", func(t *testing.T) {
|
|
p := &errorProtector{}
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{}})
|
|
if err := svc.SetSecurityPIN(context.Background(), uuidv7.MustBytes(), "123456"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("expected invalid token for missing user, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("set pin stores hashed record", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
|
|
t.Fatalf("set pin failed: %v", err)
|
|
}
|
|
raw := repo.users[string(userID)].SecurityPINHash
|
|
if len(raw) == 0 {
|
|
t.Fatalf("expected pin record to be stored")
|
|
}
|
|
if bytes.Equal(raw, []byte("123456")) {
|
|
t.Fatalf("pin must not be stored in plaintext")
|
|
}
|
|
parts := strings.Split(string(raw), "$")
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
t.Fatalf("expected hash record format <salt_b64>$<hash_b64>, got %q", string(raw))
|
|
}
|
|
})
|
|
|
|
t.Run("verify pin supports legacy v1 hash record", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
}})
|
|
|
|
hash, salt, err := svc.hashPassword("123456")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
repo.users[string(userID)].SecurityPINHash = []byte(
|
|
"v1$" + base64.RawURLEncoding.EncodeToString(salt) + "$" + base64.RawURLEncoding.EncodeToString(hash),
|
|
)
|
|
|
|
if err := svc.verifySecurityPIN(context.Background(), userID, "123456"); err != nil {
|
|
t.Fatalf("expected legacy v1 pin hash to verify, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("change pin invalid user id", func(t *testing.T) {
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svc.ChangeSecurityPIN(context.Background(), []byte("short"), "123456", "654321"); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected invalid pin error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("change pin new pin format invalid", func(t *testing.T) {
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svc.ChangeSecurityPIN(context.Background(), uuidv7.MustBytes(), "123456", "xx"); !errors.Is(err, ErrInvalidSecurityPIN) {
|
|
t.Fatalf("expected invalid new pin format, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("verify pin not set", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
|
|
p := &errorProtector{}
|
|
svc := NewAuthService(repo, newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{
|
|
SecurityPINActionTokenTTL: time.Minute,
|
|
}})
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrSecurityPINNotSet) {
|
|
t.Fatalf("expected pin not set error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("verify pin decrypt error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{
|
|
ID: userID,
|
|
Email: "user@example.com",
|
|
SecurityPINHash: []byte("cipher"),
|
|
}
|
|
p := &errorProtector{decryptErr: errors.New("decrypt failed")}
|
|
svc := NewAuthService(repo, newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{
|
|
SecurityPINActionTokenTTL: time.Minute,
|
|
}})
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, p.decryptErr) {
|
|
t.Fatalf("expected decrypt error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("verify pin missing action", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{
|
|
ID: userID,
|
|
Email: "user@example.com",
|
|
SecurityPINHash: []byte("cipher"),
|
|
}
|
|
p := &errorProtector{}
|
|
svc := NewAuthService(repo, newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{
|
|
SecurityPINActionTokenTTL: time.Minute,
|
|
}})
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", " "); err == nil {
|
|
t.Fatalf("expected missing action error")
|
|
}
|
|
})
|
|
|
|
t.Run("verify pin token set error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
userID := uuidv7.MustBytes()
|
|
repo.users[string(userID)] = &auth.User{
|
|
ID: userID,
|
|
Email: "user@example.com",
|
|
SecurityPINHash: []byte("cipher"),
|
|
}
|
|
p := &errorProtector{}
|
|
tokens := newMockTokenStore()
|
|
tokens.setErr = errors.New("set failed")
|
|
svc := NewAuthService(repo, newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{
|
|
SecurityPINActionTokenTTL: time.Minute,
|
|
}})
|
|
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, tokens.setErr) {
|
|
t.Fatalf("expected token set error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_ForgotSecurityPIN_Branches(t *testing.T) {
|
|
t.Run("empty email", func(t *testing.T) {
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
ForgotSecurityPINMinDuration: 0,
|
|
}})
|
|
svc.ForgotSecurityPIN(context.Background(), " ", "127.0.0.1", "ua")
|
|
})
|
|
|
|
t.Run("cooldown active", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
tokens := newMockTokenStore()
|
|
email := "user@example.com"
|
|
tokens.store[forgotSecurityPINCooldownKey(email)] = []byte("1")
|
|
|
|
svc := NewAuthService(repo, newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
ForgotSecurityPINCooldown: 2 * time.Minute,
|
|
}})
|
|
svc.ForgotSecurityPIN(context.Background(), email, "127.0.0.1", "ua")
|
|
if repo.lastSecurityPINResetToken != nil {
|
|
t.Fatalf("expected no token created while cooldown is active")
|
|
}
|
|
})
|
|
|
|
t.Run("lookup error still marks cooldown", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.getByMailErr = errors.New("db down")
|
|
tokens := newMockTokenStore()
|
|
|
|
svc := NewAuthService(repo, newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
ForgotSecurityPINCooldown: 2 * time.Minute,
|
|
}})
|
|
svc.ForgotSecurityPIN(context.Background(), "user@example.com", "127.0.0.1", "ua")
|
|
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:security_pin:cooldown:") {
|
|
t.Fatalf("expected cooldown key to be marked, got %q", tokens.lastKey)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_createAndSendSecurityPINReset_Branches(t *testing.T) {
|
|
user := &auth.User{
|
|
ID: uuidv7.MustBytes(),
|
|
Email: "user@example.com",
|
|
}
|
|
|
|
baseCfg := config.AuthConfig{
|
|
SecurityPINResetURL: "https://app.example.com/reset-pin",
|
|
SecurityPINResetTTL: 20 * time.Minute,
|
|
}
|
|
|
|
t.Run("skip when reset url is empty", func(t *testing.T) {
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
SecurityPINResetURL: "",
|
|
SecurityPINResetTTL: 20 * time.Minute,
|
|
}})
|
|
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
|
|
t.Fatalf("expected nil error when url is empty, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("random token generation error", func(t *testing.T) {
|
|
orig := rand.Reader
|
|
rand.Reader = errReader{}
|
|
defer func() { rand.Reader = orig }()
|
|
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
t.Fatalf("expected random token error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalidate active tokens error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.invalidatePINResetTokensErr = errors.New("invalidate failed")
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, repo.invalidatePINResetTokensErr) {
|
|
t.Fatalf("expected invalidate error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("create token error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
repo.createPINResetTokenErr = errors.New("create pin reset failed")
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, repo.createPINResetTokenErr) {
|
|
t.Fatalf("expected create token error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid reset url fails before token is persisted", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
SecurityPINResetURL: "http://",
|
|
SecurityPINResetTTL: 20 * time.Minute,
|
|
}})
|
|
|
|
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); err == nil {
|
|
t.Fatalf("expected invalid security pin reset url error")
|
|
}
|
|
if repo.lastSecurityPINResetToken != nil {
|
|
t.Fatalf("token should not be persisted when reset url is invalid")
|
|
}
|
|
})
|
|
|
|
t.Run("queue enqueue error", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
queue := &mockEmailQueue{err: errors.New("queue failed")}
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, queue.err) {
|
|
t.Fatalf("expected queue error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("queue success", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
queue := &mockEmailQueue{}
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
|
|
t.Fatalf("expected nil error, got %v", err)
|
|
}
|
|
if queue.last == nil {
|
|
t.Fatalf("expected security pin reset email to be queued")
|
|
}
|
|
if strings.TrimSpace(queue.last.HTMLBody) == "" {
|
|
t.Fatalf("expected non-empty html body in queued security pin reset email")
|
|
}
|
|
})
|
|
|
|
t.Run("no queue and no mailer returns nil", func(t *testing.T) {
|
|
repo := newMockAuthRepo()
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
|
|
|
|
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
|
|
t.Fatalf("expected nil error, got %v", err)
|
|
}
|
|
if repo.lastSecurityPINResetToken == nil {
|
|
t.Fatalf("expected security pin reset token to be persisted")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_ResetSecurityPIN_Branches(t *testing.T) {
|
|
userID := uuidv7.MustBytes()
|
|
repo := newMockAuthRepo()
|
|
repo.users[string(userID)] = &auth.User{
|
|
ID: userID,
|
|
Email: "user@example.com",
|
|
PasswordHash: []byte("hash"),
|
|
SaltValue: []byte("salt"),
|
|
}
|
|
repo.securityPINResetTokens[string(hashToken("token"))] = &auth.SecurityPINResetToken{
|
|
ID: uuidv7.MustBytes(),
|
|
UserID: userID,
|
|
TokenHash: hashToken("token"),
|
|
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
|
|
}
|
|
|
|
t.Run("missing token", func(t *testing.T) {
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svc.ResetSecurityPIN(context.Background(), " ", "password", "Password123!", "", "123456"); !errors.Is(err, ErrInvalidResetLink) {
|
|
t.Fatalf("expected invalid reset link, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("missing password", func(t *testing.T) {
|
|
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svc.ResetSecurityPIN(context.Background(), "token", "password", " ", "", "123456"); !errors.Is(err, ErrInvalidPINResetPassword) {
|
|
t.Fatalf("expected invalid pin reset password, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("reset pin works without protector", func(t *testing.T) {
|
|
password := "Password123!"
|
|
repo2 := newMockAuthRepo()
|
|
repo2.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
|
|
repo2.securityPINResetTokens[string(hashToken("token"))] = &auth.SecurityPINResetToken{
|
|
ID: uuidv7.MustBytes(),
|
|
UserID: userID,
|
|
TokenHash: hashToken("token"),
|
|
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
|
|
}
|
|
svc := NewAuthService(repo2, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
}})
|
|
hash, salt, err := svc.hashPassword(password)
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
repo2.users[string(userID)].PasswordHash = hash
|
|
repo2.users[string(userID)].SaltValue = salt
|
|
if err := svc.ResetSecurityPIN(context.Background(), "token", "password", password, "", "123456"); err != nil {
|
|
t.Fatalf("expected reset pin success without protector, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("microsoft reset works without reset token", func(t *testing.T) {
|
|
repo2 := newMockAuthRepo()
|
|
tokens2 := newMockTokenStore()
|
|
repo2.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
|
|
token := "reauth-microsoft"
|
|
tokens2.store[microsoftReauthKey(token)] = []byte(bytesToString(userID) + "|" + microsoftReauthPurposePINResetRequest)
|
|
svc := NewAuthService(repo2, newMockRoleRepo(), tokens2, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
|
|
if err := svc.ResetSecurityPIN(context.Background(), "", "microsoft", "", token, "123456"); err != nil {
|
|
t.Fatalf("expected microsoft reset pin success without reset token, got %v", err)
|
|
}
|
|
if len(repo2.users[string(userID)].SecurityPINHash) == 0 {
|
|
t.Fatalf("expected security pin hash to be updated")
|
|
}
|
|
})
|
|
|
|
t.Run("consume error", func(t *testing.T) {
|
|
password := "Password123!"
|
|
p := &errorProtector{}
|
|
repo2 := newMockAuthRepo()
|
|
repo2.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
|
|
repo2.securityPINResetTokens[string(hashToken("token"))] = &auth.SecurityPINResetToken{
|
|
ID: uuidv7.MustBytes(),
|
|
UserID: userID,
|
|
TokenHash: hashToken("token"),
|
|
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
|
|
}
|
|
repo2.consumePINResetErr = errors.New("consume failed")
|
|
svc := NewAuthService(repo2, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{
|
|
PasswordPepper: "pepper",
|
|
HashTime: 1,
|
|
HashMemoryKB: 64 * 1024,
|
|
HashThreads: 4,
|
|
HashKeyLen: 32,
|
|
}})
|
|
hash, salt, err := svc.hashPassword(password)
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
repo2.users[string(userID)].PasswordHash = hash
|
|
repo2.users[string(userID)].SaltValue = salt
|
|
if err := svc.ResetSecurityPIN(context.Background(), "token", "password", password, "", "123456"); !errors.Is(err, repo2.consumePINResetErr) {
|
|
t.Fatalf("expected consume error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthService_ForgotSecurityPINCooldownHelpers(t *testing.T) {
|
|
svcNoTokens := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
ForgotSecurityPINCooldown: 2 * time.Minute,
|
|
}})
|
|
if svcNoTokens.isForgotSecurityPINCooldownActive(context.Background(), "k") {
|
|
t.Fatalf("expected inactive cooldown when token store is nil")
|
|
}
|
|
if err := svcNoTokens.markForgotSecurityPINCooldown(context.Background(), "k"); err != nil {
|
|
t.Fatalf("expected nil when token store is nil, got %v", err)
|
|
}
|
|
|
|
tokens := newMockTokenStore()
|
|
svcDisabled := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
ForgotSecurityPINCooldown: 0,
|
|
}})
|
|
if svcDisabled.isForgotSecurityPINCooldownActive(context.Background(), "k") {
|
|
t.Fatalf("expected inactive cooldown when cooldown disabled")
|
|
}
|
|
if err := svcDisabled.markForgotSecurityPINCooldown(context.Background(), "k"); err != nil {
|
|
t.Fatalf("expected nil when cooldown disabled, got %v", err)
|
|
}
|
|
|
|
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
|
|
ForgotSecurityPINCooldown: 2 * time.Minute,
|
|
}})
|
|
if err := svc.markForgotSecurityPINCooldown(context.Background(), "k1"); err != nil {
|
|
t.Fatalf("mark cooldown: %v", err)
|
|
}
|
|
if !svc.isForgotSecurityPINCooldownActive(context.Background(), "k1") {
|
|
t.Fatalf("expected cooldown active after mark")
|
|
}
|
|
}
|