203 lines
7.9 KiB
Go
203 lines
7.9 KiB
Go
// 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
|
|
}
|