mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 15:32:04 +00:00
feat: add Godot Steam session login
This commit is contained in:
@@ -7,6 +7,7 @@ extends Node
|
|||||||
signal request_succeeded(operation: String, payload: Dictionary)
|
signal request_succeeded(operation: String, payload: Dictionary)
|
||||||
signal request_failed(operation: String, http_code: int, detail: String)
|
signal request_failed(operation: String, http_code: int, detail: String)
|
||||||
signal session_expired()
|
signal session_expired()
|
||||||
|
signal session_changed(player_id: String)
|
||||||
|
|
||||||
const DEFAULT_BASE_URL := "http://127.0.0.1:8080"
|
const DEFAULT_BASE_URL := "http://127.0.0.1:8080"
|
||||||
const PERSIST_PATH := "user://matchmaking_state.cfg"
|
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 base_url := DEFAULT_BASE_URL
|
||||||
var access_token := ""
|
var access_token := ""
|
||||||
var auth_expired := false
|
var auth_expired := false
|
||||||
|
var player_id := ""
|
||||||
|
var session_expires_at := ""
|
||||||
var state: MatchmakingState
|
var state: MatchmakingState
|
||||||
var ranked_profile: RankedProfileState
|
var ranked_profile: RankedProfileState
|
||||||
|
|
||||||
@@ -36,7 +39,7 @@ func _ready() -> void:
|
|||||||
func configure(url: String, token: String) -> bool:
|
func configure(url: String, token: String) -> bool:
|
||||||
var normalized := url.strip_edges().trim_suffix("/")
|
var normalized := url.strip_edges().trim_suffix("/")
|
||||||
var normalized_token := token.strip_edges()
|
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
|
return false
|
||||||
base_url = normalized
|
base_url = normalized
|
||||||
access_token = normalized_token
|
access_token = normalized_token
|
||||||
@@ -57,6 +60,12 @@ func queue_create(ticket_id: String, playlist: String, client_build: String, pro
|
|||||||
return err
|
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:
|
func retry_queue_create() -> Error:
|
||||||
if _last_queue_create.is_empty() or not _last_queue_create.has("ticket_id"):
|
if _last_queue_create.is_empty() or not _last_queue_create.has("ticket_id"):
|
||||||
return ERR_INVALID_DATA
|
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://")
|
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:
|
static func normalize_ticket(payload: Dictionary) -> Dictionary:
|
||||||
var result := payload.duplicate(true)
|
var result := payload.duplicate(true)
|
||||||
if result.has("expires_at") and result["expires_at"] is String:
|
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:
|
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
|
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():
|
if not idempotency_key.is_empty():
|
||||||
headers.append("Idempotency-Key: " + idempotency_key)
|
headers.append("Idempotency-Key: " + idempotency_key)
|
||||||
if expected_revision >= 0:
|
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)
|
request_failed.emit(operation, response_code, detail)
|
||||||
return
|
return
|
||||||
var payload: Dictionary = parsed
|
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", "")))
|
state.begin_queue(String(payload.get("ticket_id", "")), String(payload.get("playlist", "")))
|
||||||
if operation.begins_with("queue_"):
|
if operation.begins_with("queue_"):
|
||||||
state.apply_ticket_update(normalize_ticket(payload))
|
state.apply_ticket_update(normalize_ticket(payload))
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ func test_base_url_validation_rejects_ambiguous_or_insecure_values() -> void:
|
|||||||
var client := ControlPlaneClient.new()
|
var client := ControlPlaneClient.new()
|
||||||
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "safe access token configures")
|
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(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:
|
func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void:
|
||||||
|
|||||||
+1
-1
@@ -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.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.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.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.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 |
|
| 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 |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user