diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 4f78ff0e..8dc5a381 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -7,6 +7,7 @@ extends Node signal request_succeeded(operation: String, payload: Dictionary) signal request_failed(operation: String, http_code: int, detail: String) signal session_expired() +signal session_changed(player_id: String) const DEFAULT_BASE_URL := "http://127.0.0.1:8080" const PERSIST_PATH := "user://matchmaking_state.cfg" @@ -14,6 +15,8 @@ const PERSIST_PATH := "user://matchmaking_state.cfg" var base_url := DEFAULT_BASE_URL var access_token := "" var auth_expired := false +var player_id := "" +var session_expires_at := "" var state: MatchmakingState var ranked_profile: RankedProfileState @@ -36,7 +39,7 @@ func _ready() -> void: func configure(url: String, token: String) -> bool: var normalized := url.strip_edges().trim_suffix("/") var normalized_token := token.strip_edges() - if not is_valid_base_url(normalized) or normalized_token.is_empty() or normalized_token.contains("\r") or normalized_token.contains("\n"): + if not is_valid_base_url(normalized) or not is_valid_access_token(normalized_token): return false base_url = normalized access_token = normalized_token @@ -57,6 +60,12 @@ func queue_create(ticket_id: String, playlist: String, client_build: String, pro return err +func login_steam(web_api_ticket: String) -> Error: + if not is_valid_web_api_ticket(web_api_ticket): + return ERR_INVALID_PARAMETER + return _start_request("steam_session", HTTPClient.METHOD_POST, "/v1/session/steam", {"web_api_ticket": web_api_ticket}, "") + + func retry_queue_create() -> Error: if _last_queue_create.is_empty() or not _last_queue_create.has("ticket_id"): return ERR_INVALID_DATA @@ -115,6 +124,15 @@ static func is_valid_base_url(url: String) -> bool: return url.begins_with("http://") or url.begins_with("https://") +static func is_valid_web_api_ticket(ticket: String) -> bool: + return not ticket.is_empty() and ticket.length() <= 4096 and not ticket.contains("\r") and not ticket.contains("\n") + + +static func is_valid_access_token(token: String) -> bool: + var separator := token.find(":") + return separator > 0 and separator < token.length() - 1 and token.length() <= 4096 and not token.contains("\r") and not token.contains("\n") + + static func normalize_ticket(payload: Dictionary) -> Dictionary: var result := payload.duplicate(true) if result.has("expires_at") and result["expires_at"] is String: @@ -123,9 +141,13 @@ static func normalize_ticket(payload: Dictionary) -> Dictionary: func _start_request(operation: String, method: HTTPClient.Method, path: String, payload: Dictionary, idempotency_key: String, expected_revision: int = -1) -> Error: - if _request == null or not _operation.is_empty() or access_token.is_empty() or not is_valid_base_url(base_url): + if _request == null or not _operation.is_empty() or not is_valid_base_url(base_url): return ERR_BUSY if not _operation.is_empty() else ERR_UNAUTHORIZED - var headers := PackedStringArray(["Authorization: Bearer " + access_token, "Accept: application/json"]) + if operation != "steam_session" and access_token.is_empty(): + return ERR_UNAUTHORIZED + var headers := PackedStringArray(["Accept: application/json"]) + if operation != "steam_session": + headers.append("Authorization: Bearer " + access_token) if not idempotency_key.is_empty(): headers.append("Idempotency-Key: " + idempotency_key) if expected_revision >= 0: @@ -184,7 +206,18 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head request_failed.emit(operation, response_code, detail) return var payload: Dictionary = parsed - if operation == "queue_create": + if operation == "steam_session": + var returned_token := String(payload.get("access_token", "")) + var returned_player_id := String(payload.get("player_id", "")) + if returned_player_id.is_empty() or not is_valid_access_token(returned_token): + request_failed.emit(operation, response_code, "invalid session response") + return + player_id = returned_player_id + access_token = returned_token + auth_expired = false + session_expires_at = String(payload.get("expires_at", "")) + session_changed.emit(player_id) + elif operation == "queue_create": state.begin_queue(String(payload.get("ticket_id", "")), String(payload.get("playlist", ""))) if operation.begins_with("queue_"): state.apply_ticket_update(normalize_ticket(payload)) diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index e3156ea8..637c8cd8 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -16,6 +16,12 @@ func test_base_url_validation_rejects_ambiguous_or_insecure_values() -> void: var client := ControlPlaneClient.new() assert_true(client.configure("https://match.example", "session-id:opaque-token"), "safe access token configures") assert_true(not client.configure("https://match.example", "token\nforged-header"), "header injection is rejected") + assert_true(ControlPlaneClient.is_valid_web_api_ticket("ticket-value"), "ordinary Steam Web API ticket is accepted") + assert_true(not ControlPlaneClient.is_valid_web_api_ticket("ticket\nforged"), "ticket header characters are rejected") + assert_true(not ControlPlaneClient.is_valid_web_api_ticket(""), "empty Steam ticket is rejected") + assert_true(ControlPlaneClient.is_valid_access_token("session-id:opaque-token"), "opaque session format is accepted") + assert_true(not ControlPlaneClient.is_valid_access_token(":opaque-token"), "missing session identifier is rejected") + assert_true(not ControlPlaneClient.is_valid_access_token("session-id:token\nforged"), "session header injection is rejected") func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void: diff --git a/multiplayer-todo.md b/multiplayer-todo.md index b6c45bd3..9c115f9e 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1135,7 +1135,7 @@ New `--role=client-reconnect` grades the returning player: not a spectator, owns | 7.3 `[D:7.2]` `[P]` | **IN PROGRESS.** Server-browser UI and `ISteamMatchmakingServers` adapter remain intentionally unimplemented until the pinned GodotSteam client API is available; ENet direct-IP remains the supported browser-free path | No `server_browser.tscn` or fake Steam API has been added; implementation must wait for real Steam SDK/API access so Internet/LAN/favourites/history behavior can be verified against the actual service | | 7.4 `[D:7.2]` `[P]` | **IN PROGRESS.** `TicketVerifier` now supports a synchronized backend ban decision before single-use ticket consumption; auth tickets in `hello` → `BeginAuthSession`, Steam identity in the roster and persistent ban list remain | `server/domain/auth.go` and adversarial tests reject banned identities without consuming their ticket and allow a later verification after unban; GodotSteam auth integration, server-side VAC state and durable ban storage remain | | 7.5 `[D:7.2]` `[P]` | **IN PROGRESS.** `SteamBootstrap` gates initialization on the `steam` feature, `SteamMultiplayerPeer` class and Steam singleton; explicit Steam selection fails closed, while ENet remains the default and never becomes an implicit fallback | `test_net_transport.gd` proves stock builds keep ENet available and reject unavailable Steam requests without returning an ENet peer; custom Steam client/server export smoke remains blocked on pinned GodotSteam binaries, and full ENet runtime verification remains blocked on the absent Godot executable | -| 7.6 `[D:7.4]` | **IN PROGRESS.** Pure Go `AuthCoordinator` models pending/accepted/rejected/cancelled backend auth sessions around the single-use verifier, including maintenance expiry sweeping; `store.PostgresSessions` persists only token digests, the authenticated API can inject that durable session backend, and `POST /v1/session/steam` issues sessions only from an injected verified-identity provider | `server/domain/auth.go`, `server/store/session_sql.go`, `server/api/service.go` and adversarial tests keep identity unavailable while pending, reject cancellation/expiry/wrong-attempt/replay, expire abandoned attempts at the boundary, release identity only after verifier success, reject invalid session inputs/extra identity fields, prove API delegation, and issue opaque sessions; real Steam BeginAuthSession/EndAuthSession adapter and live PostgreSQL/session integration remain | +| 7.6 `[D:7.4]` | **IN PROGRESS.** Pure Go `AuthCoordinator` models pending/accepted/rejected/cancelled backend auth sessions around the single-use verifier, including maintenance expiry sweeping; `store.PostgresSessions` persists only token digests; the authenticated API issues sessions only from an injected verified-identity provider; Godot `ControlPlaneClient.login_steam()` now submits only the Web API ticket, validates the opaque response and stores the session in memory | `server/domain/auth.go`, `server/store/session_sql.go`, `server/api/service.go`, `control_plane_client.gd` and adversarial tests keep identity unavailable while pending, reject cancellation/expiry/wrong-attempt/replay, expire abandoned attempts at the boundary, release identity only after verifier success, reject invalid session inputs/extra identity fields, prove API delegation, validate ticket/session header boundaries and issue opaque sessions; real Steam BeginAuthSession/EndAuthSession adapter, login UI and live PostgreSQL/session integration remain | | 7.7 `[D:7.1]` `[P]` | Obtain the production App ID, publisher key, SDR coordinator SDK/signing approval, certificates and hosted-data-centre support from Valve | Production prerequisites and rotation owners are recorded; no Spacewar credential or development certificate can reach a release build | | 7.8 `[D:7.6,7.7]` | Ticketed Hosted Dedicated Server SDR: routing registration, coordinator-issued player→server relay tickets, client ticket installation, reconnect and expiry | Two real accounts complete and reconnect to an assigned dedicated match through SDR; server/player IPs are not exposed; ENet gates remain green |