From ae164e625a61b46c53a0ee3d4d6fd6ea5354bc64 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:22:33 +0100 Subject: [PATCH] fix: expire abandoned auth attempts --- multiplayer-todo.md | 2 +- server/domain/auth.go | 20 ++++++++++++++++++++ server/domain/auth_test.go | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index e546ca0c..4f8025a3 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1135,7 +1135,7 @@ New `--role=client-reconnect` grades the returning player: not a spectator, owns | 7.3 `[D:7.2]` `[P]` | `server_browser.tscn` via `ISteamMatchmakingServers` | Internet, LAN, favourites and history lists all populate | | 7.4 `[D:7.2]` `[P]` | Auth tickets in `hello` → `BeginAuthSession`; Steam identity in the roster; persistent ban list | Ownership, VAC and ban state verified server-side | | 7.5 `[D:7.2]` `[P]` | Feature-gate every Steam call behind `OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer")`; verify the ENet path end to end | Non-Steam build is fully functional | -| 7.6 `[D:7.4]` | **IN PROGRESS.** Pure Go `AuthCoordinator` models pending/accepted/rejected/cancelled backend auth sessions around the single-use verifier; separate Web API/game-server ticket lifecycles remain | `server/domain/auth.go` and adversarial tests keep identity unavailable while pending, reject cancellation/expiry/wrong-attempt/replay, and release identity only after verifier success; real Steam BeginAuthSession/EndAuthSession adapter and persistent session integration remain | +| 7.6 `[D:7.4]` | **IN PROGRESS.** Pure Go `AuthCoordinator` models pending/accepted/rejected/cancelled backend auth sessions around the single-use verifier, including maintenance expiry sweeping; separate Web API/game-server ticket lifecycles remain | `server/domain/auth.go` and adversarial tests keep identity unavailable while pending, reject cancellation/expiry/wrong-attempt/replay, expire abandoned attempts at the boundary, and release identity only after verifier success; real Steam BeginAuthSession/EndAuthSession adapter and persistent session integration remain | | 7.7 `[D:7.1]` `[P]` | Obtain the production App ID, publisher key, SDR coordinator SDK/signing approval, certificates and hosted-data-centre support from Valve | Production prerequisites and rotation owners are recorded; no Spacewar credential or development certificate can reach a release build | | 7.8 `[D:7.6,7.7]` | Ticketed Hosted Dedicated Server SDR: routing registration, coordinator-issued player→server relay tickets, client ticket installation, reconnect and expiry | Two real accounts complete and reconnect to an assigned dedicated match through SDR; server/player IPs are not exposed; ENet gates remain green | diff --git a/server/domain/auth.go b/server/domain/auth.go index 3135354b..3dede412 100644 --- a/server/domain/auth.go +++ b/server/domain/auth.go @@ -130,6 +130,26 @@ func (c *AuthCoordinator) Get(attemptID string) (AuthAttempt, error) { return attempt, nil } +// Expire closes abandoned pending attempts. The caller should run this from +// the auth maintenance loop; accepted and already terminal attempts are left +// unchanged so audit/reconciliation can still inspect their outcome. +func (c *AuthCoordinator) Expire(now time.Time) []AuthAttempt { + if c == nil || now.IsZero() { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + var expired []AuthAttempt + for id, attempt := range c.attempts { + if attempt.State == AuthPending && !now.Before(attempt.ExpiresAt) { + attempt.State = AuthRejected + c.attempts[id] = attempt + expired = append(expired, attempt) + } + } + return expired +} + var ( ErrTicketRejected = fmt.Errorf("steam ticket rejected") ErrSessionRejected = fmt.Errorf("session rejected") diff --git a/server/domain/auth_test.go b/server/domain/auth_test.go index 99d5f62f..87856127 100644 --- a/server/domain/auth_test.go +++ b/server/domain/auth_test.go @@ -109,3 +109,21 @@ func TestAuthCoordinatorRejectsExpiredCompletion(t *testing.T) { t.Fatalf("expired attempt completed: %v", err) } } + +func TestAuthCoordinatorExpiresAbandonedPendingAttemptsAtBoundary(t *testing.T) { + now := time.Unix(1000, 0) + coordinator := NewAuthCoordinator() + ticket := SteamTicket{TicketID: "ticket-1", ExpiresAt: now.Add(time.Second)} + if err := coordinator.Begin("attempt-1", ticket, now); err != nil { + t.Fatal(err) + } + if expired := coordinator.Expire(now.Add(time.Second)); len(expired) != 1 || expired[0].State != AuthRejected { + t.Fatalf("expired attempts = %+v", expired) + } + if _, err := coordinator.Get("attempt-1"); !errors.Is(err, ErrAuthAttemptPending) { + t.Fatalf("expired attempt was exposed: %v", err) + } + if expired := coordinator.Expire(now.Add(2 * time.Second)); len(expired) != 0 { + t.Fatalf("expired attempt repeated: %+v", expired) + } +}