Skip to main content

steam_auth/
cookies.rs

1//! Cookie handling utilities for Steam web authentication.
2//!
3//! This module provides pure functions for parsing and building cookies,
4//! extracted from `LoginSession::get_web_cookies` to enable easier unit
5//! testing.
6
7use std::collections::{HashMap, HashSet};
8
9use crate::error::SessionError;
10
11/// Transfer endpoint information from finalize login response.
12#[derive(Debug, Clone)]
13pub struct TransferInfo {
14    /// The URL to post the transfer request to.
15    pub url: String,
16    /// The nonce value for the transfer.
17    pub nonce: Option<String>,
18    /// The auth value for the transfer.
19    pub auth: Option<String>,
20}
21
22/// Parse transfer info from finalize login JSON response.
23///
24/// # Arguments
25/// * `json` - The parsed JSON value from the finalize login response
26///
27/// # Returns
28/// * `Ok(Vec<TransferInfo>)` - List of transfer endpoints
29/// * `Err(SessionError)` - If the transfer_info field is missing or malformed
30///
31/// # Example
32/// ```rust,ignore
33/// let json: serde_json::Value = response.json()?;
34/// let transfers = parse_transfer_info(&json)?;
35/// for transfer in transfers {
36///     println!("Transfer to: {}", transfer.url);
37/// }
38/// ```
39pub fn parse_transfer_info(json: &serde_json::Value) -> Result<Vec<TransferInfo>, SessionError> {
40    let transfer_info = json["transfer_info"].as_array().ok_or(SessionError::NetworkError("No transfer_info in response".into()))?;
41
42    let mut result = Vec::new();
43    for transfer in transfer_info {
44        let url = transfer["url"].as_str().ok_or(SessionError::NetworkError("Missing transfer URL".into()))?.to_string();
45
46        let params = &transfer["params"];
47        let nonce = params["nonce"].as_str().map(String::from);
48        let auth = params["auth"].as_str().map(String::from);
49
50        result.push(TransferInfo { url, nonce, auth });
51    }
52
53    Ok(result)
54}
55
56/// Extract the domain from a URL for cookie setting.
57///
58/// # Arguments
59/// * `url` - The URL to extract the domain from
60///
61/// # Returns
62/// * `Some(String)` - The domain (host) extracted from the URL
63/// * `None` - If the URL cannot be parsed or has no host
64pub fn extract_domain_from_url(url: &str) -> Option<String> {
65    url::Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(String::from))
66}
67
68/// Build a cookie string with domain attached.
69///
70/// If the cookie already has a Domain attribute, returns it as-is.
71/// Otherwise, appends the domain from the URL.
72///
73/// # Arguments
74/// * `cookie_str` - The raw cookie string from the Set-Cookie header
75/// * `url` - The URL the cookie came from (for domain extraction)
76///
77/// # Returns
78/// * `Some(String)` - The cookie with domain attached
79/// * `None` - If the URL has no valid domain
80pub fn build_cookie_with_domain(cookie_str: &str, url: &str) -> Option<String> {
81    let domain = extract_domain_from_url(url)?;
82
83    if cookie_str.to_lowercase().contains("domain=") {
84        Some(cookie_str.to_string())
85    } else {
86        Some(format!("{}; Domain={}", cookie_str, domain))
87    }
88}
89
90/// Parse cookies from a Set-Cookie header value.
91///
92/// Handles both comma-separated and newline-separated cookies.
93///
94/// # Arguments
95/// * `header_value` - The value of the Set-Cookie header
96/// * `url` - The URL the cookies came from (for domain extraction)
97///
98/// # Returns
99/// A vector of cookie strings with domains attached
100pub fn parse_cookies_from_header(header_value: &str, url: &str) -> Vec<String> {
101    let mut cookies = Vec::new();
102    let mut seen = std::collections::HashSet::new();
103
104    // First split by newlines, then by ", " for each line
105    for line in header_value.split('\n') {
106        for cookie_str in line.split(", ") {
107            let cookie_str = cookie_str.trim();
108            if cookie_str.starts_with("steamLoginSecure=") {
109                if let Some(cookie) = build_cookie_with_domain(cookie_str, url) {
110                    // Deduplicate cookies
111                    if !seen.contains(&cookie) {
112                        seen.insert(cookie.clone());
113                        cookies.push(cookie);
114                    }
115                }
116            }
117        }
118    }
119
120    cookies
121}
122
123/// Extract unique domains from a list of cookies.
124///
125/// Parses the Domain= attribute from each cookie and returns unique domains,
126/// excluding login.steampowered.com.
127///
128/// # Arguments
129/// * `cookies` - List of cookie strings
130///
131/// # Returns
132/// A set of unique domain strings
133pub fn extract_cookie_domains(cookies: &[String]) -> HashSet<String> {
134    cookies.iter().filter_map(|c| c.split("Domain=").nth(1).map(|d| d.split(';').next().unwrap_or(d).to_string())).filter(|d| d != "login.steampowered.com").collect()
135}
136
137/// Add sessionid cookies for all domains.
138///
139/// Creates sessionid cookies for each domain with proper attributes.
140///
141/// # Arguments
142/// * `cookies` - Mutable list of cookies to append to
143/// * `session_id` - The session ID value to use
144/// * `domains` - Set of domains to create cookies for
145pub fn add_session_id_cookies(cookies: &mut Vec<String>, session_id: &str, domains: &HashSet<String>) {
146    for domain in domains {
147        cookies.push(format!("sessionid={}; Path=/; Secure; SameSite=None; Domain={}", session_id, domain));
148    }
149}
150
151/// Filter out sessionid cookies from a list.
152///
153/// Removes any existing sessionid cookies to prepare for adding fresh ones.
154///
155/// # Arguments
156/// * `cookies` - Mutable list of cookies to filter
157pub fn filter_session_id_cookies(cookies: &mut Vec<String>) {
158    cookies.retain(|c| !c.starts_with("sessionid="));
159}
160
161/// Build cookies for non-WebBrowser platforms (MobileApp, SteamClient).
162///
163/// Creates a simple cookie set with steamLoginSecure and sessionid.
164///
165/// # Arguments
166/// * `steam_id` - The user's Steam ID
167/// * `access_token` - The access token
168/// * `session_id` - The generated session ID
169///
170/// # Returns
171/// A vector containing the two required cookies
172pub fn build_simple_cookies(steam_id: u64, access_token: &str, session_id: &str) -> Vec<String> {
173    let cookie_value = format!("{}||{}", steam_id, access_token);
174    let encoded = urlencoding::encode(&cookie_value);
175
176    vec![format!("steamLoginSecure={}", encoded), format!("sessionid={}", session_id)]
177}
178
179/// Check for errors in finalize login response.
180///
181/// # Arguments
182/// * `json` - The parsed JSON response
183///
184/// # Returns
185/// * `Ok(())` - If no error is present
186/// * `Err(SessionError)` - If an error is found in the response
187pub fn check_finalize_error(json: &serde_json::Value) -> Result<(), SessionError> {
188    if let Some(error) = json.get("error") {
189        if !error.is_null() {
190            let eresult_val = json["eresult"].as_i64().or_else(|| json["error"].as_i64()).unwrap_or(2) as i32; // Default to EResult::Fail
191
192            return Err(SessionError::from_eresult(eresult_val, Some(error.to_string())));
193        }
194    }
195    Ok(())
196}
197/// Extract steamLoginSecure cookies from a Set-Cookie header.
198///
199/// This function is specifically optimized for extracting authentication
200/// cookies from Steam's login flow responses.
201///
202/// # Arguments
203/// * `cookie_header` - The raw Set-Cookie header value (may contain multiple
204///   cookies)
205/// * `url` - The URL the cookies came from (for domain extraction)
206///
207/// # Returns
208/// A vector of steamLoginSecure cookie strings with domains attached
209///
210/// # Example
211/// ```rust,ignore
212/// let header = "steamLoginSecure=abc123; Path=/; HttpOnly";
213/// let cookies = extract_steam_login_cookies(header, "https://store.steampowered.com/");
214/// assert_eq!(cookies.len(), 1);
215/// ```
216pub fn extract_steam_login_cookies(cookie_header: &str, url: &str) -> Vec<String> {
217    let mut cookies = Vec::new();
218    let mut seen = std::collections::HashSet::new();
219
220    // First split by newlines, then by ", " for each line
221    for line in cookie_header.split('\n') {
222        for cookie_str in line.split(", ") {
223            let cookie_str = cookie_str.trim();
224            if cookie_str.starts_with("steamLoginSecure=") {
225                if let Some(cookie) = build_cookie_with_domain(cookie_str, url) {
226                    // Deduplicate cookies
227                    if !seen.contains(&cookie) {
228                        seen.insert(cookie.clone());
229                        cookies.push(cookie);
230                    }
231                }
232            }
233        }
234    }
235
236    cookies
237}
238
239/// Build form parameters for a transfer request.
240///
241/// Creates a HashMap of form fields required for Steam's token transfer
242/// endpoint.
243///
244/// # Arguments
245/// * `transfer` - The TransferInfo containing nonce and auth values
246/// * `steam_id` - The user's Steam ID
247///
248/// # Returns
249/// A HashMap containing the form fields (steamID, and optionally nonce/auth)
250///
251/// # Example
252/// ```rust,ignore
253/// let transfer = TransferInfo { url: "...", nonce: Some("n123".into()), auth: Some("a456".into()) };
254/// let params = build_transfer_form_params(&transfer, 76561198000000000);
255/// assert_eq!(params.get("steamID"), Some(&"76561198000000000".to_string()));
256/// ```
257pub fn build_transfer_form_params(transfer: &TransferInfo, steam_id: u64) -> HashMap<String, String> {
258    let mut params = HashMap::new();
259    params.insert("steamID".to_string(), steam_id.to_string());
260
261    if let Some(ref nonce) = transfer.nonce {
262        params.insert("nonce".to_string(), nonce.clone());
263    }
264    if let Some(ref auth) = transfer.auth {
265        params.insert("auth".to_string(), auth.clone());
266    }
267
268    params
269}
270
271// ============================================================================
272// Transfer execution functions (extracted from LoginSession::get_web_cookies)
273// ============================================================================
274
275use std::time::Duration;
276
277use crate::http_client::{HttpClient, MultipartForm};
278
279/// Result of executing a single transfer request.
280///
281/// This enum represents the possible outcomes of a transfer attempt,
282/// allowing the caller to decide whether to retry or handle the result.
283#[derive(Debug)]
284pub enum TransferResult {
285    /// Transfer succeeded with extracted cookies.
286    Success(Vec<String>),
287    /// Transfer failed but should be retried (e.g., 429 Too Many Requests).
288    Retry,
289    /// Transfer failed with a fatal error that should not be retried.
290    Error(SessionError),
291}
292
293/// Execute a single transfer request and extract cookies from the response.
294pub async fn execute_single_transfer(http_client: &HttpClient, transfer: &TransferInfo, steam_id: u64) -> TransferResult {
295    // Build form data
296    let form = MultipartForm::new().text("steamID", steam_id.to_string());
297
298    let form = if let Some(ref nonce) = transfer.nonce { form.text("nonce", nonce.clone()) } else { form };
299
300    let form = if let Some(ref auth) = transfer.auth { form.text("auth", auth.clone()) } else { form };
301
302    // Execute request
303    let result = http_client.post_multipart(&transfer.url, form, HashMap::new()).await;
304
305    match result {
306        Ok(response) if response.is_success() => {
307            // Extract cookies from response
308            let cookies = if let Some(cookie_header) = response.get_header("set-cookie") {
309                extract_steam_login_cookies(cookie_header, &transfer.url)
310            } else {
311                // Also check for multiple Set-Cookie headers
312                let headers = response.get_all_headers("set-cookie");
313                let mut all_cookies = Vec::new();
314                for header in headers {
315                    all_cookies.extend(extract_steam_login_cookies(header, &transfer.url));
316                }
317                all_cookies
318            };
319            TransferResult::Success(cookies)
320        }
321        Ok(response) => {
322            // Non-success status (e.g., 429 Too Many Requests)
323            tracing::debug!("Transfer to {} returned status {}, will retry", transfer.url, response.status);
324            TransferResult::Retry
325        }
326        Err(e) => {
327            tracing::debug!("Transfer to {} failed with error: {:?}", transfer.url, e);
328            TransferResult::Retry
329        }
330    }
331}
332
333/// Execute all transfers with retry logic.
334pub async fn execute_transfers_with_retry(http_client: &HttpClient, transfers: &[TransferInfo], steam_id: u64, max_retries: usize, retry_delay: Duration) -> Result<Vec<String>, SessionError> {
335    let mut all_cookies = Vec::new();
336
337    for transfer in transfers {
338        let mut last_status = None;
339
340        for attempt in 0..max_retries {
341            match execute_single_transfer(http_client, transfer, steam_id).await {
342                TransferResult::Success(cookies) => {
343                    all_cookies.extend(cookies);
344                    break;
345                }
346                TransferResult::Retry if attempt < max_retries - 1 => {
347                    // Wait before retrying
348                    tokio::time::sleep(retry_delay).await;
349                }
350                TransferResult::Retry => {
351                    // Final retry attempt failed
352                    last_status = Some("retry limit exceeded");
353                }
354                TransferResult::Error(e) => {
355                    return Err(e);
356                }
357            }
358        }
359
360        if let Some(status) = last_status {
361            return Err(SessionError::NetworkError(format!("Transfer to {} failed: {} after {} attempts", transfer.url, status, max_retries)));
362        }
363    }
364
365    Ok(all_cookies)
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_extract_domain_from_url() {
374        assert_eq!(extract_domain_from_url("https://store.steampowered.com/login"), Some("store.steampowered.com".to_string()));
375        assert_eq!(extract_domain_from_url("https://steamcommunity.com/"), Some("steamcommunity.com".to_string()));
376        assert_eq!(extract_domain_from_url("invalid-url"), None);
377    }
378
379    #[test]
380    fn test_build_cookie_with_domain() {
381        let cookie = "steamLoginSecure=abc123";
382        let url = "https://store.steampowered.com/login";
383
384        let result = build_cookie_with_domain(cookie, url);
385        assert_eq!(result, Some("steamLoginSecure=abc123; Domain=store.steampowered.com".to_string()));
386    }
387
388    #[test]
389    fn test_build_cookie_with_existing_domain() {
390        let cookie = "steamLoginSecure=abc123; Domain=steampowered.com";
391        let url = "https://store.steampowered.com/login";
392
393        let result = build_cookie_with_domain(cookie, url);
394        assert_eq!(result, Some("steamLoginSecure=abc123; Domain=steampowered.com".to_string()));
395    }
396
397    #[test]
398    fn test_parse_cookies_from_header() {
399        let header = "steamLoginSecure=abc123, steamLoginSecure=def456";
400        let url = "https://store.steampowered.com/";
401
402        let cookies = parse_cookies_from_header(header, url);
403        assert_eq!(cookies.len(), 2);
404        assert!(cookies[0].contains("steamLoginSecure=abc123"));
405        assert!(cookies[0].contains("Domain=store.steampowered.com"));
406    }
407
408    #[test]
409    fn test_parse_cookies_ignores_non_login_cookies() {
410        // Multiple Set-Cookie headers are comma-separated or newline-separated
411        let header = "sessionid=xyz, steamLoginSecure=abc123";
412        let url = "https://store.steampowered.com/";
413
414        let cookies = parse_cookies_from_header(header, url);
415        assert_eq!(cookies.len(), 1);
416        assert!(cookies[0].contains("steamLoginSecure=abc123"));
417    }
418
419    #[test]
420    fn test_extract_cookie_domains() {
421        let cookies = vec![
422            "steamLoginSecure=abc; Domain=store.steampowered.com".to_string(),
423            "steamLoginSecure=def; Domain=steamcommunity.com".to_string(),
424            "steamLoginSecure=ghi; Domain=login.steampowered.com".to_string(), // Should be excluded
425        ];
426
427        let domains = extract_cookie_domains(&cookies);
428        assert_eq!(domains.len(), 2);
429        assert!(domains.contains("store.steampowered.com"));
430        assert!(domains.contains("steamcommunity.com"));
431        assert!(!domains.contains("login.steampowered.com"));
432    }
433
434    #[test]
435    fn test_add_session_id_cookies() {
436        let mut cookies = Vec::new();
437        let mut domains = HashSet::new();
438        domains.insert("store.steampowered.com".to_string());
439        domains.insert("steamcommunity.com".to_string());
440
441        add_session_id_cookies(&mut cookies, "sess123", &domains);
442
443        assert_eq!(cookies.len(), 2);
444        for cookie in &cookies {
445            assert!(cookie.starts_with("sessionid=sess123"));
446            assert!(cookie.contains("Path=/"));
447            assert!(cookie.contains("Secure"));
448        }
449    }
450
451    #[test]
452    fn test_filter_session_id_cookies() {
453        let mut cookies = vec!["sessionid=old".to_string(), "steamLoginSecure=abc".to_string(), "sessionid=another".to_string()];
454
455        filter_session_id_cookies(&mut cookies);
456
457        assert_eq!(cookies.len(), 1);
458        assert_eq!(cookies[0], "steamLoginSecure=abc");
459    }
460
461    #[test]
462    fn test_build_simple_cookies() {
463        let cookies = build_simple_cookies(76561198000000000, "access_token_here", "sess123");
464
465        assert_eq!(cookies.len(), 2);
466        assert!(cookies[0].starts_with("steamLoginSecure="));
467        assert!(cookies[0].contains("76561198000000000"));
468        assert_eq!(cookies[1], "sessionid=sess123");
469    }
470
471    #[test]
472    fn test_parse_transfer_info() {
473        let json: serde_json::Value = serde_json::from_str(
474            r#"{
475            "transfer_info": [
476                {
477                    "url": "https://store.steampowered.com/login/settoken",
478                    "params": {
479                        "nonce": "nonce123",
480                        "auth": "auth456"
481                    }
482                },
483                {
484                    "url": "https://steamcommunity.com/login/settoken",
485                    "params": {
486                        "nonce": "nonce789"
487                    }
488                }
489            ]
490        }"#,
491        )
492        .unwrap();
493
494        let result = parse_transfer_info(&json).unwrap();
495        assert_eq!(result.len(), 2);
496        assert_eq!(result[0].url, "https://store.steampowered.com/login/settoken");
497        assert_eq!(result[0].nonce, Some("nonce123".to_string()));
498        assert_eq!(result[0].auth, Some("auth456".to_string()));
499        assert_eq!(result[1].url, "https://steamcommunity.com/login/settoken");
500        assert!(result[1].auth.is_none());
501    }
502
503    #[test]
504    fn test_parse_transfer_info_missing() {
505        let json: serde_json::Value = serde_json::from_str("{}").unwrap();
506
507        let result = parse_transfer_info(&json);
508        assert!(result.is_err());
509    }
510
511    #[test]
512    fn test_check_finalize_error_no_error() {
513        let json: serde_json::Value = serde_json::from_str(
514            r#"{
515            "transfer_info": []
516        }"#,
517        )
518        .unwrap();
519
520        assert!(check_finalize_error(&json).is_ok());
521    }
522
523    #[test]
524    fn test_check_finalize_error_with_error() {
525        let json: serde_json::Value = serde_json::from_str(
526            r#"{
527            "error": "Invalid token"
528        }"#,
529        )
530        .unwrap();
531
532        let result = check_finalize_error(&json);
533        assert!(result.is_err());
534    }
535
536    #[test]
537    fn test_check_finalize_error_null_error() {
538        let json: serde_json::Value = serde_json::from_str(
539            r#"{
540            "error": null,
541            "transfer_info": []
542        }"#,
543        )
544        .unwrap();
545
546        assert!(check_finalize_error(&json).is_ok());
547    }
548
549    #[test]
550    fn test_extract_steam_login_cookies_single() {
551        let header = "steamLoginSecure=abc123; Path=/; HttpOnly";
552        let url = "https://store.steampowered.com/";
553
554        let cookies = extract_steam_login_cookies(header, url);
555        assert_eq!(cookies.len(), 1);
556        assert!(cookies[0].contains("steamLoginSecure=abc123"));
557        assert!(cookies[0].contains("Domain=store.steampowered.com"));
558    }
559
560    #[test]
561    fn test_extract_steam_login_cookies_multiple() {
562        let header = "steamLoginSecure=abc123, steamLoginSecure=def456";
563        let url = "https://store.steampowered.com/";
564
565        let cookies = extract_steam_login_cookies(header, url);
566        assert_eq!(cookies.len(), 2);
567    }
568
569    #[test]
570    fn test_extract_steam_login_cookies_with_newlines() {
571        let header = "other=cookie\nsteamLoginSecure=abc123";
572        let url = "https://store.steampowered.com/";
573
574        let cookies = extract_steam_login_cookies(header, url);
575        assert_eq!(cookies.len(), 1);
576        assert!(cookies[0].contains("steamLoginSecure=abc123"));
577    }
578
579    #[test]
580    fn test_extract_steam_login_cookies_ignores_other() {
581        let header = "sessionid=xyz; Path=/";
582        let url = "https://store.steampowered.com/";
583
584        let cookies = extract_steam_login_cookies(header, url);
585        assert!(cookies.is_empty());
586    }
587
588    #[test]
589    fn test_build_transfer_form_params_full() {
590        let transfer = TransferInfo { url: "https://test.com".to_string(), nonce: Some("nonce123".to_string()), auth: Some("auth456".to_string()) };
591
592        let params = build_transfer_form_params(&transfer, 76561198000000000);
593
594        assert_eq!(params.get("steamID"), Some(&"76561198000000000".to_string()));
595        assert_eq!(params.get("nonce"), Some(&"nonce123".to_string()));
596        assert_eq!(params.get("auth"), Some(&"auth456".to_string()));
597    }
598
599    #[test]
600    fn test_build_transfer_form_params_nonce_only() {
601        let transfer = TransferInfo { url: "https://test.com".to_string(), nonce: Some("nonce123".to_string()), auth: None };
602
603        let params = build_transfer_form_params(&transfer, 12345);
604
605        assert_eq!(params.get("steamID"), Some(&"12345".to_string()));
606        assert_eq!(params.get("nonce"), Some(&"nonce123".to_string()));
607        assert!(!params.contains_key("auth"));
608    }
609
610    #[test]
611    fn test_build_transfer_form_params_minimal() {
612        let transfer = TransferInfo { url: "https://test.com".to_string(), nonce: None, auth: None };
613
614        let params = build_transfer_form_params(&transfer, 99999);
615
616        assert_eq!(params.len(), 1);
617        assert_eq!(params.get("steamID"), Some(&"99999".to_string()));
618    }
619}