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
@@ -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())
assert_eq(failures.size(), 1, "a challenge with no nonce is reported as a failure")
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")