diff --git a/internal/api/api_test.go b/internal/api/api_test.go index ea779fe..4d235b1 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -285,12 +285,13 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s } // CreatePasskeyChallenge / ConsumePasskeyChallengeByUser mirror PGRepo's contract so -// the hermetic tests exercise the same semantics: a fresh begin supersedes the prior -// live challenge for (user, purpose), and the consume path redeems the newest live one -// (expiry checked before consuming), single-use. +// the hermetic tests exercise the same semantics: a fresh begin supersedes ALL prior +// rows for (user, purpose) — live, consumed, or expired — so the table holds at most one +// row per (user, purpose), and the consume path redeems the newest live one (expiry +// checked before consuming), single-use. func (f *fakeRepo) CreatePasskeyChallenge(_ context.Context, id, userID, purpose string, sessionData []byte, expiresAt time.Time) error { - for k, c := range f.passkeyChallenges { // supersede prior live (DELETE ... consumed_at IS NULL) - if c.userID == userID && c.purpose == purpose && !c.consumed { + for k, c := range f.passkeyChallenges { // supersede all prior (DELETE ... user_id=$1 AND purpose=$2) + if c.userID == userID && c.purpose == purpose { delete(f.passkeyChallenges, k) } } diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index ca943b4..8d5c1a8 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -846,10 +846,13 @@ func (p *PGRepo) SetSetting(ctx context.Context, key string, value []byte) error // ---- player passkey enrollment (spec §14 WebAuthn / Phase 6 bind, migration 0007) ---- -// CreatePasskeyChallenge supersedes any prior live challenge for (user, purpose) and -// inserts the fresh one, in one transaction (mirrors CreateEmailOTP). The supersede -// DELETE means a re-begin invalidates the earlier ceremony, so only the most recent -// challenge can ever finish — at most one outstanding challenge per (user, purpose). +// CreatePasskeyChallenge supersedes any prior challenge for (user, purpose) and inserts +// the fresh one, in one transaction (mirrors CreateEmailOTP). The supersede DELETE +// removes ALL prior rows for (user, purpose) — not just the live one — so a re-begin +// invalidates the earlier ceremony AND reaps any already-consumed or expired row it left +// behind. That bounds the table at one row per (user, purpose): the begin→finish loop +// nets zero growth, since each begin sweeps the consumed row the previous finish stamped. +// (Deleting a consumed row is safe: it has already been redeemed and nothing reads it.) // The opaque SessionData is held server-side so the client cannot forge the challenge // it must answer at finish. func (p *PGRepo) CreatePasskeyChallenge(ctx context.Context, id, userID, purpose string, sessionData []byte, expiresAt time.Time) error { @@ -860,7 +863,7 @@ func (p *PGRepo) CreatePasskeyChallenge(ctx context.Context, id, userID, purpose defer tx.Rollback() //nolint:errcheck // no-op after commit if _, err := tx.ExecContext(ctx, - `DELETE FROM webauthn_challenges WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL`, + `DELETE FROM webauthn_challenges WHERE user_id = $1 AND purpose = $2`, userID, purpose); err != nil { return fmt.Errorf("supersede prior passkey challenge: %w", err) }