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
+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")
}
}
// 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")
}
}