Add session expiry renewal flow

This commit is contained in:
hz
2026-07-18 15:53:11 -05:00
parent 8c349314ad
commit a232e18c46
14 changed files with 575 additions and 21 deletions
+198 -1
View File
@@ -1,6 +1,17 @@
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 := ""
@@ -8,22 +19,208 @@ 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 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 = "") -> 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
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 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
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 = ""
auth_changed.emit()
expiration_unix = 0.0
refresh_in_progress = false
refresh_revision = -1
warning_active = false
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))
+6 -2
View File
@@ -31,6 +31,7 @@ var pending_action := ""
var queue: Array = []
var text_scale := 1.0
var highlights := {}
var request_access_token := ""
func _ready() -> void:
@@ -148,11 +149,12 @@ func _send(action: String, method: int, suffix: String, payload: Dictionary = {}
if is_loading:
queue.append({"action": action, "method": method, "url": url, "payload": payload.duplicate(true)})
return
if AuthState.access_token.is_empty():
if not AuthState.can_send_authenticated_request():
return
is_loading = true
pending_action = action
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
request_access_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % request_access_token])
var body := ""
if not payload.is_empty():
headers.append("Content-Type: application/json")
@@ -168,6 +170,8 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
pending_action = ""
is_loading = false
call_deferred("_process_queue")
if response_code == 401:
AuthState.handle_unauthorized(request_access_token)
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
var detail := body.get_string_from_utf8().strip_edges().left(240)
var message := "CCS request failed (%d). %s" % [response_code, detail]
+6 -2
View File
@@ -21,6 +21,7 @@ var history_next_cursor := ""
var is_loading := false
var pending_action := ""
var request_queue: Array = []
var request_access_token := ""
func _ready() -> void:
@@ -122,14 +123,15 @@ func _send(action: String, method: int, suffix: String, payload: Dictionary = {}
func _send_absolute(action: String, method: int, url: String, payload: Dictionary = {}) -> void:
if AuthState.access_token == "":
if not AuthState.can_send_authenticated_request():
return
if is_loading:
request_queue.append({"action": action, "method": method, "url": url, "payload": payload.duplicate(true)})
return
is_loading = true
pending_action = action
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
request_access_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % request_access_token])
var body := ""
if not payload.is_empty():
headers.append("Content-Type: application/json")
@@ -146,6 +148,8 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
pending_action = ""
is_loading = false
call_deferred("_process_request_queue")
if response_code == 401:
AuthState.handle_unauthorized(request_access_token)
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
var server_message := body.get_string_from_utf8().strip_edges()
if server_message.length() > 240:
+6 -2
View File
@@ -10,6 +10,7 @@ signal feedback_failed(message: String)
var api_url := DEFAULT_API_URL
var request: HTTPRequest
var is_submitting := false
var request_access_token := ""
func _ready() -> void:
@@ -25,13 +26,14 @@ func _ready() -> void:
func submit(category: String, details: String, context: Dictionary) -> void:
if is_submitting:
return
if AuthState.access_token.is_empty():
if not AuthState.can_send_authenticated_request():
feedback_failed.emit("Log in before sending feedback.")
return
is_submitting = true
request_access_token = AuthState.access_token
var headers := PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % AuthState.access_token
"Authorization: Bearer %s" % request_access_token
])
var payload := {"category": category, "details": details, "context": context}
var error := request.request(api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
@@ -58,6 +60,8 @@ func build_context(screen_name: String, viewport_size: Vector2) -> Dictionary:
func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
is_submitting = false
if response_code == 401:
AuthState.handle_unauthorized(request_access_token)
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
var detail := body.get_string_from_utf8().strip_edges().left(240)
var message := "Feedback submission failed with status %d." % response_code
+25 -10
View File
@@ -45,6 +45,10 @@ var pending_report_question_id := ""
var attempt_queue: Array = []
var active_attempt_index := -1
var attempt_request_active := false
var questions_request_token := ""
var attempt_request_token := ""
var rating_request_token := ""
var report_request_token := ""
var pending_excluded_question_ids: Array = []
var pending_require_media := false
var image_question_search_attempts := 0
@@ -102,7 +106,7 @@ func _ready() -> void:
func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Array = [], require_media: bool = false) -> void:
if AuthState.access_token.is_empty():
if not AuthState.can_send_authenticated_request():
_set_load_error("Login required before loading questions.", false)
return
@@ -132,9 +136,10 @@ func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Ar
if should_load_ratings:
load_ratings_from_api()
questions_request_token = AuthState.access_token
var headers := PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % AuthState.access_token
"Authorization: Bearer %s" % questions_request_token
])
var payload := {
"limit": 1,
@@ -149,6 +154,8 @@ func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Ar
func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
is_loading = false
if response_code == 401:
AuthState.handle_unauthorized(questions_request_token)
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
var message := "The connection to the question service was interrupted."
if response_code > 0:
@@ -251,28 +258,29 @@ func clear_questions() -> void:
func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = []) -> void:
if AuthState.access_token.is_empty() or question_id == "" or choice_id == "":
if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "":
return
_enqueue_attempt("answer", question_id, choice_id, presented_choice_ids)
func skip_question(question_id: String) -> void:
if AuthState.access_token.is_empty() or question_id == "":
if not AuthState.can_send_authenticated_request() or question_id == "":
return
_enqueue_attempt("skip", question_id, "")
func submit_question_report(question_id: String, category: String, details: String) -> bool:
if AuthState.access_token.is_empty() or question_id == "":
if not AuthState.can_send_authenticated_request() or question_id == "":
question_report_failed.emit(question_id, "Login is required to submit a report.")
return false
if report_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
question_report_failed.emit(question_id, "Another report is still being submitted.")
return false
report_request_token = AuthState.access_token
var headers := PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % AuthState.access_token
"Authorization: Bearer %s" % report_request_token
])
var payload := {
"questionExternalId": question_id,
@@ -294,10 +302,11 @@ func submit_question_report(question_id: String, category: String, details: Stri
func load_ratings_from_api() -> void:
if AuthState.access_token.is_empty():
if not AuthState.can_send_authenticated_request():
return
rating_request_token = AuthState.access_token
var headers := PackedStringArray([
"Authorization: Bearer %s" % AuthState.access_token
"Authorization: Bearer %s" % rating_request_token
])
var err := rating_request.request(ratings_api_url, headers, HTTPClient.METHOD_GET)
if err != OK:
@@ -397,7 +406,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
func _process_attempt_queue() -> void:
if attempt_request_active or attempt_queue.is_empty() or AuthState.access_token.is_empty():
if attempt_request_active or attempt_queue.is_empty() or not AuthState.can_send_authenticated_request():
return
if attempt_retry_timer != null and not attempt_retry_timer.is_stopped():
return
@@ -411,9 +420,10 @@ func _process_attempt_queue() -> void:
return
var queued: Dictionary = attempt_queue[active_attempt_index]
attempt_request_token = AuthState.access_token
var headers := PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % AuthState.access_token
"Authorization: Bearer %s" % attempt_request_token
])
var question_id := str(queued.get("question_id", ""))
var url := question_attempt_api_url
@@ -460,6 +470,7 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
var succeeded := result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300
if not succeeded:
if response_code == 401:
AuthState.handle_unauthorized(attempt_request_token)
active_attempt_index = -1
elif response_code in [400, 403, 404]:
push_warning("Discarding invalid question attempt with status %d." % response_code)
@@ -563,6 +574,8 @@ func _on_question_report_submitted(result: int, response_code: int, _headers: Pa
if question_id == "":
question_id = pending_report_question_id
pending_report_question_id = ""
if response_code == 401:
AuthState.handle_unauthorized(report_request_token)
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
var message := "The report could not be submitted. Please try again."
@@ -585,6 +598,8 @@ func _on_question_report_submitted(result: int, response_code: int, _headers: Pa
func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if response_code == 401:
AuthState.handle_unauthorized(rating_request_token)
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
push_warning("Ratings API request failed with status %d." % response_code)
return