Files
fm_be/internal/service/auth_webauthn_test.go
2026-07-16 22:16:45 +07:00

1008 lines
39 KiB
Go

package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"time"
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
"wucher/internal/config"
"wucher/internal/domain/auth"
"wucher/internal/shared/pkg/uuidv7"
)
const (
testCredentialCreationResponseJSON = `{
"id":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
"rawId":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
"type":"public-key",
"authenticatorAttachment":"platform",
"clientExtensionResults":{"appid":true},
"response":{
"attestationObject":"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw",
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJXOEd6RlU4cEdqaG9SYldyTERsYW1BZnFfeTRTMUNaRzFWdW9lUkxBUnJFIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ",
"transports":["usb","nfc","fake"]
}
}`
testCredentialAssertionResponseJSON = `{
"id":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng",
"rawId":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng",
"clientExtensionResults":{"appID":"example.com"},
"type":"public-key",
"response":{
"authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ",
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9",
"signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc",
"userHandle":"0ToAAAAAAAAAAA"
}
}`
)
type webAuthnRepoWithErrors struct {
*mockAuthRepo
listCredsErr error
createCredErr error
updateCredErr error
}
func newWebAuthnRepoWithErrors() *webAuthnRepoWithErrors {
return &webAuthnRepoWithErrors{mockAuthRepo: newMockAuthRepo()}
}
func (r *webAuthnRepoWithErrors) ListUserWebAuthnCredentials(ctx context.Context, userID []byte) ([]auth.UserWebAuthnCredential, error) {
if r.listCredsErr != nil {
return nil, r.listCredsErr
}
return r.mockAuthRepo.ListUserWebAuthnCredentials(ctx, userID)
}
func (r *webAuthnRepoWithErrors) CreateUserWebAuthnCredential(ctx context.Context, credential *auth.UserWebAuthnCredential) error {
if r.createCredErr != nil {
return r.createCredErr
}
return r.mockAuthRepo.CreateUserWebAuthnCredential(ctx, credential)
}
func (r *webAuthnRepoWithErrors) UpdateUserWebAuthnCredential(ctx context.Context, userID, credentialID, credentialJSON []byte, usedAt time.Time) error {
if r.updateCredErr != nil {
return r.updateCredErr
}
return r.mockAuthRepo.UpdateUserWebAuthnCredential(ctx, userID, credentialID, credentialJSON, usedAt)
}
func newConfiguredWebAuthnService(repo auth.Repository, tokens TokenStore) *AuthService {
return NewAuthService(
repo,
newMockRoleRepo(),
tokens,
nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
WebAuthnRPID: "example.com",
WebAuthnRPDisplayName: "Wucher Test",
WebAuthnRPOrigins: []string{"https://example.com"},
WebAuthnChallengeTTL: 2 * time.Minute,
}},
)
}
func putWebAuthnChallengeToken(t *testing.T, tokens *mockTokenStore, token, ceremony string, userID []byte, expires time.Time) {
t.Helper()
envelope := webAuthnChallengeEnvelope{
Ceremony: ceremony,
UserID: append([]byte(nil), userID...),
Session: webauthnlib.SessionData{
Challenge: "challenge",
RelyingPartyID: "example.com",
UserID: append([]byte(nil), userID...),
Expires: expires,
},
}
payload, err := json.Marshal(envelope)
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
tokens.store[webAuthnChallengeKey(token)] = payload
}
func TestWebAuthnUserMethods(t *testing.T) {
user := webAuthnUser{
id: []byte{1, 2, 3},
name: "user@example.com",
displayName: "User Example",
credentials: []webauthnlib.Credential{{ID: []byte{9, 8, 7}}},
}
id := user.WebAuthnID()
if !bytes.Equal(id, []byte{1, 2, 3}) {
t.Fatalf("unexpected id: %v", id)
}
id[0] = 99
if user.id[0] == 99 {
t.Fatalf("expected returned id to be copied")
}
if user.WebAuthnName() != "user@example.com" {
t.Fatalf("unexpected name")
}
if user.WebAuthnDisplayName() != "User Example" {
t.Fatalf("unexpected display name")
}
creds := user.WebAuthnCredentials()
if len(creds) != 1 {
t.Fatalf("unexpected credential length")
}
creds = append(creds, webauthnlib.Credential{})
if len(user.credentials) != 1 {
t.Fatalf("expected credential slice header to be copied")
}
}
func TestBuildWebAuthnUser(t *testing.T) {
uid := uuidv7.MustBytes()
credential := webauthnlib.Credential{ID: []byte{1, 2, 3}}
user := &auth.User{
ID: uid,
Email: " alice@example.com ",
FirstName: " Alice ",
LastName: " Smith ",
IsActive: true,
}
waUser := buildWebAuthnUser(user, []webauthnlib.Credential{credential})
if waUser.WebAuthnName() != "alice@example.com" {
t.Fatalf("unexpected webauthn name")
}
if waUser.WebAuthnDisplayName() != "Alice Smith" {
t.Fatalf("unexpected webauthn display name")
}
if !bytes.Equal(waUser.WebAuthnID(), uid) {
t.Fatalf("unexpected webauthn id")
}
fallback := buildWebAuthnUser(&auth.User{
ID: uid,
Email: "bob@example.com",
FirstName: " ",
LastName: " ",
IsActive: true,
}, nil)
if fallback.WebAuthnDisplayName() != "bob@example.com" {
t.Fatalf("expected display name fallback to email")
}
}
func TestAuthServiceIsWebAuthnConfigured(t *testing.T) {
t.Run("no credentials", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
svc := newConfiguredWebAuthnService(repo, newMockTokenStore())
ok, err := svc.IsWebAuthnConfigured(context.Background(), uuidv7.MustBytes())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Fatalf("expected not configured")
}
})
t.Run("has credentials", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
userID := uuidv7.MustBytes()
repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
ID: uuidv7.MustBytes(),
UserID: userID,
CredentialID: []byte("cred-1"),
}}
svc := newConfiguredWebAuthnService(repo, newMockTokenStore())
ok, err := svc.IsWebAuthnConfigured(context.Background(), userID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatalf("expected configured")
}
})
t.Run("repo error", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
repo.listCredsErr = errors.New("list failed")
svc := newConfiguredWebAuthnService(repo, newMockTokenStore())
ok, err := svc.IsWebAuthnConfigured(context.Background(), uuidv7.MustBytes())
if !errors.Is(err, repo.listCredsErr) {
t.Fatalf("expected repo error, got %v", err)
}
if ok {
t.Fatalf("expected configured false on error")
}
})
}
func TestAuthServiceWebAuthnChallengeTTL(t *testing.T) {
svcDefault := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
WebAuthnChallengeTTL: 0,
}})
if got := svcDefault.WebAuthnChallengeTTL(); got != 5*time.Minute {
t.Fatalf("expected default ttl 5m, got %v", got)
}
svcCustom := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
WebAuthnChallengeTTL: 42 * time.Second,
}})
if got := svcCustom.WebAuthnChallengeTTL(); got != 42*time.Second {
t.Fatalf("expected custom ttl, got %v", got)
}
}
func TestAuthServiceWebAuthnClient(t *testing.T) {
t.Run("missing rpid", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
WebAuthnRPOrigins: []string{"https://example.com"},
}})
if _, err := svc.webAuthnClient(); !errors.Is(err, ErrWebAuthnNotConfigured) {
t.Fatalf("expected not configured error, got %v", err)
}
})
t.Run("missing origins", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
WebAuthnRPID: "example.com",
}})
if _, err := svc.webAuthnClient(); !errors.Is(err, ErrWebAuthnNotConfigured) {
t.Fatalf("expected not configured error, got %v", err)
}
})
t.Run("success trims and defaults", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
WebAuthnRPID: " example.com ",
WebAuthnRPDisplayName: " ",
WebAuthnRPOrigins: []string{" ", " https://example.com "},
WebAuthnChallengeTTL: 30 * time.Second,
}})
client, err := svc.webAuthnClient()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client == nil || client.Config == nil {
t.Fatalf("expected webauthn client")
}
if client.Config.RPID != "example.com" {
t.Fatalf("unexpected rpid: %q", client.Config.RPID)
}
if client.Config.RPDisplayName != "Wucher" {
t.Fatalf("expected default rp display name, got %q", client.Config.RPDisplayName)
}
if len(client.Config.RPOrigins) != 1 || client.Config.RPOrigins[0] != "https://example.com" {
t.Fatalf("unexpected rp origins: %#v", client.Config.RPOrigins)
}
if client.Config.Timeouts.Login.Timeout != 30*time.Second {
t.Fatalf("unexpected login timeout: %v", client.Config.Timeouts.Login.Timeout)
}
})
}
func TestAuthServiceStoreWebAuthnChallenge(t *testing.T) {
t.Run("nil token store", func(t *testing.T) {
svc := newConfiguredWebAuthnService(newMockAuthRepo(), nil)
session := &webauthnlib.SessionData{Challenge: "a"}
if _, err := svc.storeWebAuthnChallenge(context.Background(), webAuthnCeremonyLogin, []byte{1}, session); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge error, got %v", err)
}
})
t.Run("nil session", func(t *testing.T) {
svc := newConfiguredWebAuthnService(newMockAuthRepo(), newMockTokenStore())
if _, err := svc.storeWebAuthnChallenge(context.Background(), webAuthnCeremonyLogin, []byte{1}, nil); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge error, got %v", err)
}
})
t.Run("random token failure", func(t *testing.T) {
orig := rand.Reader
rand.Reader = errReader{}
defer func() { rand.Reader = orig }()
svc := newConfiguredWebAuthnService(newMockAuthRepo(), newMockTokenStore())
session := &webauthnlib.SessionData{Challenge: "abc"}
if _, err := svc.storeWebAuthnChallenge(context.Background(), webAuthnCeremonyRegistration, []byte{1}, session); !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("expected random token error, got %v", err)
}
})
t.Run("token store set error", func(t *testing.T) {
tokens := newMockTokenStore()
tokens.setErr = errors.New("set failed")
svc := newConfiguredWebAuthnService(newMockAuthRepo(), tokens)
session := &webauthnlib.SessionData{Challenge: "abc"}
if _, err := svc.storeWebAuthnChallenge(context.Background(), webAuthnCeremonyRegistration, []byte{1, 2}, session); !errors.Is(err, tokens.setErr) {
t.Fatalf("expected set error, got %v", err)
}
})
t.Run("success", func(t *testing.T) {
tokens := newMockTokenStore()
svc := newConfiguredWebAuthnService(newMockAuthRepo(), tokens)
userID := uuidv7.MustBytes()
session := &webauthnlib.SessionData{
Challenge: "abc",
RelyingPartyID: "example.com",
UserID: userID,
Expires: time.Now().UTC().Add(time.Minute),
}
token, err := svc.storeWebAuthnChallenge(context.Background(), webAuthnCeremonyLogin, userID, session)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.TrimSpace(token) == "" {
t.Fatalf("expected token")
}
if !strings.HasPrefix(tokens.lastKey, "auth:webauthn:challenge:") {
t.Fatalf("unexpected key: %q", tokens.lastKey)
}
raw := tokens.store[tokens.lastKey]
var envelope webAuthnChallengeEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("unmarshal envelope: %v", err)
}
if envelope.Ceremony != webAuthnCeremonyLogin || !bytes.Equal(envelope.UserID, userID) {
t.Fatalf("unexpected envelope")
}
})
}
func TestAuthServiceConsumeWebAuthnChallenge(t *testing.T) {
t.Run("empty token", func(t *testing.T) {
svc := newConfiguredWebAuthnService(newMockAuthRepo(), newMockTokenStore())
if _, err := svc.consumeWebAuthnChallenge(context.Background(), " ", webAuthnCeremonyLogin); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge error, got %v", err)
}
})
t.Run("nil token store", func(t *testing.T) {
svc := newConfiguredWebAuthnService(newMockAuthRepo(), nil)
if _, err := svc.consumeWebAuthnChallenge(context.Background(), "token", webAuthnCeremonyLogin); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge error, got %v", err)
}
})
t.Run("get error", func(t *testing.T) {
tokens := newMockTokenStore()
tokens.getErr = errors.New("token store down")
svc := newConfiguredWebAuthnService(newMockAuthRepo(), tokens)
if _, err := svc.consumeWebAuthnChallenge(context.Background(), "token", webAuthnCeremonyLogin); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge error, got %v", err)
}
})
t.Run("missing token", func(t *testing.T) {
svc := newConfiguredWebAuthnService(newMockAuthRepo(), newMockTokenStore())
if _, err := svc.consumeWebAuthnChallenge(context.Background(), "missing", webAuthnCeremonyLogin); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge error, got %v", err)
}
})
t.Run("invalid json", func(t *testing.T) {
tokens := newMockTokenStore()
tokens.store[webAuthnChallengeKey("badjson")] = []byte("{bad")
svc := newConfiguredWebAuthnService(newMockAuthRepo(), tokens)
if _, err := svc.consumeWebAuthnChallenge(context.Background(), "badjson", webAuthnCeremonyLogin); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge error, got %v", err)
}
})
t.Run("ceremony mismatch", func(t *testing.T) {
tokens := newMockTokenStore()
putWebAuthnChallengeToken(t, tokens, "mismatch", webAuthnCeremonyRegistration, uuidv7.MustBytes(), time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(newMockAuthRepo(), tokens)
if _, err := svc.consumeWebAuthnChallenge(context.Background(), "mismatch", webAuthnCeremonyLogin); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge error, got %v", err)
}
})
t.Run("expired", func(t *testing.T) {
tokens := newMockTokenStore()
putWebAuthnChallengeToken(t, tokens, "expired", webAuthnCeremonyLogin, uuidv7.MustBytes(), time.Now().UTC().Add(-time.Second))
svc := newConfiguredWebAuthnService(newMockAuthRepo(), tokens)
if _, err := svc.consumeWebAuthnChallenge(context.Background(), "expired", webAuthnCeremonyLogin); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge error, got %v", err)
}
})
t.Run("success deletes token", func(t *testing.T) {
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
putWebAuthnChallengeToken(t, tokens, "ok", webAuthnCeremonyLogin, userID, time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(newMockAuthRepo(), tokens)
envelope, err := svc.consumeWebAuthnChallenge(context.Background(), "ok", webAuthnCeremonyLogin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if envelope == nil || !bytes.Equal(envelope.UserID, userID) {
t.Fatalf("unexpected envelope")
}
if _, found := tokens.store[webAuthnChallengeKey("ok")]; found {
t.Fatalf("expected challenge token to be deleted")
}
})
}
func TestWebAuthnChallengeKey(t *testing.T) {
if got := webAuthnChallengeKey("abc"); got != "auth:webauthn:challenge:abc" {
t.Fatalf("unexpected key: %q", got)
}
}
func TestAuthServiceLoadWebAuthnCredentials(t *testing.T) {
t.Run("repo list error", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
repo.listCredsErr = errors.New("list creds failed")
svc := newConfiguredWebAuthnService(repo, nil)
if _, err := svc.loadWebAuthnCredentials(context.Background(), uuidv7.MustBytes()); !errors.Is(err, repo.listCredsErr) {
t.Fatalf("expected list error, got %v", err)
}
})
t.Run("invalid credential json", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
userID := uuidv7.MustBytes()
repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
UserID: userID,
CredentialID: []byte{1},
CredentialJSON: []byte("{bad"),
}}
svc := newConfiguredWebAuthnService(repo, nil)
if _, err := svc.loadWebAuthnCredentials(context.Background(), userID); err == nil {
t.Fatalf("expected unmarshal error")
}
})
t.Run("success", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
userID := uuidv7.MustBytes()
serialized, err := json.Marshal(webauthnlib.Credential{
ID: []byte{1, 2, 3},
PublicKey: []byte{4, 5, 6},
AttestationType: "none",
})
if err != nil {
t.Fatalf("marshal credential: %v", err)
}
repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
UserID: userID,
CredentialID: []byte{1, 2, 3},
CredentialJSON: serialized,
}}
svc := newConfiguredWebAuthnService(repo, nil)
creds, err := svc.loadWebAuthnCredentials(context.Background(), userID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(creds) != 1 || !bytes.Equal(creds[0].ID, []byte{1, 2, 3}) {
t.Fatalf("unexpected credentials")
}
})
}
func TestAuthServiceBeginWebAuthnRegistrationBranches(t *testing.T) {
t.Run("invalid user id length", func(t *testing.T) {
svc := newConfiguredWebAuthnService(newMockAuthRepo(), newMockTokenStore())
if _, _, err := svc.BeginWebAuthnRegistration(context.Background(), []byte("short")); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
})
t.Run("not configured", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, _, err := svc.BeginWebAuthnRegistration(context.Background(), uuidv7.MustBytes()); !errors.Is(err, ErrWebAuthnNotConfigured) {
t.Fatalf("expected not configured, got %v", err)
}
})
t.Run("user fetch error", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
repo.getByIDErr = errors.New("db failed")
svc := newConfiguredWebAuthnService(repo, newMockTokenStore())
if _, _, err := svc.BeginWebAuthnRegistration(context.Background(), uuidv7.MustBytes()); !errors.Is(err, repo.getByIDErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("user not found", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
svc := newConfiguredWebAuthnService(repo, newMockTokenStore())
if _, _, err := svc.BeginWebAuthnRegistration(context.Background(), uuidv7.MustBytes()); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
})
t.Run("load credentials error", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com", IsActive: true}
repo.listCredsErr = errors.New("list creds failed")
svc := newConfiguredWebAuthnService(repo, newMockTokenStore())
if _, _, err := svc.BeginWebAuthnRegistration(context.Background(), userID); !errors.Is(err, repo.listCredsErr) {
t.Fatalf("expected list creds error, got %v", err)
}
})
t.Run("token store unavailable", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com", IsActive: true}
svc := newConfiguredWebAuthnService(repo, nil)
if _, _, err := svc.BeginWebAuthnRegistration(context.Background(), userID); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge, got %v", err)
}
})
t.Run("success with exclusions", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{
ID: userID,
Email: "user@example.com",
FirstName: "Web",
LastName: "Authn",
IsActive: true,
}
serialized, err := json.Marshal(webauthnlib.Credential{
ID: []byte{8, 8, 8},
PublicKey: []byte{1, 2, 3},
AttestationType: "none",
})
if err != nil {
t.Fatalf("marshal credential: %v", err)
}
repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
UserID: userID,
CredentialID: []byte{8, 8, 8},
CredentialJSON: serialized,
}}
svc := newConfiguredWebAuthnService(repo, tokens)
creation, challengeToken, err := svc.BeginWebAuthnRegistration(context.Background(), userID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if creation == nil || strings.TrimSpace(challengeToken) == "" {
t.Fatalf("expected creation options and challenge token")
}
if len(creation.Response.CredentialExcludeList) != 1 {
t.Fatalf("expected one exclusion descriptor")
}
})
}
func TestAuthServiceFinishWebAuthnRegistrationBranches(t *testing.T) {
t.Run("invalid user id length", func(t *testing.T) {
svc := newConfiguredWebAuthnService(newMockAuthRepo(), newMockTokenStore())
err := svc.FinishWebAuthnRegistration(context.Background(), []byte("short"), "token", []byte("{}"), "name")
if !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
})
t.Run("not configured", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
err := svc.FinishWebAuthnRegistration(context.Background(), uuidv7.MustBytes(), "token", []byte("{}"), "name")
if !errors.Is(err, ErrWebAuthnNotConfigured) {
t.Fatalf("expected not configured, got %v", err)
}
})
t.Run("invalid challenge", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com", IsActive: true}
svc := newConfiguredWebAuthnService(repo, newMockTokenStore())
err := svc.FinishWebAuthnRegistration(context.Background(), userID, "missing", []byte("{}"), "name")
if !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge, got %v", err)
}
})
t.Run("challenge user mismatch", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com", IsActive: true}
putWebAuthnChallengeToken(t, tokens, "tok1", webAuthnCeremonyRegistration, uuidv7.MustBytes(), time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(repo, tokens)
err := svc.FinishWebAuthnRegistration(context.Background(), userID, "tok1", []byte("{}"), "name")
if !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected challenge invalid, got %v", err)
}
})
t.Run("user fetch error", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
repo.getByIDErr = errors.New("db failed")
putWebAuthnChallengeToken(t, tokens, "tokfetch", webAuthnCeremonyRegistration, userID, time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(repo, tokens)
err := svc.FinishWebAuthnRegistration(context.Background(), userID, "tokfetch", []byte("{}"), "name")
if !errors.Is(err, repo.getByIDErr) {
t.Fatalf("expected user fetch error, got %v", err)
}
})
t.Run("user not found", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
putWebAuthnChallengeToken(t, tokens, "tok2", webAuthnCeremonyRegistration, userID, time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(repo, tokens)
err := svc.FinishWebAuthnRegistration(context.Background(), userID, "tok2", []byte("{}"), "name")
if !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
})
t.Run("load credentials error", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com", IsActive: true}
repo.listCredsErr = errors.New("list creds failed")
putWebAuthnChallengeToken(t, tokens, "tok3", webAuthnCeremonyRegistration, userID, time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(repo, tokens)
err := svc.FinishWebAuthnRegistration(context.Background(), userID, "tok3", []byte("{}"), "name")
if !errors.Is(err, repo.listCredsErr) {
t.Fatalf("expected list creds error, got %v", err)
}
})
t.Run("invalid payload", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com", IsActive: true}
putWebAuthnChallengeToken(t, tokens, "tok4", webAuthnCeremonyRegistration, userID, time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(repo, tokens)
err := svc.FinishWebAuthnRegistration(context.Background(), userID, "tok4", []byte("{}"), "name")
if !errors.Is(err, ErrWebAuthnPayloadInvalid) {
t.Fatalf("expected payload invalid, got %v", err)
}
})
t.Run("verification failed", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{
ID: userID,
Email: "user@example.com",
FirstName: "A",
LastName: "B",
IsActive: true,
}
svc := newConfiguredWebAuthnService(repo, tokens)
_, challengeToken, err := svc.BeginWebAuthnRegistration(context.Background(), userID)
if err != nil {
t.Fatalf("begin registration: %v", err)
}
err = svc.FinishWebAuthnRegistration(context.Background(), userID, challengeToken, []byte(testCredentialCreationResponseJSON), "Device A")
if !errors.Is(err, ErrWebAuthnVerificationFailed) {
t.Fatalf("expected verification failed, got %v", err)
}
})
t.Run("success persists credential", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{
ID: userID,
Email: "user@example.com",
FirstName: "Web",
LastName: "Authn",
IsActive: true,
}
svc := NewAuthService(
repo,
newMockRoleRepo(),
tokens,
nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
WebAuthnRPID: "webauthn.io",
WebAuthnRPDisplayName: "Wucher Test",
WebAuthnRPOrigins: []string{"https://webauthn.io"},
WebAuthnChallengeTTL: 2 * time.Minute,
}},
)
envelope := webAuthnChallengeEnvelope{
Ceremony: webAuthnCeremonyRegistration,
UserID: append([]byte(nil), userID...),
Session: webauthnlib.SessionData{
Challenge: "W8GzFU8pGjhoRbWrLDlamAfq_y4S1CZG1VuoeRLARrE",
RelyingPartyID: "webauthn.io",
UserID: append([]byte(nil), userID...),
Expires: time.Now().UTC().Add(time.Minute),
CredParams: webauthnlib.CredentialParametersDefault(),
},
}
payload, err := json.Marshal(envelope)
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
tokens.store[webAuthnChallengeKey("toksuccess")] = payload
if err := svc.FinishWebAuthnRegistration(context.Background(), userID, "toksuccess", []byte(testCredentialCreationResponseJSON), " Device A "); err != nil {
t.Fatalf("unexpected error: %v", err)
}
records := repo.webauthnCredentials[string(userID)]
if len(records) != 1 {
t.Fatalf("expected one stored webauthn credential, got %d", len(records))
}
if records[0].Name != "Device A" {
t.Fatalf("expected trimmed credential name, got %q", records[0].Name)
}
if len(records[0].CredentialID) == 0 || len(records[0].CredentialJSON) == 0 {
t.Fatalf("expected persisted credential data")
}
})
}
func TestAuthServiceBeginWebAuthnLoginBranches(t *testing.T) {
t.Run("not configured", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, _, err := svc.BeginWebAuthnLogin(context.Background(), "user@example.com"); !errors.Is(err, ErrWebAuthnNotConfigured) {
t.Fatalf("expected not configured, got %v", err)
}
})
t.Run("user fetch error", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
repo.getByMailErr = errors.New("db failed")
svc := newConfiguredWebAuthnService(repo, newMockTokenStore())
if _, _, err := svc.BeginWebAuthnLogin(context.Background(), "user@example.com"); !errors.Is(err, repo.getByMailErr) {
t.Fatalf("expected db error, got %v", err)
}
})
t.Run("user not found", func(t *testing.T) {
svc := newConfiguredWebAuthnService(newWebAuthnRepoWithErrors(), newMockTokenStore())
if _, _, err := svc.BeginWebAuthnLogin(context.Background(), "user@example.com"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials, got %v", err)
}
})
t.Run("email not verified", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
userID := uuidv7.MustBytes()
repo.usersByMail["user@example.com"] = &auth.User{ID: userID, Email: "user@example.com", IsActive: true}
svc := newConfiguredWebAuthnService(repo, newMockTokenStore())
if _, _, err := svc.BeginWebAuthnLogin(context.Background(), " user@example.com "); !errors.Is(err, ErrEmailNotVerified) {
t.Fatalf("expected email not verified, got %v", err)
}
})
t.Run("load credentials error", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
userID := uuidv7.MustBytes()
now := time.Now().UTC()
repo.usersByMail["user@example.com"] = &auth.User{ID: userID, Email: "user@example.com", EmailVerifiedAt: &now, IsActive: true}
repo.listCredsErr = errors.New("list creds failed")
svc := newConfiguredWebAuthnService(repo, newMockTokenStore())
if _, _, err := svc.BeginWebAuthnLogin(context.Background(), "user@example.com"); !errors.Is(err, repo.listCredsErr) {
t.Fatalf("expected list creds error, got %v", err)
}
})
t.Run("no credentials configured", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
userID := uuidv7.MustBytes()
now := time.Now().UTC()
repo.usersByMail["user@example.com"] = &auth.User{ID: userID, Email: "user@example.com", EmailVerifiedAt: &now, IsActive: true}
svc := newConfiguredWebAuthnService(repo, newMockTokenStore())
if _, _, err := svc.BeginWebAuthnLogin(context.Background(), "user@example.com"); !errors.Is(err, ErrWebAuthnCredentialUnavailable) {
t.Fatalf("expected credential unavailable, got %v", err)
}
})
t.Run("token store unavailable", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
userID := uuidv7.MustBytes()
now := time.Now().UTC()
repo.usersByMail["user@example.com"] = &auth.User{ID: userID, Email: "user@example.com", EmailVerifiedAt: &now, IsActive: true}
serialized, err := json.Marshal(webauthnlib.Credential{ID: []byte{1, 2, 3}, PublicKey: []byte{4}})
if err != nil {
t.Fatalf("marshal credential: %v", err)
}
repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
UserID: userID,
CredentialID: []byte{1, 2, 3},
CredentialJSON: serialized,
}}
svc := newConfiguredWebAuthnService(repo, nil)
if _, _, err := svc.BeginWebAuthnLogin(context.Background(), "user@example.com"); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge, got %v", err)
}
})
t.Run("success", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
now := time.Now().UTC()
repo.usersByMail["user@example.com"] = &auth.User{
ID: userID,
Email: "user@example.com",
EmailVerifiedAt: &now,
FirstName: "Web",
LastName: "User",
IsActive: true,
}
serialized, err := json.Marshal(webauthnlib.Credential{
ID: []byte{1, 2, 3},
PublicKey: []byte{4, 5, 6},
AttestationType: "none",
})
if err != nil {
t.Fatalf("marshal credential: %v", err)
}
repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
UserID: userID,
CredentialID: []byte{1, 2, 3},
CredentialJSON: serialized,
}}
svc := newConfiguredWebAuthnService(repo, tokens)
assertion, token, err := svc.BeginWebAuthnLogin(context.Background(), " user@example.com ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if assertion == nil || strings.TrimSpace(token) == "" {
t.Fatalf("expected assertion and challenge token")
}
if len(assertion.Response.AllowedCredentials) != 1 {
t.Fatalf("expected allowed credential list")
}
})
}
func TestAuthServiceFinishWebAuthnLoginBranches(t *testing.T) {
t.Run("not configured", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.FinishWebAuthnLogin(context.Background(), "token", []byte("{}")); !errors.Is(err, ErrWebAuthnNotConfigured) {
t.Fatalf("expected not configured, got %v", err)
}
})
t.Run("invalid challenge", func(t *testing.T) {
svc := newConfiguredWebAuthnService(newWebAuthnRepoWithErrors(), newMockTokenStore())
if _, err := svc.FinishWebAuthnLogin(context.Background(), "missing", []byte("{}")); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge, got %v", err)
}
})
t.Run("challenge invalid user id length", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
putWebAuthnChallengeToken(t, tokens, "baduid", webAuthnCeremonyLogin, []byte("short"), time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(repo, tokens)
if _, err := svc.FinishWebAuthnLogin(context.Background(), "baduid", []byte("{}")); !errors.Is(err, ErrWebAuthnChallengeInvalid) {
t.Fatalf("expected invalid challenge, got %v", err)
}
})
t.Run("user fetch error", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
repo.getByIDErr = errors.New("db failed")
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
putWebAuthnChallengeToken(t, tokens, "tok1", webAuthnCeremonyLogin, userID, time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(repo, tokens)
if _, err := svc.FinishWebAuthnLogin(context.Background(), "tok1", []byte("{}")); !errors.Is(err, repo.getByIDErr) {
t.Fatalf("expected db error, got %v", err)
}
})
t.Run("user not found", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
putWebAuthnChallengeToken(t, tokens, "tok2", webAuthnCeremonyLogin, userID, time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(repo, tokens)
if _, err := svc.FinishWebAuthnLogin(context.Background(), "tok2", []byte("{}")); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials, got %v", err)
}
})
t.Run("load credentials error", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
repo.listCredsErr = errors.New("list creds failed")
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com", IsActive: true}
putWebAuthnChallengeToken(t, tokens, "tok3", webAuthnCeremonyLogin, userID, time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(repo, tokens)
if _, err := svc.FinishWebAuthnLogin(context.Background(), "tok3", []byte("{}")); !errors.Is(err, repo.listCredsErr) {
t.Fatalf("expected list creds error, got %v", err)
}
})
t.Run("no credentials", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com", IsActive: true}
putWebAuthnChallengeToken(t, tokens, "tok4", webAuthnCeremonyLogin, userID, time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(repo, tokens)
if _, err := svc.FinishWebAuthnLogin(context.Background(), "tok4", []byte("{}")); !errors.Is(err, ErrWebAuthnCredentialUnavailable) {
t.Fatalf("expected credential unavailable, got %v", err)
}
})
t.Run("invalid payload", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com", IsActive: true}
serialized, err := json.Marshal(webauthnlib.Credential{ID: []byte{1}, PublicKey: []byte{2}})
if err != nil {
t.Fatalf("marshal credential: %v", err)
}
repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
UserID: userID,
CredentialID: []byte{1},
CredentialJSON: serialized,
}}
putWebAuthnChallengeToken(t, tokens, "tok5", webAuthnCeremonyLogin, userID, time.Now().UTC().Add(time.Minute))
svc := newConfiguredWebAuthnService(repo, tokens)
if _, err := svc.FinishWebAuthnLogin(context.Background(), "tok5", []byte("{}")); !errors.Is(err, ErrWebAuthnPayloadInvalid) {
t.Fatalf("expected payload invalid, got %v", err)
}
})
t.Run("verification failed", func(t *testing.T) {
repo := newWebAuthnRepoWithErrors()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
now := time.Now().UTC()
user := &auth.User{
ID: userID,
Email: "user@example.com",
EmailVerifiedAt: &now,
FirstName: "Web",
LastName: "User",
IsActive: true,
}
repo.usersByMail["user@example.com"] = user
repo.users[string(userID)] = user
serialized, err := json.Marshal(webauthnlib.Credential{
ID: []byte{1, 2, 3},
PublicKey: []byte{4, 5, 6},
AttestationType: "none",
})
if err != nil {
t.Fatalf("marshal credential: %v", err)
}
repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
UserID: userID,
CredentialID: []byte{1, 2, 3},
CredentialJSON: serialized,
}}
svc := newConfiguredWebAuthnService(repo, tokens)
_, challengeToken, err := svc.BeginWebAuthnLogin(context.Background(), "user@example.com")
if err != nil {
t.Fatalf("begin login: %v", err)
}
if _, err := svc.FinishWebAuthnLogin(context.Background(), challengeToken, []byte(testCredentialAssertionResponseJSON)); !errors.Is(err, ErrWebAuthnVerificationFailed) {
t.Fatalf("expected verification failed, got %v", err)
}
})
}