Skip to main content

steam_user/
session.rs

1//! Session and cookie management for Steam Community.
2
3use std::sync::{Arc, LazyLock};
4
5use parking_lot::Mutex;
6use reqwest::{cookie::Jar, Url};
7use steamid::SteamID;
8
9/// All Steam domains that need cookies set.
10static 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")]);
11
12/// Session manager for Steam Community.
13///
14/// Handles cookies, session IDs, and authentication state.
15pub struct Session {
16    /// The cookie jar (shared across clones).
17    pub(crate) jar: Arc<Jar>,
18    /// Current Steam ID if logged in.
19    pub steam_id: Option<SteamID>,
20    /// Session ID for CSRF protection.
21    pub session_id: Option<String>,
22    /// Mobile access token for 2FA operations.
23    pub(crate) mobile_access_token: Option<String>,
24    /// OAuth access token.
25    pub(crate) access_token: Option<String>,
26    /// OAuth refresh token.
27    pub(crate) refresh_token: Option<String>,
28    /// Shared secret for 2FA finalization (behind Mutex for interior
29    /// mutability).
30    pub(crate) shared_secret: Mutex<Option<String>>,
31    /// Cached profile URL (e.g., "/id/username" or "/profiles/76561198...")
32    pub(crate) profile_url: Mutex<Option<String>>,
33    /// Raw cookie string for manual header injection.
34    pub(crate) cookie_string: String,
35}
36
37impl std::fmt::Debug for Session {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("Session")
40            .field("steam_id", &self.steam_id)
41            .field("session_id", &self.session_id)
42            .field("mobile_access_token", &self.mobile_access_token.as_ref().map(|_| "<redacted>"))
43            .field("access_token", &self.access_token.as_ref().map(|_| "<redacted>"))
44            .field("refresh_token", &self.refresh_token.as_ref().map(|_| "<redacted>"))
45            .field("shared_secret", &self.shared_secret.lock().as_ref().map(|_| "<redacted>"))
46            .field("profile_url", &"<Mutex>")
47            .finish()
48    }
49}
50
51impl Clone for Session {
52    fn clone(&self) -> Self {
53        Self {
54            jar: Arc::clone(&self.jar),
55            steam_id: self.steam_id,
56            session_id: self.session_id.clone(),
57            mobile_access_token: self.mobile_access_token.clone(),
58            access_token: self.access_token.clone(),
59            refresh_token: self.refresh_token.clone(),
60            shared_secret: Mutex::new(self.shared_secret.lock().clone()),
61            profile_url: Mutex::new(self.profile_url.lock().clone()),
62            cookie_string: self.cookie_string.clone(),
63        }
64    }
65}
66
67impl Default for Session {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl Session {
74    /// Create a new empty session.
75    pub fn new() -> Self {
76        Self {
77            jar: Arc::new(Jar::default()),
78            steam_id: None,
79            session_id: None,
80            mobile_access_token: None,
81            access_token: None,
82            refresh_token: None,
83            shared_secret: Mutex::new(None),
84            profile_url: Mutex::new(None),
85            cookie_string: String::new(),
86        }
87    }
88
89    /// Set cookies from string slice.
90    ///
91    /// Cookies should be in the format "name=value" or full Set-Cookie format.
92    pub fn set_cookies(&mut self, cookies: &[&str]) -> Result<(), crate::SteamUserError> {
93        // Store raw cookie string for manual injection
94        self.cookie_string = cookies.join("; ");
95
96        for raw_cookie in cookies {
97            for cookie in raw_cookie.split(';') {
98                let cookie = cookie.trim();
99                if cookie.is_empty() {
100                    continue;
101                }
102
103                // Extract cookie name
104                let name = cookie.split('=').next().unwrap_or("");
105
106                // Parse steamLoginSecure to get SteamID
107                if name == "steamLoginSecure" || name == "steamLogin" {
108                    if let Some(value) = cookie.split_once('=').map(|x| x.1) {
109                        // Value format: "steamid||token" or just "steamid"
110                        let decoded = urlencoding::decode(value).unwrap_or_default();
111                        if let Some(id_str) = decoded.split("||").next() {
112                            if let Ok(id) = id_str.parse::<u64>() {
113                                self.steam_id = Some(SteamID::from(id));
114                            }
115                        }
116                    }
117                }
118
119                // Extract sessionid
120                if name == "sessionid" {
121                    if let Some(value) = cookie.split_once('=').map(|x| x.1) {
122                        self.session_id = Some(urlencoding::decode(value).unwrap_or_default().to_string());
123                    }
124                }
125
126                // Add cookie to all Steam domains
127                for url in STEAM_URLS.iter() {
128                    self.jar.add_cookie_str(cookie, url);
129                }
130            }
131        }
132
133        Ok(())
134    }
135
136    pub fn ensure_session_id(&mut self) -> &str {
137        if self.session_id.is_none() {
138            use rand::Rng;
139            let bytes: [u8; 12] = rand::rng().random();
140            let new_id = hex::encode(bytes);
141            self.session_id = Some(new_id.clone());
142
143            // Add to jar to ensure CSRF checks pass
144            let cookie_str = format!("sessionid={}", new_id);
145            for url in STEAM_URLS.iter() {
146                self.jar.add_cookie_str(&cookie_str, url);
147            }
148
149            // Update raw cookie string for manual injection
150            if !self.cookie_string.is_empty() {
151                self.cookie_string.push_str("; ");
152            }
153            self.cookie_string.push_str(&cookie_str);
154        }
155        self.session_id.as_deref().expect("session_id was just initialized")
156    }
157
158    /// Get the session ID, ensuring one exists.
159    pub fn get_session_id(&mut self) -> &str {
160        self.ensure_session_id()
161    }
162
163    /// Get the raw cookie string.
164    pub fn get_cookie_string(&self) -> &str {
165        &self.cookie_string
166    }
167
168    /// Get the SteamID for the current session.
169    pub fn get_steam_id(&self) -> SteamID {
170        self.steam_id.unwrap_or_default()
171    }
172
173    /// Check if the session appears to be logged in (has steam_id).
174    pub fn is_logged_in(&self) -> bool {
175        self.steam_id.is_some()
176    }
177
178    /// Get the mobile access token, if set.
179    pub fn mobile_access_token(&self) -> Option<&str> {
180        self.mobile_access_token.as_deref()
181    }
182
183    /// Get the OAuth access token, if set.
184    pub fn access_token(&self) -> Option<&str> {
185        self.access_token.as_deref()
186    }
187
188    /// Get the OAuth refresh token, if set.
189    pub fn refresh_token(&self) -> Option<&str> {
190        self.refresh_token.as_deref()
191    }
192
193    /// Set the mobile access token for 2FA operations.
194    pub fn set_mobile_access_token(&mut self, token: String) {
195        self.mobile_access_token = Some(token);
196    }
197
198    /// Set the refresh token for token enumeration and renewal.
199    pub fn set_refresh_token(&mut self, token: String) {
200        self.refresh_token = Some(token);
201    }
202
203    /// Set the access token.
204    pub fn set_access_token(&mut self, token: String) {
205        self.access_token = Some(token);
206    }
207
208    /// Clear the cached profile URL.
209    pub fn clear_profile_url(&self) {
210        *self.profile_url.lock() = None;
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_session_new() {
220        let session = Session::new();
221        assert!(session.steam_id.is_none());
222        assert!(session.session_id.is_none());
223        assert!(!session.is_logged_in());
224    }
225
226    #[test]
227    fn test_set_cookies() {
228        let mut session = Session::new();
229        session.set_cookies(&["steamLoginSecure=76561198012345678%7C%7Ctoken123", "sessionid=abc123def456"]).unwrap();
230
231        assert!(session.is_logged_in());
232        assert_eq!(session.steam_id.unwrap().steam_id64(), 76561198012345678);
233        assert_eq!(session.session_id.as_ref().unwrap(), "abc123def456");
234    }
235
236    #[test]
237    fn test_ensure_session_id() {
238        let mut session = Session::new();
239        let id = session.ensure_session_id().to_string();
240        assert_eq!(id.len(), 24); // 12 bytes = 24 hex chars
241
242        // Should return same ID on subsequent calls
243        let id2 = session.ensure_session_id();
244        assert_eq!(id, id2);
245    }
246}