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://pauth.ranaze.com/api/Auth" const WARNING_SECONDS := 120 const REFRESH_TIMEOUT_SECONDS := 20.0 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 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) 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 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: 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 != "": 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))