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