This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authwebauthn_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/internal/webauthnvendored/protocol"
|
||||
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
|
||||
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
|
||||
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authsqlite"
|
||||
"gamertan.com/web/authwebauthn"
|
||||
)
|
||||
|
||||
func TestBootstrapEnrollmentAndApprovalPolicy(t *testing.T) {
|
||||
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
|
||||
store, authService, service := newService(t, &now, &counterReader{})
|
||||
defer store.Close()
|
||||
|
||||
user, enrollmentToken, err := service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "operator.one", Email: "operator@example.test", DisplayName: "Operator One"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enrollmentToken == "" || user.PasswordChangeRequired {
|
||||
t.Fatalf("unexpected bootstrap user=%+v token=%q", user, enrollmentToken)
|
||||
}
|
||||
|
||||
begin, err := service.BeginEnrollment(t.Context(), enrollmentToken, "Primary passkey")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.BeginEnrollment(t.Context(), enrollmentToken, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
|
||||
t.Fatalf("replayed enrollment err=%v", err)
|
||||
}
|
||||
var options protocol.PublicKeyCredentialCreationOptions
|
||||
if err = json.Unmarshal(begin.PublicKey, &options); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if options.RelyingParty.ID != "tend.gamertan.com" || options.AuthenticatorSelection.UserVerification != protocol.VerificationRequired || options.AuthenticatorSelection.ResidentKey != protocol.ResidentKeyRequirementRequired || options.Attestation != protocol.PreferNoAttestation {
|
||||
t.Fatalf("unexpected registration policy: %+v", options)
|
||||
}
|
||||
if len(options.Parameters) != 1 || options.Parameters[0].Algorithm != webauthncose.AlgES256 {
|
||||
t.Fatalf("unexpected algorithms: %+v", options.Parameters)
|
||||
}
|
||||
if !begin.ExpiresAt.Equal(now.Add(5 * time.Minute)) {
|
||||
t.Fatalf("registration expiry=%v", begin.ExpiresAt)
|
||||
}
|
||||
|
||||
if err = service.RequireReady(t.Context(), user.ID); !errors.Is(err, authwebauthn.ErrPasskeyReadiness) {
|
||||
t.Fatalf("readiness without credentials err=%v", err)
|
||||
}
|
||||
for index := range 2 {
|
||||
credential := wa.Credential{ID: bytes.Repeat([]byte{byte(index + 1)}, 32), PublicKey: []byte{1, 2, 3}}
|
||||
encoded, marshalErr := json.Marshal(credential)
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
audit := auth.AuditEvent{ID: "audit-passkey-" + string(rune('a'+index)), ActorUserID: user.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: "fixture", Summary: "fixture", CreatedAt: now}
|
||||
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: credential.ID, UserID: user.ID, Label: "Fixture", Data: encoded, CreatedAt: now}, audit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = service.RequireReady(t.Context(), user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summaries, err := service.CredentialSummaries(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summaries) != 2 || summaries[0].Label != "Fixture" || len(summaries[0].ID) != 32 {
|
||||
t.Fatalf("unexpected summaries: %+v", summaries)
|
||||
}
|
||||
summaries[0].ID[0] = 99
|
||||
refreshed, err := service.CredentialSummaries(t.Context(), user.ID)
|
||||
if err != nil || refreshed[0].ID[0] == 99 {
|
||||
t.Fatalf("credential summary did not defensively copy the identifier: summaries=%+v err=%v", refreshed, err)
|
||||
}
|
||||
if _, err = service.BeginCredentialRemoval(t.Context(), user.ID, refreshed[0].ID); !errors.Is(err, authwebauthn.ErrCredentialFloor) {
|
||||
t.Fatalf("credential removal below operational floor err=%v", err)
|
||||
}
|
||||
if _, err = service.BeginCredentialRemoval(t.Context(), user.ID, bytes.Repeat([]byte{9}, 32)); !errors.Is(err, authwebauthn.ErrCredentialNotFound) {
|
||||
t.Fatalf("unknown credential removal err=%v", err)
|
||||
}
|
||||
|
||||
binding := bytes.Repeat([]byte("approved operation "), 3)
|
||||
approvalBegin, err := service.BeginApproval(t.Context(), user.ID, binding)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.FinishApproval(t.Context(), approvalBegin.CeremonyToken, append([]byte(nil), binding[:len(binding)-1]...), []byte(`{}`)); !errors.Is(err, authwebauthn.ErrOperationBinding) {
|
||||
t.Fatalf("tampered binding err=%v", err)
|
||||
}
|
||||
if _, err = service.FinishApproval(t.Context(), approvalBegin.CeremonyToken, binding, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
|
||||
t.Fatalf("replayed approval err=%v", err)
|
||||
}
|
||||
|
||||
login, err := service.BeginLogin(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(3 * time.Minute)
|
||||
if _, err = service.FinishLogin(t.Context(), login.CeremonyToken, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
|
||||
t.Fatalf("expired login err=%v", err)
|
||||
}
|
||||
|
||||
_ = authService
|
||||
}
|
||||
|
||||
func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) {
|
||||
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
|
||||
store, authService, service := newService(t, &now, &counterReader{})
|
||||
defer store.Close()
|
||||
user, _, err := service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "recover.me", Email: "recover@example.test", DisplayName: "Recover Me"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, token, err := service.Recover(t.Context(), "recover.me", "all authenticators unavailable")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||
t.Fatalf("session survived recovery: %v", err)
|
||||
}
|
||||
if _, err = service.BeginEnrollment(t.Context(), token, "Recovered passkey"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.BeginEnrollment(t.Context(), token, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
|
||||
t.Fatalf("recovery token replay err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationAndEntropyFailures(t *testing.T) {
|
||||
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
authService, err := auth.New(store, auth.Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, config := range []authwebauthn.Config{
|
||||
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "http://tend.gamertan.com"},
|
||||
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.gamertan.com"},
|
||||
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com/path"},
|
||||
} {
|
||||
if _, err = authwebauthn.New(store, authService, config); err == nil {
|
||||
t.Fatalf("accepted config=%+v", config)
|
||||
}
|
||||
}
|
||||
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err = service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "entropy.fail", Email: "entropy@example.test", DisplayName: "Entropy"}); err == nil || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("entropy failure err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newService(t *testing.T, now *time.Time, random io.Reader) (*authsqlite.Store, *auth.Service, *authwebauthn.Service) {
|
||||
t.Helper()
|
||||
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return *now }})
|
||||
if err != nil {
|
||||
store.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: random, Now: func() time.Time { return *now }})
|
||||
if err != nil {
|
||||
store.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store, authService, service
|
||||
}
|
||||
|
||||
type failingReader struct{}
|
||||
|
||||
func (failingReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
|
||||
|
||||
type counterReader struct{ next byte }
|
||||
|
||||
func (reader *counterReader) Read(value []byte) (int, error) {
|
||||
for index := range value {
|
||||
reader.next++
|
||||
value[index] = reader.next
|
||||
}
|
||||
return len(value), nil
|
||||
}
|
||||
Reference in New Issue
Block a user