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
+30
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
@@ -15,6 +16,7 @@ import (
"github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/cosmic-clash/cosmic-clash/server/migrations"
"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/jackc/pgx/v5/stdlib"
"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")
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")
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")
flag.Parse()
if *role != "api" {
@@ -79,7 +84,32 @@ func main() {
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")
}
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)
// 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.ClientIPs = clientIPs
service.MinProtocolVersion = *minProtocolVersion