extends Node ## AuthManager - Centralized authentication handling for Nakama ## Supports: Guest (device ID), Email/Password, Social (Google, Apple, Facebook) # Signals signal auth_started signal auth_completed(success: bool, user_data: Dictionary) signal auth_failed(error: String) signal session_restored signal logged_out # Auth modes enum AuthMode {GUEST, EMAIL, GOOGLE, APPLE, FACEBOOK, STEAM, CUSTOM} # User data var current_user: Dictionary = {} var is_authenticated: bool = false var is_guest: bool = false var auth_mode: AuthMode = AuthMode.GUEST # Session persistence const SESSION_FILE := "user://auth_session.dat" const CREDENTIALS_FILE := "user://auth_credentials.dat" # Encryption key for session storage (device-specific) var ENCRYPTION_KEY: String = OS.get_unique_id().sha256_text() func _ready() -> void: # Try to restore session on startup call_deferred("_try_restore_session") # ============================================================================= # Session Persistence # ============================================================================= func _try_restore_session() -> void: if not FileAccess.file_exists(SESSION_FILE): print("[AuthManager] No saved session found") return var file := FileAccess.open_encrypted_with_pass(SESSION_FILE, FileAccess.READ, ENCRYPTION_KEY) if not file: print("[AuthManager] Could not open session file") return var session_data = file.get_var() file.close() if not session_data or not session_data is Dictionary: return var token: String = session_data.get("token", "") var refresh_token: String = session_data.get("refresh_token", "") var saved_auth_mode: int = session_data.get("auth_mode", AuthMode.GUEST) if saved_auth_mode == AuthMode.GUEST: print("[AuthManager] Ignoring guest session auto-restore to force login screen.") return if token.is_empty(): return print("[AuthManager] Attempting to restore session...") # Try to restore the session var session = NakamaClient.restore_session(token) if session.is_expired(): # Try to refresh if refresh_token: var refreshed: NakamaSession = await NakamaManager.client.session_refresh_async(session) if not refreshed.is_exception(): session = refreshed _save_session(session, saved_auth_mode) else: var err_msg: String = refreshed.get_exception().message print("[AuthManager] Session refresh failed (%s) — need to re-login" % err_msg) return else: print("[AuthManager] Session expired, need to re-login") return # Session valid, connect NakamaManager.session = session auth_mode = saved_auth_mode as AuthMode is_guest = auth_mode == AuthMode.GUEST var socket_success := await _connect_socket() if not socket_success: # Clean up so manual login starts from a blank slate push_warning("[AuthManager] Session restore failed — socket could not connect. Resetting state.") NakamaManager.session = null NakamaManager.socket = null return await _load_user_profile() is_authenticated = true emit_signal("session_restored") emit_signal("auth_completed", true, current_user) print("[AuthManager] Session restored successfully") func _save_session(session: NakamaSession, mode: AuthMode) -> void: var file := FileAccess.open_encrypted_with_pass(SESSION_FILE, FileAccess.WRITE, ENCRYPTION_KEY) if file: file.store_var({ "token": session.token, "refresh_token": session.refresh_token, "auth_mode": mode, "user_id": session.user_id }) file.close() func clear_session() -> void: if FileAccess.file_exists(SESSION_FILE): DirAccess.remove_absolute(SESSION_FILE) if FileAccess.file_exists(CREDENTIALS_FILE): DirAccess.remove_absolute(CREDENTIALS_FILE) # ============================================================================= # Guest Authentication # ============================================================================= func login_as_guest() -> bool: emit_signal("auth_started") auth_mode = AuthMode.GUEST is_guest = true # Use device ID for guest auth var device_id := _get_device_id() print("[AuthManager] Guest login with device: ", device_id.substr(0, 8) + "...") var session: NakamaSession = await NakamaManager.client.authenticate_device_async(device_id, null, true) if session.is_exception(): var error: String = session.get_exception().message emit_signal("auth_failed", error) return false NakamaManager.session = session _save_session(session, AuthMode.GUEST) var socket_success := await _connect_socket() if not socket_success: emit_signal("auth_failed", "Failed to connect to game server") return false await _load_user_profile() is_authenticated = true emit_signal("auth_completed", true, current_user) return true func _get_device_id() -> String: # Try to load saved device ID for consistent guest identity var id_file := "user://device_id.txt" if FileAccess.file_exists(id_file): var file_read := FileAccess.open(id_file, FileAccess.READ) if file_read: var saved_id := file_read.get_as_text().strip_edges() file_read.close() if not saved_id.is_empty(): return saved_id # Generate new device ID var device_id := OS.get_unique_id() if device_id.is_empty(): device_id = str(randi()) + str(Time.get_ticks_msec()) # Save for future use var file_write := FileAccess.open(id_file, FileAccess.WRITE) if file_write: file_write.store_string(device_id) file_write.close() return device_id # ============================================================================= # Email/Password Authentication # ============================================================================= func login_with_email(email: String, password: String, remember: bool = true) -> bool: emit_signal("auth_started") auth_mode = AuthMode.EMAIL is_guest = false print("[AuthManager] Email login: ", email) var session: NakamaSession = await NakamaManager.client.authenticate_email_async(email, password, null, false) if session.is_exception(): var error: String = session.get_exception().message emit_signal("auth_failed", error) return false NakamaManager.session = session if remember: _save_session(session, AuthMode.EMAIL) var socket_success := await _connect_socket() if not socket_success: emit_signal("auth_failed", "Failed to connect to game server") return false await _load_user_profile() is_authenticated = true emit_signal("auth_completed", true, current_user) return true func register_with_email(email: String, password: String, username: String = "") -> bool: emit_signal("auth_started") auth_mode = AuthMode.EMAIL is_guest = false print("[AuthManager] Registering: ", email) # Create account (true = create if not exists) var session: NakamaSession = await NakamaManager.client.authenticate_email_async(email, password, username, true) if session.is_exception(): var error: String = session.get_exception().message emit_signal("auth_failed", error) return false NakamaManager.session = session _save_session(session, AuthMode.EMAIL) var socket_success := await _connect_socket() if not socket_success: emit_signal("auth_failed", "Failed to connect to game server") return false await _load_user_profile() is_authenticated = true emit_signal("auth_completed", true, current_user) return true # ============================================================================= # Social Authentication # ============================================================================= func login_with_google(id_token: String) -> bool: emit_signal("auth_started") auth_mode = AuthMode.GOOGLE is_guest = false print("[AuthManager] Google login...") var session: NakamaSession = await NakamaManager.client.authenticate_google_async(id_token, null, true) if session.is_exception(): var error: String = session.get_exception().message emit_signal("auth_failed", error) return false NakamaManager.session = session _save_session(session, AuthMode.GOOGLE) var socket_success := await _connect_socket() if not socket_success: emit_signal("auth_failed", "Failed to connect to game server") return false await _load_user_profile() is_authenticated = true emit_signal("auth_completed", true, current_user) return true func login_with_apple(id_token: String) -> bool: emit_signal("auth_started") auth_mode = AuthMode.APPLE is_guest = false print("[AuthManager] Apple login...") var session: NakamaSession = await NakamaManager.client.authenticate_apple_async(id_token, null, true) if session.is_exception(): var error: String = session.get_exception().message emit_signal("auth_failed", error) return false NakamaManager.session = session _save_session(session, AuthMode.APPLE) var socket_success := await _connect_socket() if not socket_success: emit_signal("auth_failed", "Failed to connect to game server") return false await _load_user_profile() is_authenticated = true emit_signal("auth_completed", true, current_user) return true func login_with_facebook(access_token: String) -> bool: emit_signal("auth_started") auth_mode = AuthMode.FACEBOOK is_guest = false print("[AuthManager] Facebook login...") var session: NakamaSession = await NakamaManager.client.authenticate_facebook_async(access_token, null, true) if session.is_exception(): var error: String = session.get_exception().message emit_signal("auth_failed", error) return false NakamaManager.session = session _save_session(session, AuthMode.FACEBOOK) var socket_success := await _connect_socket() if not socket_success: emit_signal("auth_failed", "Failed to connect to game server") return false await _load_user_profile() is_authenticated = true emit_signal("auth_completed", true, current_user) return true func login_with_steam() -> bool: emit_signal("auth_started") auth_mode = AuthMode.STEAM is_guest = false print("[AuthManager] Steam login...") # Get Steamworks manager from BackendService var backend = get_node_or_null("/root/BackendService") if not backend: emit_signal("auth_failed", "BackendService not available") return false var steamworks = backend.get_steamworks_manager() if not steamworks or not steamworks.is_initialized(): emit_signal("auth_failed", "Steamworks not available. Please launch through Steam.") return false # Try full Steam ticket auth first, fall back to Steam ID custom auth for dev/testing var session: NakamaSession = await _authenticate_steam_with_fallback(steamworks) if session == null or session.is_exception(): var error: String = "Steam authentication failed" if session != null: error = session.get_exception().message emit_signal("auth_failed", error) return false NakamaManager.session = session _save_session(session, AuthMode.STEAM) var socket_success := await _connect_socket() if not socket_success: emit_signal("auth_failed", "Failed to connect to game server") return false await _load_user_profile() is_authenticated = true emit_signal("auth_completed", true, current_user) return true func _authenticate_steam_with_fallback(steamworks: Node) -> NakamaSession: # Proper Steam ticket auth var auth_ticket = steamworks.get_auth_session_ticket() if not auth_ticket.is_empty(): print("[AuthManager] Got Steam auth ticket, authenticating with Nakama...") var session: NakamaSession = await NakamaManager.client.authenticate_steam_async(auth_ticket, null, true) if not session.is_exception(): return session print("[AuthManager] Steam ticket auth failed: %s" % session.get_exception().message) return null print("[AuthManager] Steam auth ticket is empty.") return null # ============================================================================= # Account Linking (Convert Guest to Full Account) # ============================================================================= func link_email(email: String, password: String) -> bool: if not is_authenticated or not NakamaManager.session: return false print("[AuthManager] Linking email to guest account...") var result: NakamaAsyncResult = await NakamaManager.client.link_email_async(NakamaManager.session, email, password) if result.is_exception(): push_error("[AuthManager] Link failed: " + result.get_exception().message) return false is_guest = false auth_mode = AuthMode.EMAIL _save_session(NakamaManager.session, AuthMode.EMAIL) print("[AuthManager] Email linked successfully!") return true func link_google(id_token: String) -> bool: if not is_authenticated or not NakamaManager.session: return false var result: NakamaAsyncResult = await NakamaManager.client.link_google_async(NakamaManager.session, id_token) if result.is_exception(): return false is_guest = false auth_mode = AuthMode.GOOGLE _save_session(NakamaManager.session, AuthMode.GOOGLE) return true # ============================================================================= # Logout # ============================================================================= func logout() -> void: print("[AuthManager] Logging out...") # FULL CLEANUP: close socket, leave bridge, clear multiplayer peer NakamaManager.cleanup() clear_session() current_user = {} is_authenticated = false is_guest = false NakamaManager.session = null emit_signal("logged_out") # ============================================================================= # Helper Functions # ============================================================================= func _connect_socket() -> bool: if not NakamaManager.session: push_error("[AuthManager] Socket connection failed: no Nakama session") return false if NakamaManager.socket and NakamaManager.socket.is_connected_to_host(): if not multiplayer.has_multiplayer_peer() and NakamaManager.bridge: multiplayer.set_multiplayer_peer(NakamaManager.bridge.multiplayer_peer) NakamaManager.connected_to_nakama.emit() return true if NakamaManager.socket: NakamaManager.socket.close() NakamaManager.socket = null NakamaManager.socket = Nakama.create_socket_from(NakamaManager.client) var result = await NakamaManager.socket.connect_async(NakamaManager.session) if result.is_exception(): var exception = result.get_exception() var error_message = "Socket connection failed" if exception and not exception.message.is_empty(): error_message = exception.message elif exception and exception.status_code >= 0: error_message = "Socket connection failed with error code %s" % exception.status_code push_error("[AuthManager] " + error_message) NakamaManager.socket.close() NakamaManager.socket = null return false # Initialize multiplayer bridge NakamaManager.bridge = NakamaMultiplayerBridge.new(NakamaManager.socket) NakamaManager.bridge.match_joined.connect(NakamaManager._on_bridge_match_joined) NakamaManager.bridge.match_join_error.connect(NakamaManager._on_bridge_match_join_error) multiplayer.set_multiplayer_peer(NakamaManager.bridge.multiplayer_peer) # Notify other systems that Nakama socket is ready NakamaManager.connected_to_nakama.emit() return true func _load_user_profile() -> void: if not NakamaManager.session: return var account = await NakamaManager.client.get_account_async(NakamaManager.session) if account.is_exception(): push_error("[AuthManager] Failed to load account") return current_user = { "user_id": account.user.id, "username": account.user.username, "display_name": account.user.display_name if account.user.display_name else account.user.username, "avatar_url": account.user.avatar_url, "email": account.email, "is_guest": is_guest, "auth_mode": auth_mode, "created_at": account.user.create_time } print("[AuthManager] User profile loaded: ", current_user.display_name) func get_display_name() -> String: return current_user.get("display_name", "Guest") func get_user_id() -> String: return current_user.get("user_id", "") func is_logged_in() -> bool: return is_authenticated