mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
fix(multiplayer): expire client sessions proactively
This commit is contained in:
@@ -52,6 +52,8 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not auth_expired and is_session_expired(session_expires_at):
|
||||
_expire_session()
|
||||
if _websocket == null:
|
||||
return
|
||||
_websocket.poll()
|
||||
@@ -269,6 +271,21 @@ static func is_valid_access_token(token: String) -> bool:
|
||||
return separator > 0 and separator < token.length() - 1 and token.length() <= 4096 and not token.contains("\r") and not token.contains("\n")
|
||||
|
||||
|
||||
static func is_session_expired(expires_at: String, now_unix: int = -1) -> bool:
|
||||
if expires_at.is_empty():
|
||||
return false
|
||||
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
|
||||
if timestamp_pattern.search(expires_at) == null:
|
||||
return true
|
||||
var expiry_unix := Time.get_unix_time_from_datetime_string(expires_at)
|
||||
if expiry_unix < 0:
|
||||
return true
|
||||
var current_unix := now_unix
|
||||
if current_unix < 0:
|
||||
current_unix = int(Time.get_unix_time_from_system())
|
||||
return expiry_unix <= current_unix
|
||||
|
||||
|
||||
static func is_retryable_mutation_response(response_code: int) -> bool:
|
||||
return response_code == 0 or response_code == HTTPClient.RESPONSE_REQUEST_TIMEOUT or response_code == HTTPClient.RESPONSE_TOO_MANY_REQUESTS or response_code >= 500
|
||||
|
||||
@@ -287,6 +304,9 @@ func _start_request(operation: String, method: HTTPClient.Method, path: String,
|
||||
return ERR_BUSY if not _operation.is_empty() else ERR_UNAUTHORIZED
|
||||
if operation != "steam_session" and access_token.is_empty():
|
||||
return ERR_UNAUTHORIZED
|
||||
if operation != "steam_session" and is_session_expired(session_expires_at):
|
||||
_expire_session()
|
||||
return ERR_UNAUTHORIZED
|
||||
var headers := PackedStringArray(["Accept: application/json"])
|
||||
if operation != "steam_session":
|
||||
headers.append("Authorization: Bearer " + access_token)
|
||||
@@ -306,6 +326,17 @@ func _start_request(operation: String, method: HTTPClient.Method, path: String,
|
||||
return OK
|
||||
|
||||
|
||||
func _expire_session() -> void:
|
||||
if auth_expired:
|
||||
return
|
||||
access_token = ""
|
||||
auth_expired = true
|
||||
disconnect_event_stream()
|
||||
state.fail("Session expired; sign in again")
|
||||
ranked_profile.set_error("Session expired; sign in again")
|
||||
session_expired.emit()
|
||||
|
||||
|
||||
func _on_request_completed(result: HTTPRequest.Result, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var operation := _operation
|
||||
_operation = ""
|
||||
|
||||
@@ -43,6 +43,13 @@ func test_ticket_normalization_derives_authoritative_enqueue_time() -> void:
|
||||
assert_eq(int(normalized["enqueued_at_unix"]), 1000, "RFC3339 enqueue time is converted to epoch")
|
||||
|
||||
|
||||
func test_session_expiry_is_checked_at_the_boundary_and_fails_closed() -> void:
|
||||
assert_true(not ControlPlaneClient.is_session_expired("", 1000), "legacy sessions without an expiry remain compatible")
|
||||
assert_true(not ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 999), "session remains valid before expiry")
|
||||
assert_true(ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 1000), "session expires at the exact boundary")
|
||||
assert_true(ControlPlaneClient.is_session_expired("not-a-timestamp", 1000), "malformed non-empty expiry fails closed")
|
||||
|
||||
|
||||
func test_websocket_event_validation_requires_contract_specific_fields() -> void:
|
||||
var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket-1", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"}
|
||||
assert_true(ControlPlaneClient._valid_websocket_event(envelope), "valid state event is accepted")
|
||||
|
||||
@@ -1554,3 +1554,5 @@ Recovery targeting now follows the same boundary: only an `OPEN` proposal is pol
|
||||
Client queue/proposal expiry and enqueue epoch metadata now fail closed on malformed, negative, or fractional values instead of being silently coerced to zero. Adversarial metadata tests cover string, negative, and fractional timestamps.
|
||||
|
||||
Ticket projections now validate playlist metadata on every update, rejecting unknown values before either phase or playlist state can mutate. An adversarial higher-revision update test covers this boundary.
|
||||
|
||||
Client sessions now fail closed at the expiry boundary and proactively clear credentials before reconnects or authenticated requests. Boundary and malformed-expiry tests cover the lifecycle guard.
|
||||
|
||||
Reference in New Issue
Block a user