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
+50
View File
@@ -40,3 +40,53 @@ static func initialize() -> Dictionary:
if result is Dictionary and bool(result.get("status", false)):
return {"error": OK, "app_id": 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