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
+19
View File
@@ -15,6 +15,10 @@ signal assignment_connection_started(assignment: AssignmentState)
signal assignment_connection_failed(detail: String) signal assignment_connection_failed(detail: String)
const DEFAULT_BASE_URL := "http://127.0.0.1:8080" const DEFAULT_BASE_URL := "http://127.0.0.1:8080"
# Release builds must point at the real control plane rather than a developer's
# loopback. The environment variable is read at startup so the same binary can
# be pointed at a staging or production endpoint without a rebuild.
const BASE_URL_ENV := "COSMIC_CLASH_CONTROL_PLANE_URL"
const PERSIST_PATH := "user://matchmaking_state.cfg" const PERSIST_PATH := "user://matchmaking_state.cfg"
const AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS := 5.0 const AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS := 5.0
@@ -156,6 +160,21 @@ func _connect_when_assigned(match_id: String) -> void:
_pending_connect_match_id = match_id _pending_connect_match_id = match_id
# configured_base_url resolves the endpoint this build should use, preferring
# explicit configuration over the loopback development default.
static func configured_base_url() -> String:
var configured := OS.get_environment(BASE_URL_ENV).strip_edges()
if is_valid_base_url(configured):
return configured
return DEFAULT_BASE_URL
# has_session reports whether matchmaking requests can be made at all. Without
# it every request fails ERR_UNAUTHORIZED at the first guard in _start_request.
func has_session() -> bool:
return not access_token.is_empty() and not is_session_expired(session_expires_at)
func configure(url: String, token: String) -> bool: func configure(url: String, token: String) -> bool:
var normalized := url.strip_edges().trim_suffix("/") var normalized := url.strip_edges().trim_suffix("/")
var normalized_token := token.strip_edges() var normalized_token := token.strip_edges()
+52
View File
@@ -24,6 +24,7 @@ var _recovery_poll_seconds := 0.0
var _pending_probe_regions: Array[String] = [] var _pending_probe_regions: Array[String] = []
var _probed_regions: Array[String] = [] var _probed_regions: Array[String] = []
var _deferred_queue := {} var _deferred_queue := {}
var _web_api_ticket_handle := 0
func _ready() -> void: func _ready() -> void:
@@ -39,10 +40,57 @@ func _ready() -> void:
ControlPlaneClient.session_expired.connect(_on_session_expired) ControlPlaneClient.session_expired.connect(_on_session_expired)
ControlPlaneClient.probe_challenge_received.connect(_on_probe_challenge_received) ControlPlaneClient.probe_challenge_received.connect(_on_probe_challenge_received)
ControlPlaneClient.probe_recorded.connect(_on_probe_recorded) ControlPlaneClient.probe_recorded.connect(_on_probe_recorded)
_ensure_signed_in()
_refresh_ranked_profile() _refresh_ranked_profile()
_render(ControlPlaneClient.state.snapshot()) _render(ControlPlaneClient.state.snapshot())
# Matchmaking previously opened with an empty token against a loopback default,
# so every request failed ERR_UNAUTHORIZED before reaching the network. Point
# the client at its configured endpoint and complete Steam sign-in first.
func _ensure_signed_in() -> void:
if ControlPlaneClient.has_session():
return
if not ControlPlaneClient.configure(ControlPlaneClient.configured_base_url(), ""):
_on_local_error("Matchmaking endpoint is not configured")
return
if not SteamBootstrap.supports_web_api_ticket():
# Deliberately explicit rather than silently presenting a search that
# can never start: online matchmaking requires a verified identity.
_on_local_error("Sign-in requires the Steam build: %s" % SteamBootstrap.unavailable_reason())
return
var steam := Engine.get_singleton("Steam")
if not steam.get_auth_ticket_for_web_api.is_connected(_on_web_api_ticket):
steam.get_auth_ticket_for_web_api.connect(_on_web_api_ticket)
_web_api_ticket_handle = SteamBootstrap.request_web_api_ticket()
if _web_api_ticket_handle <= 0:
_on_local_error("Could not request a Steam authentication ticket")
return
ControlPlaneClient.state.set_notice("Signing in...")
func _on_web_api_ticket(_handle: int, result: int, ticket: PackedByteArray) -> void:
# Steam reports k_EResultOK as 1; anything else means no usable ticket.
if result != 1 or ticket.is_empty():
_on_local_error("Steam declined to issue an authentication ticket")
return
var encoded := SteamBootstrap.encode_web_api_ticket(ticket)
if encoded.is_empty():
_on_local_error("Steam returned an unusable authentication ticket")
return
var err := ControlPlaneClient.login_steam(encoded)
if err != OK:
_on_local_error("Could not sign in: %s" % error_string(err))
func _exit_tree() -> void:
# The ticket handle is a Steam resource; releasing it avoids leaking one
# per visit to this screen.
if _web_api_ticket_handle > 0:
SteamBootstrap.cancel_web_api_ticket(_web_api_ticket_handle)
_web_api_ticket_handle = 0
func _process(delta: float) -> void: func _process(delta: float) -> void:
if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING]: if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING]:
_elapsed_seconds += delta _elapsed_seconds += delta
@@ -62,6 +110,10 @@ func _process(delta: float) -> void:
func _on_queue_pressed() -> void: func _on_queue_pressed() -> void:
if not ControlPlaneClient.has_session():
# Queueing without a session would fail at the first request guard.
_ensure_signed_in()
return
if ControlPlaneClient.can_retry_queue_create(): if ControlPlaneClient.can_retry_queue_create():
var retry_err := ControlPlaneClient.retry_queue_create() var retry_err := ControlPlaneClient.retry_queue_create()
if retry_err != OK: if retry_err != OK:
+50
View File
@@ -40,3 +40,53 @@ static func initialize() -> Dictionary:
if result is Dictionary and bool(result.get("status", false)): if result is Dictionary and bool(result.get("status", false)):
return {"error": OK, "app_id": app_id()} return {"error": OK, "app_id": app_id()}
return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()} return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()}
# Web-API auth ticket acquisition (task 7.6). The control plane exchanges this
# ticket with Valve's publisher API for a verified Steam identity; the client
# never chooses its own identity, which is what makes this the fix for slot
# reclaim being keyed on a display name.
#
# GodotSteam delivers the ticket asynchronously through the
# `get_auth_ticket_for_web_api` signal, because the ticket is not usable until
# Steam has confirmed it with its backend. Requesting one and reading the
# return value alone yields a handle, not a ticket.
#
# Everything here is called dynamically so stock Godot, which has no GodotSteam
# symbols, can still parse and run the project.
const WEB_API_IDENTITY := "cosmicclash"
static func supports_web_api_ticket() -> bool:
if not is_runtime_available():
return false
var steam := Engine.get_singleton("Steam")
return steam.has_signal("get_auth_ticket_for_web_api") and steam.has_method("getAuthTicketForWebApi")
# Returns the request handle, or 0 when unavailable. The caller must await the
# `get_auth_ticket_for_web_api` signal for the ticket itself.
static func request_web_api_ticket() -> int:
if not supports_web_api_ticket():
return 0
var steam := Engine.get_singleton("Steam")
var handle = steam.call("getAuthTicketForWebApi", WEB_API_IDENTITY)
return int(handle) if handle is int or handle is float else 0
static func cancel_web_api_ticket(handle: int) -> void:
if handle <= 0 or not is_runtime_available():
return
var steam := Engine.get_singleton("Steam")
if steam.has_method("cancelAuthTicket"):
steam.call("cancelAuthTicket", handle)
# GodotSteam hands back raw ticket bytes; the Web API expects them hex encoded.
static func encode_web_api_ticket(buffer: PackedByteArray) -> String:
if buffer.is_empty():
return ""
var encoded := ""
for byte in buffer:
encoded += "%02x" % int(byte)
return encoded
@@ -557,3 +557,26 @@ func test_probe_challenge_response_without_a_nonce_is_a_failure() -> void:
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 201, PackedStringArray(), JSON.stringify({"region": "EU"}).to_utf8_buffer()) client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 201, PackedStringArray(), JSON.stringify({"region": "EU"}).to_utf8_buffer())
assert_eq(failures.size(), 1, "a challenge with no nonce is reported as a failure") assert_eq(failures.size(), 1, "a challenge with no nonce is reported as a failure")
client.free() client.free()
# The game started with an empty token against a loopback default and no
# production code ever called configure() or login_steam(), so every
# matchmaking request failed ERR_UNAUTHORIZED before reaching the network.
func test_has_session_reflects_token_and_expiry() -> void:
var client = ControlPlaneClient.new()
assert_true(not client.has_session(), "a fresh client has no session")
client.access_token = "session-1234567890:token-1234567890"
client.session_expires_at = "2099-01-01T00:00:00Z"
assert_true(client.has_session(), "a valid unexpired token is a session")
client.session_expires_at = "2000-01-01T00:00:00Z"
assert_true(not client.has_session(), "an expired token is not a session")
client.free()
func test_configured_base_url_falls_back_to_the_development_default() -> void:
# Release builds set COSMIC_CLASH_CONTROL_PLANE_URL; without it the
# loopback default keeps local development working.
var resolved := ControlPlaneClient.configured_base_url()
assert_true(ControlPlaneClient.is_valid_base_url(resolved), "the resolved endpoint is always usable")
if OS.get_environment(ControlPlaneClient.BASE_URL_ENV).strip_edges().is_empty():
assert_eq(resolved, ControlPlaneClient.DEFAULT_BASE_URL, "falls back to the development default")
+29
View File
@@ -0,0 +1,29 @@
extends "res://tests/test_case.gd"
const SteamBootstrap = preload("res://scripts/steam_bootstrap.gd")
# Web-API ticket acquisition (task 7.6). Nothing in the project could obtain a
# ticket before, so ControlPlaneClient.login_steam() had no production caller.
# These run on stock Godot, which has no GodotSteam symbols, so they cover the
# pure encoding and the unavailable path rather than a live Steam session.
func test_web_api_ticket_is_unsupported_without_the_steam_runtime() -> void:
if SteamBootstrap.is_runtime_available():
return
assert_true(not SteamBootstrap.supports_web_api_ticket(), "no ticket support without the custom build")
assert_eq(SteamBootstrap.request_web_api_ticket(), 0, "requesting a ticket yields no handle")
# Must not throw on stock Godot; cancelling a handle we never got is a no-op.
SteamBootstrap.cancel_web_api_ticket(0)
SteamBootstrap.cancel_web_api_ticket(17)
func test_web_api_ticket_encoding_is_lowercase_hex() -> void:
# The publisher Web API expects the raw ticket bytes hex encoded; the
# backend rejects anything non-hex before it forwards a ticket to Valve.
assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray()), "", "an empty ticket encodes to nothing")
assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray([0x00, 0x0f, 0xa5, 0xff])), "000fa5ff", "bytes are zero-padded lowercase hex")
var encoded := SteamBootstrap.encode_web_api_ticket(PackedByteArray([1, 2, 3, 4, 250]))
assert_eq(encoded.length(), 10, "each byte becomes exactly two characters")
assert_eq(encoded, encoded.to_lower(), "encoding is lowercase")
+20 -1
View File
@@ -20,12 +20,14 @@ import (
"github.com/cosmic-clash/cosmic-clash/server/domain" "github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/cosmic-clash/cosmic-clash/server/observability" "github.com/cosmic-clash/cosmic-clash/server/observability"
"github.com/cosmic-clash/cosmic-clash/server/steam"
) )
const maxBodyBytes = 8 << 10 const maxBodyBytes = 8 << 10
type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error) type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error)
type CandidateProviderV2 func(playerID, ticketID string, spec domain.QueueSpec) (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 // ProbeProvider validates a probe answer against the nonce the backend issued
// and returns evidence whose ServerRTT is derived from backend timestamps // and returns evidence whose ServerRTT is derived from backend timestamps
// only. It takes a context because the issued nonce is durable: any replica // only. It takes a context because the issued nonce is durable: any replica
@@ -355,7 +357,18 @@ func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) {
} }
now := s.now() now := s.now()
identity, err := s.SteamLogin.Authenticate(r.Context(), input.WebAPITicket, 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") writeError(w, http.StatusUnauthorized, "unauthorized")
return return
} }
@@ -370,6 +383,12 @@ func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) {
return return
} }
if err != nil { 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") writeError(w, http.StatusServiceUnavailable, "auth_unavailable")
return 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
}
+30
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"syscall" "syscall"
"time" "time"
@@ -15,6 +16,7 @@ import (
"github.com/cosmic-clash/cosmic-clash/server/domain" "github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/cosmic-clash/cosmic-clash/server/migrations" "github.com/cosmic-clash/cosmic-clash/server/migrations"
"github.com/cosmic-clash/cosmic-clash/server/observability" "github.com/cosmic-clash/cosmic-clash/server/observability"
"github.com/cosmic-clash/cosmic-clash/server/steam"
"github.com/cosmic-clash/cosmic-clash/server/store" "github.com/cosmic-clash/cosmic-clash/server/store"
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@@ -34,6 +36,9 @@ func main() {
rateWindow := flag.Duration("rate-limit-window", time.Minute, "fixed window for the per-replica request limiter") rateWindow := flag.Duration("rate-limit-window", time.Minute, "fixed window for the per-replica request limiter")
rateMaxKeys := flag.Int("rate-limit-max-keys", 10000, "maximum credential/IP keys retained by the per-replica request limiter") rateMaxKeys := flag.Int("rate-limit-max-keys", 10000, "maximum credential/IP keys retained by the per-replica request limiter")
trustedProxyCIDRs := flag.String("trusted-proxy-cidrs", os.Getenv("COSMIC_CLASH_TRUSTED_PROXY_CIDRS"), "comma-separated immediate proxy CIDRs allowed to supply X-Forwarded-For") trustedProxyCIDRs := flag.String("trusted-proxy-cidrs", os.Getenv("COSMIC_CLASH_TRUSTED_PROXY_CIDRS"), "comma-separated immediate proxy CIDRs allowed to supply X-Forwarded-For")
steamPublisherKey := flag.String("steam-publisher-key", os.Getenv("COSMIC_CLASH_STEAM_PUBLISHER_KEY"), "Steamworks publisher Web API key. Required for player sign-in; POST /v1/session/steam returns 503 until it and --steam-app-id are set. Never expose this to clients")
steamAppID := flag.Uint64("steam-app-id", 0, "Steamworks App ID this build authenticates tickets for; may also be set via COSMIC_CLASH_STEAM_APP_ID")
steamRejectBanned := flag.Bool("steam-reject-banned", true, "refuse sign-in for VAC- or publisher-banned accounts")
minProtocolVersion := flag.Int("min-protocol-version", 0, "reject queue_create below this protocol_version with 426 Upgrade Required instead of queueing a client the matcher can never pair with anyone; zero disables the floor") minProtocolVersion := flag.Int("min-protocol-version", 0, "reject queue_create below this protocol_version with 426 Upgrade Required instead of queueing a client the matcher can never pair with anyone; zero disables the floor")
flag.Parse() flag.Parse()
if *role != "api" { if *role != "api" {
@@ -79,7 +84,32 @@ func main() {
if *workloadSecret == "" { if *workloadSecret == "" {
fmt.Fprintln(os.Stderr, "control-plane: warning: --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET is unset; server registration and result submission will return 503") fmt.Fprintln(os.Stderr, "control-plane: warning: --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET is unset; server registration and result submission will return 503")
} }
if *steamAppID == 0 {
if value := os.Getenv("COSMIC_CLASH_STEAM_APP_ID"); value != "" {
parsed, parseErr := strconv.ParseUint(value, 10, 64)
if parseErr != nil {
fatalf("COSMIC_CLASH_STEAM_APP_ID must be a positive integer")
}
*steamAppID = parsed
}
}
service := newAPIService(db, *workloadSecret, candidateIndex) service := newAPIService(db, *workloadSecret, candidateIndex)
// Player sign-in is configuration-gated rather than always-on: without a
// publisher key there is no safe way to verify a ticket, and silently
// accepting one would be worse than refusing to authenticate at all. The
// endpoint keeps returning 503 until both values are supplied.
if *steamPublisherKey != "" && *steamAppID != 0 {
service.SteamLogin = api.SteamLogin{
DB: db,
Verifier: steam.WebAPIVerifier{
PublisherKey: *steamPublisherKey,
AppID: *steamAppID,
RejectBanned: *steamRejectBanned,
},
}
} else {
fmt.Fprintln(os.Stderr, "control-plane: warning: --steam-publisher-key and --steam-app-id are unset; player sign-in will return 503")
}
service.RateLimiter = rateLimiter service.RateLimiter = rateLimiter
service.ClientIPs = clientIPs service.ClientIPs = clientIPs
service.MinProtocolVersion = *minProtocolVersion service.MinProtocolVersion = *minProtocolVersion
+185
View File
@@ -0,0 +1,185 @@
// Package steam adapts Valve's publisher Web API to the control plane's
// SteamLoginProvider. It is the only place that talks to Valve, so the rest of
// the service stays testable without network access or a publisher key.
package steam
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// AuthenticateUserTicketURL is the publisher endpoint. Only the backend may
// call it: it requires the publisher key, which must never reach a client.
const AuthenticateUserTicketURL = "https://partner.steam-api.com/ISteamUserAuth/AuthenticateUserTicket/v1/"
// MaxTicketBytes bounds what will be forwarded to Valve. A web-API ticket is a
// few hundred hex characters; anything larger is abuse, not a ticket.
const MaxTicketBytes = 4096
var (
// ErrTicketRejected is returned for any ticket Valve does not accept, and
// for a ticket issued for another application. It deliberately does not
// distinguish those cases to the caller.
ErrTicketRejected = fmt.Errorf("steam ticket rejected")
// ErrUnavailable separates "Valve is down or misconfigured" from "this
// player's ticket is bad", so the API can answer 503 rather than telling a
// legitimate player their login failed.
ErrUnavailable = fmt.Errorf("steam authentication is unavailable")
)
// Identity is what a verified ticket proves. It is deliberately not
// domain.VerifiedIdentity: this package resolves a Steam ID, and mapping that
// onto a durable player ID is the caller's business.
type Identity struct {
SteamID string
OwnerSteamID string
VACBanned bool
PublisherBan bool
}
// WebAPIVerifier calls Valve's publisher API. Construct it only when a
// publisher key and App ID are configured; the control plane leaves its login
// provider unset otherwise, which surfaces as an explicit 503.
type WebAPIVerifier struct {
PublisherKey string
AppID uint64
HTTP *http.Client
// Endpoint overrides the Valve URL in tests. Production leaves it empty.
Endpoint string
// RejectBanned refuses a VAC- or publisher-banned account at login.
RejectBanned bool
}
func (v WebAPIVerifier) validate() error {
if v.PublisherKey == "" || v.AppID == 0 {
return ErrUnavailable
}
return nil
}
func (v WebAPIVerifier) endpoint() string {
if v.Endpoint != "" {
return v.Endpoint
}
return AuthenticateUserTicketURL
}
func (v WebAPIVerifier) httpClient() *http.Client {
if v.HTTP != nil {
return v.HTTP
}
return &http.Client{Timeout: 10 * time.Second}
}
// authenticateResponse is Valve's shape. Fields absent from a failure response
// stay zero, which the result check below rejects.
type authenticateResponse struct {
Response struct {
Params struct {
Result string `json:"result"`
SteamID string `json:"steamid"`
OwnerSteamID string `json:"ownersteamid"`
VACBanned bool `json:"vacbanned"`
PublisherBanned bool `json:"publisherbanned"`
} `json:"params"`
Error *struct {
ErrorCode int `json:"errorcode"`
ErrorDesc string `json:"errordesc"`
} `json:"error"`
} `json:"response"`
}
// Verify exchanges a client-supplied web-API ticket for a Steam identity.
//
// The ticket is single-use at Valve's end and the client never gets to choose
// the resulting Steam ID, which is the property that makes this the fix for
// slot reclaim being keyed on a display name.
func (v WebAPIVerifier) Verify(ctx context.Context, ticket string) (Identity, error) {
if err := v.validate(); err != nil {
return Identity{}, err
}
ticket = strings.TrimSpace(ticket)
if ticket == "" || len(ticket) > MaxTicketBytes || !isHex(ticket) {
// Rejected locally: a malformed ticket is never worth a round trip,
// and this bounds what an unauthenticated caller can make us forward.
return Identity{}, ErrTicketRejected
}
query := url.Values{}
query.Set("key", v.PublisherKey)
query.Set("appid", strconv.FormatUint(v.AppID, 10))
query.Set("ticket", ticket)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, v.endpoint()+"?"+query.Encode(), nil)
if err != nil {
return Identity{}, ErrUnavailable
}
response, err := v.httpClient().Do(request)
if err != nil {
return Identity{}, ErrUnavailable
}
defer response.Body.Close()
// Bounded read: this is a third-party response and must not be able to
// exhaust memory.
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return Identity{}, ErrUnavailable
}
if response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusUnauthorized {
// Our publisher key is wrong or revoked. That is our problem, not the
// player's, so it must not read as a rejected ticket.
return Identity{}, ErrUnavailable
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return Identity{}, ErrUnavailable
}
var decoded authenticateResponse
if err := json.Unmarshal(body, &decoded); err != nil {
return Identity{}, ErrUnavailable
}
if decoded.Response.Error != nil || !strings.EqualFold(decoded.Response.Params.Result, "OK") {
return Identity{}, ErrTicketRejected
}
identity := Identity{
SteamID: decoded.Response.Params.SteamID,
OwnerSteamID: decoded.Response.Params.OwnerSteamID,
VACBanned: decoded.Response.Params.VACBanned,
PublisherBan: decoded.Response.Params.PublisherBanned,
}
if !isSteamID(identity.SteamID) {
return Identity{}, ErrTicketRejected
}
if identity.OwnerSteamID != "" && identity.OwnerSteamID != identity.SteamID {
// Family sharing: the account playing does not own the app. Treat it
// as a rejection rather than silently matchmaking a borrowed copy.
return Identity{}, ErrTicketRejected
}
if v.RejectBanned && (identity.VACBanned || identity.PublisherBan) {
return Identity{}, ErrTicketRejected
}
return identity, nil
}
func isHex(value string) bool {
for _, r := range value {
switch {
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
func isSteamID(value string) bool {
if len(value) < 17 || len(value) > 20 {
return false
}
parsed, err := strconv.ParseUint(value, 10, 64)
return err == nil && parsed > 0
}
+143
View File
@@ -0,0 +1,143 @@
package steam
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const validTicket = "140000008bc0a1f45fd4b4b7e0af2c4a01001001"
func stubValve(t *testing.T, status int, body string, inspect func(*http.Request)) WebAPIVerifier {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if inspect != nil {
inspect(r)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
return WebAPIVerifier{PublisherKey: "publisher-key", AppID: 480, Endpoint: server.URL, HTTP: server.Client()}
}
func TestVerifyReturnsIdentityForAnAcceptedTicket(t *testing.T) {
var seen *http.Request
verifier := stubValve(t, http.StatusOK,
`{"response":{"params":{"result":"OK","steamid":"76561198000000001","ownersteamid":"76561198000000001","vacbanned":false,"publisherbanned":false}}}`,
func(r *http.Request) { seen = r })
identity, err := verifier.Verify(context.Background(), validTicket)
if err != nil {
t.Fatalf("verify: %v", err)
}
if identity.SteamID != "76561198000000001" {
t.Fatalf("identity = %+v", identity)
}
// The publisher key must be sent to Valve and nowhere else; assert it is
// carried in the request rather than, say, logged or returned.
if seen.URL.Query().Get("key") != "publisher-key" || seen.URL.Query().Get("appid") != "480" {
t.Fatalf("request query = %s", seen.URL.RawQuery)
}
if seen.URL.Query().Get("ticket") != validTicket {
t.Fatalf("ticket was not forwarded verbatim: %s", seen.URL.Query().Get("ticket"))
}
}
func TestVerifyRejectsTicketsValveDoesNotAccept(t *testing.T) {
for name, body := range map[string]string{
"explicit failure": `{"response":{"params":{"result":"Failure","steamid":"76561198000000001"}}}`,
"error object": `{"response":{"error":{"errorcode":101,"errordesc":"Invalid ticket"}}}`,
"empty response": `{"response":{}}`,
"no steam id": `{"response":{"params":{"result":"OK"}}}`,
"bogus steam id": `{"response":{"params":{"result":"OK","steamid":"not-a-steam-id"}}}`,
} {
t.Run(name, func(t *testing.T) {
verifier := stubValve(t, http.StatusOK, body, nil)
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
t.Fatalf("err = %v, want ErrTicketRejected", err)
}
})
}
}
func TestVerifyRejectsFamilySharedAndBannedAccounts(t *testing.T) {
shared := stubValve(t, http.StatusOK,
`{"response":{"params":{"result":"OK","steamid":"76561198000000002","ownersteamid":"76561198000000001"}}}`, nil)
if _, err := shared.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
t.Fatalf("family-shared copy accepted: %v", err)
}
banned := stubValve(t, http.StatusOK,
`{"response":{"params":{"result":"OK","steamid":"76561198000000001","ownersteamid":"76561198000000001","vacbanned":true}}}`, nil)
banned.RejectBanned = true
if _, err := banned.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
t.Fatalf("VAC-banned account accepted: %v", err)
}
banned.RejectBanned = false
if _, err := banned.Verify(context.Background(), validTicket); err != nil {
t.Fatalf("ban enforcement should be configurable: %v", err)
}
}
// A Valve outage or a revoked publisher key must not read as "this player's
// ticket is bad", or a legitimate player is told to fix an account that is
// fine while the real fault goes unnoticed.
func TestVerifyDistinguishesOurFaultsFromBadTickets(t *testing.T) {
for name, status := range map[string]int{
"revoked publisher key": http.StatusForbidden,
"unauthorized": http.StatusUnauthorized,
"valve error": http.StatusInternalServerError,
"valve gateway": http.StatusBadGateway,
} {
t.Run(name, func(t *testing.T) {
verifier := stubValve(t, status, `{}`, nil)
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
t.Fatalf("err = %v, want ErrUnavailable", err)
}
})
}
t.Run("malformed response", func(t *testing.T) {
verifier := stubValve(t, http.StatusOK, `not json`, nil)
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
t.Fatalf("err = %v, want ErrUnavailable", err)
}
})
}
func TestVerifyRefusesMalformedTicketsWithoutCallingValve(t *testing.T) {
called := false
verifier := stubValve(t, http.StatusOK, `{}`, func(*http.Request) { called = true })
for name, ticket := range map[string]string{
"empty": "",
"whitespace": " ",
"not hex": "zzzz-not-a-ticket",
"oversized": strings.Repeat("a", MaxTicketBytes+1),
} {
t.Run(name, func(t *testing.T) {
if _, err := verifier.Verify(context.Background(), ticket); !errors.Is(err, ErrTicketRejected) {
t.Fatalf("err = %v, want ErrTicketRejected", err)
}
})
}
if called {
t.Fatal("a malformed ticket was forwarded to Valve")
}
}
func TestVerifyIsUnavailableWithoutCredentials(t *testing.T) {
for name, verifier := range map[string]WebAPIVerifier{
"no key": {AppID: 480},
"no app id": {PublisherKey: "publisher-key"},
"neither": {},
} {
t.Run(name, func(t *testing.T) {
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
t.Fatalf("err = %v, want ErrUnavailable", err)
}
})
}
}
+61
View File
@@ -2277,3 +2277,64 @@ func TestPostgreSQLProbedTicketBecomesSelectableByTheMatcher(t *testing.T) {
t.Fatal("the refreshed candidate still carries an empty RTT map, so Redis would keep a stale entry") t.Fatal("the refreshed candidate still carries an empty RTT map, so Redis would keep a stale entry")
} }
} }
// Production sign-in, exercised through the real SteamLogin provider with only
// the Valve HTTP call stubbed. newAPIService never supplied SteamLogin, so
// POST /v1/session/steam always returned 503 auth_unavailable; the only
// implementation was cmd/testkit-api's fake, which accepts any ticket string
// and therefore proves nothing about the deployable path.
func TestPostgreSQLSteamLoginResolvesDurableIdentities(t *testing.T) {
db := openIntegrationPostgres(t)
applyIntegrationMigrations(t, db)
ctx := context.Background()
const steamID = "76561198000000001"
// First sign-in creates the identity.
playerID, err := ResolveSteamIdentity(ctx, db, steamID, "player-first")
if err != nil {
t.Fatalf("first sign-in: %v", err)
}
if playerID != "player-first" {
t.Fatalf("first sign-in player = %q", playerID)
}
// A returning player must keep the player ID they already had, or their
// ratings, penalties and bans would silently detach from their account.
returning, err := ResolveSteamIdentity(ctx, db, steamID, "player-different-proposal")
if err != nil {
t.Fatalf("returning sign-in: %v", err)
}
if returning != "player-first" {
t.Fatalf("returning player was given a new ID %q", returning)
}
var identities int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM identities WHERE steam_id = $1`, steamID).Scan(&identities); err != nil {
t.Fatal(err)
}
if identities != 1 {
t.Fatalf("one Steam ID produced %d identity rows", identities)
}
// The resolved identity must be usable for session issuance, which is the
// step that was unreachable in production.
sessions := PostgresSessions{DB: db}
now := time.Now().UTC().Truncate(time.Microsecond)
session, token, err := sessions.Issue(ctx, returning, time.Hour, now)
if err != nil {
t.Fatalf("issue session for a freshly resolved identity: %v", err)
}
if _, err := sessions.Authenticate(ctx, session.SessionID, token, now); err != nil {
t.Fatalf("authenticate freshly issued session: %v", err)
}
// And a banned account cannot sign in, tying the real login path to the
// durable ban enforcement rather than leaving it adapter-specific.
if err := ApplyIdentityBan(ctx, db, returning, "cheating", now.Add(time.Hour), now); err != nil {
t.Fatalf("apply ban: %v", err)
}
if _, _, err := sessions.Issue(ctx, returning, time.Hour, now.Add(time.Minute)); err == nil {
t.Fatal("a banned identity signed in through the production path")
}
}
+23
View File
@@ -163,3 +163,26 @@ func ApplyIdentityBan(ctx context.Context, db *sql.DB, playerID, reason string,
return err return err
}) })
} }
// IdentityUpsertSQL resolves a verified Steam ID to a durable player ID,
// creating the identity on first sign-in. The player ID is derived by the
// backend and never supplied by the client.
const IdentityUpsertSQL = `INSERT INTO identities (player_id, steam_id)
VALUES ($1, $2)
ON CONFLICT (steam_id) DO UPDATE SET steam_id = EXCLUDED.steam_id
RETURNING player_id`
// ResolveSteamIdentity returns the player ID for a verified Steam ID. The
// proposed ID is used only when this Steam ID has never signed in before; an
// existing identity keeps the player ID it already had, so a returning player
// keeps their ratings and penalties.
func ResolveSteamIdentity(ctx context.Context, db *sql.DB, steamID, proposedPlayerID string) (string, error) {
if db == nil || steamID == "" || proposedPlayerID == "" {
return "", domain.ErrTicketRejected
}
var playerID string
if err := db.QueryRowContext(ctx, IdentityUpsertSQL, proposedPlayerID, steamID).Scan(&playerID); err != nil {
return "", err
}
return playerID, nil
}