feat: add verified Steam session endpoint

This commit is contained in:
Josh Creek
2026-08-31 22:26:27 +01:00
parent a2a7107dd7
commit 8253d772cb
3 changed files with 106 additions and 1 deletions
+1 -1
View File
@@ -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 |
+53
View File
@@ -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"})
}
+52
View File
@@ -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()