Files
step3/scripts/autoload/auth_state.gd
hz f17797becc Don't show the session-expiry warning to actively-used sessions
The warning was purely a token-expiry countdown with no concept of
user activity, so it interrupted people who had been clicking through
questions the whole time. AuthState now tracks real input activity
globally and silently refreshes the session in the background when
the user has interacted recently, only surfacing the "Stay logged in?"
dialog for sessions that are genuinely idle. A failed background
refresh still surfaces the dialog rather than silently logging the
user out.
2026-07-22 22:52:38 -05:00

264 lines
8.4 KiB
GDScript

extends Node
signal auth_changed
signal session_warning(seconds_remaining: int)
signal session_countdown_changed(seconds_remaining: int)
signal session_refresh_started
signal session_refreshed
signal session_refresh_failed(message: String, terminal: bool)
signal session_expired(reason: String)
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_AUTH_BASE_URL := "https://us3q.ranaze.com/api/Auth"
const WARNING_SECONDS := 120
const REFRESH_TIMEOUT_SECONDS := 20.0
const ACTIVE_RECENTLY_SECONDS := 60
var is_logged_in := false
var username := ""
var role := ""
var access_token := ""
var refresh_token := ""
var expires_at := ""
var expiration_unix := 0.0
var refresh_in_progress := false
var warning_active := false
var requires_legal_acceptance := false
var terms_version := ""
var privacy_version := ""
var terms_url := ""
var privacy_url := ""
var auth_base_url := DEFAULT_AUTH_BASE_URL
var expiry_timer: Timer
var refresh_request: HTTPRequest
var session_revision := 0
var refresh_revision := -1
var last_activity_unix := 0.0
func _ready() -> void:
auth_base_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/auth_api_base_url", DEFAULT_AUTH_BASE_URL)),
"/api/Auth")
expiry_timer = Timer.new()
expiry_timer.wait_time = 1.0
expiry_timer.timeout.connect(_evaluate_expiry)
add_child(expiry_timer)
refresh_request = HTTPRequest.new()
refresh_request.timeout = REFRESH_TIMEOUT_SECONDS
refresh_request.request_completed.connect(_on_refresh_completed)
add_child(refresh_request)
last_activity_unix = Time.get_unix_time_from_system()
func _input(_event: InputEvent) -> void:
last_activity_unix = Time.get_unix_time_from_system()
func set_session(new_username: String, token: String, new_refresh_token: String = "", new_role: String = "", token_expiration: String = "", legal: Dictionary = {}) -> void:
session_revision += 1
is_logged_in = not token.is_empty()
username = new_username
access_token = token
refresh_token = new_refresh_token
role = new_role
expires_at = token_expiration
expiration_unix = _parse_expiration(token_expiration, token)
refresh_in_progress = false
refresh_revision = -1
warning_active = false
last_activity_unix = Time.get_unix_time_from_system()
set_legal_state(legal, false)
if is_logged_in and expiration_unix > 0.0:
expiry_timer.start()
else:
expiry_timer.stop()
auth_changed.emit()
if is_logged_in:
_evaluate_expiry.call_deferred()
func set_legal_state(legal: Dictionary, notify := true) -> void:
requires_legal_acceptance = bool(legal.get("requiresLegalAcceptance", false))
terms_version = str(legal.get("termsVersion", terms_version))
privacy_version = str(legal.get("privacyVersion", privacy_version))
terms_url = str(legal.get("termsUrl", terms_url))
privacy_url = str(legal.get("privacyUrl", privacy_url))
if notify:
auth_changed.emit()
func clear_session() -> void:
_clear_session_fields()
auth_changed.emit()
func expire_session(reason: String = "expired") -> void:
if not is_logged_in and access_token.is_empty():
return
_clear_session_fields()
auth_changed.emit()
session_expired.emit(reason)
func request_session_refresh() -> void:
if refresh_in_progress or not is_logged_in:
return
if refresh_token.is_empty() or username.is_empty():
expire_session("refresh_unavailable")
return
refresh_in_progress = true
refresh_revision = session_revision
session_refresh_started.emit()
var headers := PackedStringArray(["Content-Type: application/json"])
var payload := JSON.stringify({"username": username, "refreshToken": refresh_token})
var error := refresh_request.request(
"%s/refresh" % auth_base_url.rstrip("/"),
headers,
HTTPClient.METHOD_POST,
payload)
if error != OK:
refresh_in_progress = false
refresh_revision = -1
session_refresh_failed.emit("Unable to contact the authentication service. Check your connection and try again.", false)
_evaluate_expiry()
func can_send_authenticated_request() -> bool:
if not is_logged_in or access_token.is_empty():
return false
if expiration_unix <= 0.0 or Time.get_unix_time_from_system() < expiration_unix:
return true
if not refresh_in_progress:
expire_session("expired")
return false
func handle_unauthorized(token_used: String = "") -> void:
if not is_logged_in:
return
if not token_used.is_empty() and token_used != access_token:
return
expire_session("unauthorized")
func seconds_until_expiry() -> int:
if expiration_unix <= 0.0:
return 0
return maxi(0, int(ceil(expiration_unix - Time.get_unix_time_from_system())))
func _evaluate_expiry() -> void:
if not is_logged_in or expiration_unix <= 0.0:
return
var remaining := seconds_until_expiry()
if remaining <= 0:
if refresh_in_progress:
if warning_active:
session_countdown_changed.emit(0)
return
expire_session("expired")
return
if remaining <= WARNING_SECONDS:
var seconds_since_activity := Time.get_unix_time_from_system() - last_activity_unix
if seconds_since_activity <= ACTIVE_RECENTLY_SECONDS and not refresh_in_progress:
request_session_refresh()
return
if not warning_active:
warning_active = true
session_warning.emit(remaining)
session_countdown_changed.emit(remaining)
func _on_refresh_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var completed_revision := refresh_revision
refresh_in_progress = false
refresh_revision = -1
if completed_revision != session_revision or not is_logged_in:
return
var succeeded := result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300
if not succeeded:
var terminal := response_code in [400, 401, 403] or seconds_until_expiry() <= 0
var message := "Unable to renew the session. Check your connection and try again."
if terminal:
message = "Your session could not be renewed. Please sign in again."
session_refresh_failed.emit(message, terminal)
if terminal:
expire_session("refresh_failed")
else:
_evaluate_expiry()
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
session_refresh_failed.emit("The authentication service returned an invalid response.", true)
expire_session("refresh_failed")
return
var new_access_token := str(parsed.get("accessToken", ""))
var new_expiration := str(parsed.get("exp", ""))
var new_expiration_unix := _parse_expiration(new_expiration, new_access_token)
if new_access_token.is_empty() or new_expiration_unix <= Time.get_unix_time_from_system():
session_refresh_failed.emit("The authentication service returned an invalid session.", true)
expire_session("refresh_failed")
return
access_token = new_access_token
expires_at = new_expiration
expiration_unix = new_expiration_unix
warning_active = false
var legal_value: Variant = parsed.get("legal", {})
if typeof(legal_value) == TYPE_DICTIONARY:
set_legal_state(legal_value, false)
session_revision += 1
expiry_timer.start()
auth_changed.emit()
session_refreshed.emit()
func _clear_session_fields() -> void:
session_revision += 1
if refresh_request != null and refresh_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
refresh_request.cancel_request()
if expiry_timer != null:
expiry_timer.stop()
is_logged_in = false
username = ""
role = ""
access_token = ""
refresh_token = ""
expires_at = ""
expiration_unix = 0.0
refresh_in_progress = false
refresh_revision = -1
warning_active = false
requires_legal_acceptance = false
terms_version = ""
privacy_version = ""
terms_url = ""
privacy_url = ""
func _parse_expiration(value: String, token: String = "") -> float:
var cleaned := value.strip_edges()
if not cleaned.is_empty() and cleaned != "<null>":
if cleaned.is_valid_float():
var numeric := float(cleaned)
return numeric / 1000.0 if numeric > 100000000000.0 else numeric
var iso_value := cleaned.trim_suffix("Z")
var parsed_iso := Time.get_unix_time_from_datetime_string(iso_value)
if parsed_iso > 0:
return float(parsed_iso)
return _expiration_from_jwt(token)
func _expiration_from_jwt(token: String) -> float:
var segments := token.split(".")
if segments.size() < 2:
return 0.0
var encoded := str(segments[1]).replace("-", "+").replace("_", "/")
while encoded.length() % 4 != 0:
encoded += "="
var payload_text := Marshalls.base64_to_raw(encoded).get_string_from_utf8()
var payload: Variant = JSON.parse_string(payload_text)
if typeof(payload) != TYPE_DICTIONARY:
return 0.0
return float(payload.get("exp", 0.0))