auth: publish passkey foundations preview
verify / verify (push) Successful in 3m40s

This commit is contained in:
2026-08-21 17:33:00 -04:00
parent fb6bbd0dad
commit bfe6cfd29e
230 changed files with 44547 additions and 17 deletions
+202
View File
@@ -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
}