feat(auth): wire production Steam sign-in and the client login flow

newAPIService never supplied SteamLogin, so POST /v1/session/steam
always returned 503 auth_unavailable in production. The only
implementation was cmd/testkit-api's fake, which derives an identity
from the ticket string itself and accepts anything -- so the passing
integration path was neither deployable nor secure. On the client side
the game started with an empty token and a loopback base URL, and no
production code called configure() or login_steam(); the menu entered
matchmaking directly, so every request failed ERR_UNAUTHORIZED before
reaching the network.

Add a real ISteamUserAuth/AuthenticateUserTicket adapter behind an
interface, so the production login path is testable with only the Valve
call stubbed. It rejects family-shared copies (the account playing does
not own the app) and, by default, VAC- or publisher-banned accounts, and
refuses malformed tickets locally rather than forwarding them.

Crucially it separates our faults from the player's: a Valve outage or a
revoked publisher key returns 503, not 401. Answering 401 would tell a
legitimate player their login failed and send them to fix an account
that is fine while the real fault went unnoticed. A banned identity now
returns 403 rather than a misleading 503.

Sign-in is configuration-gated on the publisher key and App ID: without
them the endpoint keeps returning 503, since silently accepting an
unverified ticket would be worse than refusing to authenticate. A
returning player keeps the player ID they already had, so ratings,
penalties and bans follow the account rather than the session.

Client side: acquire a web-API ticket through GodotSteam's async
signal -- requesting one returns a handle, not a ticket -- using the
existing dynamic-call pattern so stock Godot still parses the project.
The endpoint is configurable for release builds, and matchmaking
completes sign-in before it will queue.

Verified against real PostgreSQL; 232 Godot tests pass.
This commit is contained in:
Josh Creek
2026-09-05 10:57:50 +01:00
parent d40344a2c0
commit f628ccfd35
12 changed files with 701 additions and 14 deletions
+33 -14
View File
@@ -20,12 +20,14 @@ import (
"github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/cosmic-clash/cosmic-clash/server/observability"
"github.com/cosmic-clash/cosmic-clash/server/steam"
)
const maxBodyBytes = 8 << 10
type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error)
type CandidateProviderV2 func(playerID, ticketID string, spec domain.QueueSpec) (domain.Candidate, error)
// ProbeProvider validates a probe answer against the nonce the backend issued
// and returns evidence whose ServerRTT is derived from backend timestamps
// only. It takes a context because the issued nonce is durable: any replica
@@ -116,25 +118,25 @@ type RosterProvider func(context.Context, domain.WorkloadBinding, time.Time) ([]
type ReadinessCheck func(context.Context) error
type Service struct {
Sessions *domain.SessionStore
SessionBackend SessionBackend
SessionIssuer SessionIssuer
SteamLogin SteamLoginProvider
Queue *domain.Queue
Candidate CandidateProvider
CandidateV2 CandidateProviderV2
QueueBackend QueueBackend
CandidateIndex CandidateIndex
Sessions *domain.SessionStore
SessionBackend SessionBackend
SessionIssuer SessionIssuer
SteamLogin SteamLoginProvider
Queue *domain.Queue
Candidate CandidateProvider
CandidateV2 CandidateProviderV2
QueueBackend QueueBackend
CandidateIndex CandidateIndex
// EventFanout, when set, publishes outbox-sourced events through a shared
// transport instead of only this replica's in-memory hub. Without it a
// client connected to a replica other than the one that drained the outbox
// row never receives the event.
EventFanout func(ControlPlaneEvent) error
Probe ProbeProvider
ProbeChallenger ProbeChallengeIssuer
EventFanout func(ControlPlaneEvent) error
Probe ProbeProvider
ProbeChallenger ProbeChallengeIssuer
// CandidateRefresh re-reads a player's durable queue candidate so the
// transient index can be corrected after its RTT changes.
CandidateRefresh func(context.Context, string, time.Time) (domain.Candidate, bool, error)
CandidateRefresh func(context.Context, string, time.Time) (domain.Candidate, bool, error)
ProbeRecorder ProbeRecorder
WorkloadVerify WorkloadVerifier
ResultSubmitter ResultSubmitter
@@ -355,7 +357,18 @@ func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) {
}
now := s.now()
identity, err := s.SteamLogin.Authenticate(r.Context(), input.WebAPITicket, now)
if err != nil || identity.PlayerID == "" || identity.SteamID == "" {
if err != nil {
// A Valve outage or a bad publisher key is our problem, not the
// player's; answering 401 would tell a legitimate player their login
// failed and send them off to fix an account that is fine.
if errors.Is(err, steam.ErrUnavailable) {
writeError(w, http.StatusServiceUnavailable, "auth_unavailable")
return
}
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
if identity.PlayerID == "" || identity.SteamID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
@@ -370,6 +383,12 @@ func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) {
return
}
if err != nil {
// Session issuance refuses an actively banned identity. That is a
// decision about this account, not an outage.
if errors.Is(err, domain.ErrSessionRejected) {
writeError(w, http.StatusForbidden, "identity_banned")
return
}
writeError(w, http.StatusServiceUnavailable, "auth_unavailable")
return
}
+53
View File
@@ -0,0 +1,53 @@
package api
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/cosmic-clash/cosmic-clash/server/steam"
"github.com/cosmic-clash/cosmic-clash/server/store"
)
// SteamTicketVerifier is the boundary to Valve. Keeping it an interface means
// the production login path can be exercised end to end with the external call
// stubbed, instead of only through a fake login provider that skips the whole
// flow.
type SteamTicketVerifier interface {
Verify(ctx context.Context, ticket string) (steam.Identity, error)
}
// SteamLogin is the production SteamLoginProvider: verify the ticket with
// Valve, then resolve the verified Steam ID to a durable player ID.
type SteamLogin struct {
DB *sql.DB
Verifier SteamTicketVerifier
}
// PlayerIDForSteamID derives the durable player ID for a Steam ID on first
// sign-in. It is a hash rather than the Steam ID itself so player IDs, which
// appear in rosters and logs, do not restate the platform identifier.
func PlayerIDForSteamID(steamID string) string {
digest := sha256.Sum256([]byte("cosmic-clash/player/" + steamID))
return "player-" + hex.EncodeToString(digest[:12])
}
func (s SteamLogin) Authenticate(ctx context.Context, ticket string, _ time.Time) (domain.VerifiedIdentity, error) {
if s.DB == nil || s.Verifier == nil {
return domain.VerifiedIdentity{}, domain.ErrTicketRejected
}
identity, err := s.Verifier.Verify(ctx, ticket)
if err != nil {
return domain.VerifiedIdentity{}, err
}
// A returning player keeps the player ID they already had, so ratings,
// penalties and bans follow the account rather than the session.
playerID, err := store.ResolveSteamIdentity(ctx, s.DB, identity.SteamID, PlayerIDForSteamID(identity.SteamID))
if err != nil {
return domain.VerifiedIdentity{}, err
}
return domain.VerifiedIdentity{PlayerID: playerID, SteamID: identity.SteamID}, nil
}