From 8253d772cb5bea9f927a383d8bcc2117931046de Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:26:27 +0100 Subject: [PATCH] feat: add verified Steam session endpoint --- multiplayer-todo.md | 2 +- server/api/service.go | 53 ++++++++++++++++++++++++++++++++++++++ server/api/service_test.go | 52 +++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index b5cf1171..d631d695 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, including maintenance expiry sweeping; `store.PostgresSessions` persists only token digests and the authenticated API can inject that durable session backend | `server/domain/auth.go`, `server/store/session_sql.go`, `server/api/service.go` and adversarial tests keep identity unavailable while pending, reject cancellation/expiry/wrong-attempt/replay, expire abandoned attempts at the boundary, release identity only after verifier success, reject invalid session inputs, and prove API delegation; real Steam BeginAuthSession/EndAuthSession adapter and live PostgreSQL/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; `store.PostgresSessions` persists only token digests, the authenticated API can inject that durable session backend, and `POST /v1/session/steam` issues sessions only from an injected verified-identity provider | `server/domain/auth.go`, `server/store/session_sql.go`, `server/api/service.go` and adversarial tests keep identity unavailable while pending, reject cancellation/expiry/wrong-attempt/replay, expire abandoned attempts at the boundary, release identity only after verifier success, reject invalid session inputs/extra identity fields, prove API delegation, and issue opaque sessions; real Steam BeginAuthSession/EndAuthSession adapter and live PostgreSQL/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/api/service.go b/server/api/service.go index 8376d9b0..193f965d 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -33,10 +33,18 @@ type QueueBackend interface { type SessionBackend interface { Authenticate(context.Context, string, string, time.Time) (domain.Session, error) } +type SteamLoginProvider interface { + Authenticate(context.Context, string, time.Time) (domain.VerifiedIdentity, error) +} +type SessionIssuer interface { + Issue(context.Context, string, time.Duration, time.Time) (domain.Session, string, error) +} type Service struct { Sessions *domain.SessionStore SessionBackend SessionBackend + SessionIssuer SessionIssuer + SteamLogin SteamLoginProvider Queue *domain.Queue Candidate CandidateProvider CandidateV2 CandidateProviderV2 @@ -52,6 +60,7 @@ type Service struct { func (s *Service) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", s.health) + mux.HandleFunc("/v1/session/steam", s.steamSession) mux.HandleFunc("/v1/queue", s.queueCreate) mux.HandleFunc("/v1/queue/", s.queueMutation) mux.HandleFunc("/v1/proposals/", s.proposalMutation) @@ -60,6 +69,50 @@ func (s *Service) Handler() http.Handler { return mux } +type steamSessionRequest struct { + WebAPITicket string `json:"web_api_ticket"` +} + +func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + if s.SteamLogin == nil { + writeError(w, http.StatusServiceUnavailable, "auth_unavailable") + return + } + var input steamSessionRequest + if !decodeBody(w, r, &input) { + return + } + if input.WebAPITicket == "" || len(input.WebAPITicket) > 4096 { + writeError(w, http.StatusBadRequest, "invalid_request") + return + } + now := s.now() + identity, err := s.SteamLogin.Authenticate(r.Context(), input.WebAPITicket, now) + if err != nil || identity.PlayerID == "" || identity.SteamID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + var session domain.Session + var token string + if s.SessionIssuer != nil { + session, token, err = s.SessionIssuer.Issue(r.Context(), identity.PlayerID, time.Hour, now) + } else if s.Sessions != nil { + session, token, err = s.Sessions.Issue(identity.PlayerID, time.Hour, now) + } else { + writeError(w, http.StatusServiceUnavailable, "auth_unavailable") + return + } + if err != nil { + writeError(w, http.StatusServiceUnavailable, "auth_unavailable") + return + } + writeJSON(w, http.StatusOK, map[string]any{"player_id": session.PlayerID, "expires_at": session.ExpiresAt, "access_token": session.SessionID + ":" + token}) +} + func (s *Service) health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } diff --git a/server/api/service_test.go b/server/api/service_test.go index 6fe7591a..bf8e12ba 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -21,6 +21,16 @@ func (s *sessionBackendSpy) Authenticate(_ context.Context, sessionID, _ string, return domain.Session{SessionID: sessionID, PlayerID: "player-1"}, nil } +type steamLoginSpy struct{ calls int } + +func (s *steamLoginSpy) Authenticate(_ context.Context, ticket string, _ time.Time) (domain.VerifiedIdentity, error) { + s.calls++ + if ticket != "valid-web-ticket" { + return domain.VerifiedIdentity{}, domain.ErrTicketRejected + } + return domain.VerifiedIdentity{PlayerID: "player-1", SteamID: "steam-1"}, nil +} + func (b *queueBackendSpy) Create(_ context.Context, playerID, ticketID, _ string, spec domain.QueueSpec, now time.Time) (domain.QueueTicket, error) { b.createCalls++ return domain.QueueTicket{TicketID: ticketID, PlayerID: playerID, Playlist: spec.Playlist, State: domain.Queued, EnqueuedAt: now, ExpiresAt: now.Add(domain.QueueExpiryWindow)}, nil @@ -294,6 +304,48 @@ func TestQueueAPIUsesInjectedSessionBackend(t *testing.T) { } } +func TestSteamSessionAPIRequiresBackendVerificationAndIssuesOpaqueSession(t *testing.T) { + now := time.Unix(1000, 0).UTC() + sessions := domain.NewSessionStore() + provider := &steamLoginSpy{} + service := &Service{Sessions: sessions, SteamLogin: provider, Now: func() time.Time { return now }} + server := httptest.NewServer(service.Handler()) + defer server.Close() + request := func(body string) *http.Response { + req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/session/steam", strings.NewReader(body)) + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return response + } + response := request(`{"web_api_ticket":"valid-web-ticket","steam_id":"spoofed"}`) + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("extra field status = %d", response.StatusCode) + } + response.Body.Close() + response = request(`{"web_api_ticket":"invalid"}`) + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("invalid ticket status = %d", response.StatusCode) + } + response.Body.Close() + response = request(`{"web_api_ticket":"valid-web-ticket"}`) + if response.StatusCode != http.StatusOK { + t.Fatalf("valid ticket status = %d", response.StatusCode) + } + var result struct { + PlayerID string `json:"player_id"` + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + t.Fatal(err) + } + response.Body.Close() + if result.PlayerID != "player-1" || !strings.Contains(result.AccessToken, ":") || provider.calls != 2 { + t.Fatalf("session result=%+v provider_calls=%d", result, provider.calls) + } +} + func TestQueueRecoveryAPIIsAuthenticatedOwnerOnlyAndExpiresStaleTickets(t *testing.T) { now := time.Unix(1000, 0).UTC() sessions := domain.NewSessionStore()