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.
264 lines
8.4 KiB
GDScript
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))
|