steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
//! Session and cookie management for Steam Community.

use std::sync::{Arc, LazyLock};

use parking_lot::Mutex;
use reqwest::{cookie::Jar, Url};
use steamid::SteamID;

/// All Steam domains that need cookies set.
static STEAM_URLS: LazyLock<[Url; 4]> = LazyLock::new(|| ["https://steamcommunity.com".parse().expect("valid Steam URL"), "https://store.steampowered.com".parse().expect("valid Steam URL"), "https://help.steampowered.com".parse().expect("valid Steam URL"), "https://api.steampowered.com".parse().expect("valid Steam URL")]);

/// Session manager for Steam Community.
///
/// Handles cookies, session IDs, and authentication state.
pub struct Session {
    /// The cookie jar (shared across clones).
    pub(crate) jar: Arc<Jar>,
    /// Current Steam ID if logged in.
    pub steam_id: Option<SteamID>,
    /// Session ID for CSRF protection.
    pub session_id: Option<String>,
    /// Mobile access token for 2FA operations.
    pub(crate) mobile_access_token: Option<String>,
    /// OAuth access token.
    pub(crate) access_token: Option<String>,
    /// OAuth refresh token.
    pub(crate) refresh_token: Option<String>,
    /// Shared secret for 2FA finalization (behind Mutex for interior
    /// mutability).
    pub(crate) shared_secret: Mutex<Option<String>>,
    /// Cached profile URL (e.g., "/id/username" or "/profiles/76561198...")
    pub(crate) profile_url: Mutex<Option<String>>,
    /// Raw cookie string for manual header injection.
    pub(crate) cookie_string: String,
}

impl std::fmt::Debug for Session {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Session")
            .field("steam_id", &self.steam_id)
            .field("session_id", &self.session_id)
            .field("mobile_access_token", &self.mobile_access_token.as_ref().map(|_| "<redacted>"))
            .field("access_token", &self.access_token.as_ref().map(|_| "<redacted>"))
            .field("refresh_token", &self.refresh_token.as_ref().map(|_| "<redacted>"))
            .field("shared_secret", &self.shared_secret.lock().as_ref().map(|_| "<redacted>"))
            .field("profile_url", &"<Mutex>")
            .finish()
    }
}

impl Clone for Session {
    fn clone(&self) -> Self {
        Self {
            jar: Arc::clone(&self.jar),
            steam_id: self.steam_id,
            session_id: self.session_id.clone(),
            mobile_access_token: self.mobile_access_token.clone(),
            access_token: self.access_token.clone(),
            refresh_token: self.refresh_token.clone(),
            shared_secret: Mutex::new(self.shared_secret.lock().clone()),
            profile_url: Mutex::new(self.profile_url.lock().clone()),
            cookie_string: self.cookie_string.clone(),
        }
    }
}

impl Default for Session {
    fn default() -> Self {
        Self::new()
    }
}

impl Session {
    /// Create a new empty session.
    pub fn new() -> Self {
        Self {
            jar: Arc::new(Jar::default()),
            steam_id: None,
            session_id: None,
            mobile_access_token: None,
            access_token: None,
            refresh_token: None,
            shared_secret: Mutex::new(None),
            profile_url: Mutex::new(None),
            cookie_string: String::new(),
        }
    }

    /// Set cookies from string slice.
    ///
    /// Cookies should be in the format "name=value" or full Set-Cookie format.
    pub fn set_cookies(&mut self, cookies: &[&str]) -> Result<(), crate::SteamUserError> {
        // Store raw cookie string for manual injection
        self.cookie_string = cookies.join("; ");

        for raw_cookie in cookies {
            for cookie in raw_cookie.split(';') {
                let cookie = cookie.trim();
                if cookie.is_empty() {
                    continue;
                }

                // Extract cookie name
                let name = cookie.split('=').next().unwrap_or("");

                // Parse steamLoginSecure to get SteamID
                if name == "steamLoginSecure" || name == "steamLogin" {
                    if let Some(value) = cookie.split_once('=').map(|x| x.1) {
                        // Value format: "steamid||token" or just "steamid"
                        let decoded = urlencoding::decode(value).unwrap_or_default();
                        if let Some(id_str) = decoded.split("||").next() {
                            if let Ok(id) = id_str.parse::<u64>() {
                                self.steam_id = Some(SteamID::from(id));
                            }
                        }
                    }
                }

                // Extract sessionid
                if name == "sessionid" {
                    if let Some(value) = cookie.split_once('=').map(|x| x.1) {
                        self.session_id = Some(urlencoding::decode(value).unwrap_or_default().to_string());
                    }
                }

                // Add cookie to all Steam domains
                for url in STEAM_URLS.iter() {
                    self.jar.add_cookie_str(cookie, url);
                }
            }
        }

        Ok(())
    }

    pub fn ensure_session_id(&mut self) -> &str {
        if self.session_id.is_none() {
            use rand::Rng;
            let bytes: [u8; 12] = rand::rng().random();
            let new_id = hex::encode(bytes);
            self.session_id = Some(new_id.clone());

            // Add to jar to ensure CSRF checks pass
            let cookie_str = format!("sessionid={}", new_id);
            for url in STEAM_URLS.iter() {
                self.jar.add_cookie_str(&cookie_str, url);
            }

            // Update raw cookie string for manual injection
            if !self.cookie_string.is_empty() {
                self.cookie_string.push_str("; ");
            }
            self.cookie_string.push_str(&cookie_str);
        }
        self.session_id.as_deref().expect("session_id was just initialized")
    }

    /// Get the session ID, ensuring one exists.
    pub fn get_session_id(&mut self) -> &str {
        self.ensure_session_id()
    }

    /// Get the raw cookie string.
    pub fn get_cookie_string(&self) -> &str {
        &self.cookie_string
    }

    /// Get the SteamID for the current session.
    pub fn get_steam_id(&self) -> SteamID {
        self.steam_id.unwrap_or_default()
    }

    /// Check if the session appears to be logged in (has steam_id).
    pub fn is_logged_in(&self) -> bool {
        self.steam_id.is_some()
    }

    /// Get the mobile access token, if set.
    pub fn mobile_access_token(&self) -> Option<&str> {
        self.mobile_access_token.as_deref()
    }

    /// Get the OAuth access token, if set.
    pub fn access_token(&self) -> Option<&str> {
        self.access_token.as_deref()
    }

    /// Get the OAuth refresh token, if set.
    pub fn refresh_token(&self) -> Option<&str> {
        self.refresh_token.as_deref()
    }

    /// Set the mobile access token for 2FA operations.
    pub fn set_mobile_access_token(&mut self, token: String) {
        self.mobile_access_token = Some(token);
    }

    /// Set the refresh token for token enumeration and renewal.
    pub fn set_refresh_token(&mut self, token: String) {
        self.refresh_token = Some(token);
    }

    /// Set the access token.
    pub fn set_access_token(&mut self, token: String) {
        self.access_token = Some(token);
    }

    /// Clear the cached profile URL.
    pub fn clear_profile_url(&self) {
        *self.profile_url.lock() = None;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_session_new() {
        let session = Session::new();
        assert!(session.steam_id.is_none());
        assert!(session.session_id.is_none());
        assert!(!session.is_logged_in());
    }

    #[test]
    fn test_set_cookies() {
        let mut session = Session::new();
        session.set_cookies(&["steamLoginSecure=76561198012345678%7C%7Ctoken123", "sessionid=abc123def456"]).unwrap();

        assert!(session.is_logged_in());
        assert_eq!(session.steam_id.unwrap().steam_id64(), 76561198012345678);
        assert_eq!(session.session_id.as_ref().unwrap(), "abc123def456");
    }

    #[test]
    fn test_ensure_session_id() {
        let mut session = Session::new();
        let id = session.ensure_session_id().to_string();
        assert_eq!(id.len(), 24); // 12 bytes = 24 hex chars

        // Should return same ID on subsequent calls
        let id2 = session.ensure_session_id();
        assert_eq!(id, id2);
    }
}