Skip to main content

steam_user/
client.rs

1//! Steam Community client.
2//!
3//! The main client for interacting with Steam Community web endpoints.
4
5use std::{sync::Arc, time::Duration};
6
7use base64::Engine;
8use parking_lot::Mutex;
9use prost::Message;
10use reqwest::{Response, StatusCode};
11use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
12use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
13
14use crate::retry::KindAwareRetryStrategy;
15use steam_enums::etokenrenewaltype::ETokenRenewalType;
16use steam_protos::messages::auth::{CAuthenticationAccessTokenGenerateForAppRequest, CAuthenticationAccessTokenGenerateForAppResponse, CAuthenticationGetAuthSessionInfoRequest, CAuthenticationGetAuthSessionInfoResponse};
17use steamid::SteamID;
18
19use crate::{error::SteamUserError, session::Session, utils::qr::decode_login_qr_url};
20
21/// Default User-Agent (Chrome-like).
22const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
23
24const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
25const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
26
27/// Timeout for fetches to non-Steam hosts (image CDNs, user-supplied URLs).
28/// Shorter than `DEFAULT_TIMEOUT` because we don't owe arbitrary external
29/// hosts the same patience we owe Steam.
30const EXTERNAL_TIMEOUT: Duration = Duration::from_secs(60);
31
32/// Inner wrapper to prevent accidental bypass of request builder overrides
33#[derive(Debug, Clone)]
34pub(crate) struct InnerHttpClient(ClientWithMiddleware);
35
36/// Steam Community web client.
37///
38/// Provides HTTP-based interactions with Steam Community endpoints.
39///
40/// `SteamUser` implements `Clone`. All clones share the same cookie jar
41/// (`Arc<Jar>`) and rate-limiter (`Arc<SteamRateLimiter>`), so they stay in
42/// sync for cookies and rate limits. The `time_offset` is copied by value at
43/// clone time.
44#[derive(Debug)]
45pub struct SteamUser {
46    /// HTTP client with cookie support.
47    pub(crate) client: InnerHttpClient,
48    /// HTTP client for non-Steam hosts. Has no cookie jar, no Steam
49    /// rate-limiter, and a shorter timeout. Use via `external_get` /
50    /// `external_post` for arbitrary external URLs (e.g. user-supplied
51    /// image URLs); never use it for Steam endpoints.
52    pub(crate) external_client: reqwest::Client,
53    /// HTTP client for Steam endpoints with redirect-following disabled,
54    /// used by `get_with_manual_redirects`. Shares the same cookie jar
55    /// as `client` so cookies stay in sync across redirect hops. Built
56    /// once in the constructor instead of per-call.
57    pub(crate) no_redirect_client: reqwest::Client,
58    /// Session state (cookies, steam_id, etc.).
59    pub session: Session,
60    /// Time offset from Steam servers (for TOTP), behind Mutex for interior
61    /// mutability.
62    pub(crate) time_offset: Mutex<Option<i64>>,
63    /// Optional per-session rate limiter.
64    pub(crate) session_limiter: Option<Arc<crate::limiter::SteamRateLimiter>>,
65}
66
67impl Clone for SteamUser {
68    fn clone(&self) -> Self {
69        Self {
70            client: self.client.clone(),
71            external_client: self.external_client.clone(),
72            no_redirect_client: self.no_redirect_client.clone(),
73            session: self.session.clone(),
74            time_offset: Mutex::new(*self.time_offset.lock()),
75            session_limiter: self.session_limiter.clone(),
76        }
77    }
78}
79
80/// Builder for [`SteamUser`].
81///
82/// Obtained via [`SteamUser::builder`]. Required field: cookies. All other
83/// fields fall back to the same defaults that [`SteamUser::new`] uses.
84#[derive(Debug, Default)]
85pub struct SteamUserBuilder {
86    cookies: Vec<String>,
87    rate_limit: Option<(u32, u32)>,
88    timeout: Option<Duration>,
89    connect_timeout: Option<Duration>,
90    external_timeout: Option<Duration>,
91    user_agent: Option<String>,
92    retry_count: Option<u32>,
93}
94
95impl SteamUserBuilder {
96    /// Set the session cookies. Replaces any previously-set cookies.
97    pub fn cookies<S: AsRef<str>>(mut self, cookies: &[S]) -> Self {
98        self.cookies = cookies.iter().map(|s| s.as_ref().to_string()).collect();
99        self
100    }
101
102    /// Set a per-session rate limit (requests-per-minute, burst).
103    /// Applied in addition to the global IP-wide limit.
104    pub fn rate_limit(mut self, requests_per_minute: u32, burst: u32) -> Self {
105        self.rate_limit = Some((requests_per_minute, burst));
106        self
107    }
108
109    /// Override the per-request timeout (default: 300s).
110    pub fn timeout(mut self, timeout: Duration) -> Self {
111        self.timeout = Some(timeout);
112        self
113    }
114
115    /// Override the TCP connect timeout (default: 30s).
116    pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self {
117        self.connect_timeout = Some(connect_timeout);
118        self
119    }
120
121    /// Override the timeout for the external (non-Steam) client (default: 60s).
122    pub fn external_timeout(mut self, external_timeout: Duration) -> Self {
123        self.external_timeout = Some(external_timeout);
124        self
125    }
126
127    /// Override the User-Agent header.
128    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
129        self.user_agent = Some(user_agent.into());
130        self
131    }
132
133    /// Override the retry count (default: 3 retries = 4 total attempts).
134    pub fn retry_count(mut self, retry_count: u32) -> Self {
135        self.retry_count = Some(retry_count);
136        self
137    }
138
139    /// Build the `SteamUser`. Validates inputs and constructs all three
140    /// HTTP clients (Steam, no-redirect Steam, external).
141    pub fn build(self) -> Result<SteamUser, SteamUserError> {
142        let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
143        let connect_timeout = self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT);
144        let external_timeout = self.external_timeout.unwrap_or(EXTERNAL_TIMEOUT);
145        let user_agent = self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT);
146        let retry_count = self.retry_count.unwrap_or(3);
147
148        let mut session = Session::new();
149        let cookie_refs: Vec<&str> = self.cookies.iter().map(|s| s.as_str()).collect();
150        session.set_cookies(&cookie_refs)?;
151        session.ensure_session_id();
152
153        let reqwest_client = reqwest::Client::builder().cookie_provider(Arc::clone(&session.jar)).connect_timeout(connect_timeout).timeout(timeout).user_agent(user_agent).gzip(true).min_tls_version(reqwest::tls::Version::TLS_1_2).https_only(true).build().map_err(|e| SteamUserError::ClientBuild(e.to_string()))?;
154
155        let retry_policy = ExponentialBackoff::builder().build_with_max_retries(retry_count);
156        let client = ClientBuilder::new(reqwest_client).with(reqwest_tracing::TracingMiddleware::default()).with(RetryTransientMiddleware::new_with_policy_and_strategy(retry_policy, KindAwareRetryStrategy)).build();
157
158        let external_client = reqwest::Client::builder().connect_timeout(connect_timeout).timeout(external_timeout).user_agent(user_agent).gzip(true).min_tls_version(reqwest::tls::Version::TLS_1_2).https_only(true).build().map_err(|e| SteamUserError::ClientBuild(e.to_string()))?;
159
160        let no_redirect_client = reqwest::Client::builder().cookie_provider(Arc::clone(&session.jar)).connect_timeout(connect_timeout).timeout(timeout).user_agent(user_agent).gzip(true).min_tls_version(reqwest::tls::Version::TLS_1_2).https_only(true).redirect(reqwest::redirect::Policy::none()).build().map_err(|e| SteamUserError::ClientBuild(e.to_string()))?;
161
162        let session_limiter = match self.rate_limit {
163            Some((rpm, burst)) => {
164                use std::num::NonZeroU32;
165
166                use governor::{Quota, RateLimiter};
167                let rpm = NonZeroU32::new(rpm).ok_or_else(|| SteamUserError::InvalidInput("`requests_per_minute` must be non-zero".into()))?;
168                let burst = NonZeroU32::new(burst).ok_or_else(|| SteamUserError::InvalidInput("`burst` must be non-zero".into()))?;
169                Some(Arc::new(RateLimiter::direct(Quota::per_minute(rpm).allow_burst(burst))))
170            }
171            None => None,
172        };
173
174        Ok(SteamUser { client: InnerHttpClient(client), external_client, no_redirect_client, session, time_offset: Mutex::new(None), session_limiter })
175    }
176}
177
178impl SteamUser {
179    /// Creates a new `SteamUser` client with the provided cookies.
180    ///
181    /// Convenience wrapper over [`SteamUser::builder`] with default
182    /// timeouts and user-agent. Use the builder when you need to tweak
183    /// any of those.
184    ///
185    /// # Arguments
186    ///
187    /// * `cookies` - A slice of cookie strings in "name=value" format.
188    ///
189    /// # Errors
190    ///
191    /// Returns a `SteamUserError` if the cookies are invalid or could not be
192    /// parsed.
193    #[tracing::instrument(skip(cookies))]
194    pub fn new(cookies: &[&str]) -> Result<Self, SteamUserError> {
195        Self::builder().cookies(cookies).build()
196    }
197
198    /// Returns a builder for configuring a new `SteamUser`.
199    ///
200    /// Use the builder to override defaults (timeouts, user-agent, retry
201    /// count) or to set a per-session rate limit at construction time
202    /// instead of via [`Self::with_rate_limit`].
203    pub fn builder() -> SteamUserBuilder {
204        SteamUserBuilder::default()
205    }
206
207    /// Sets a per-session rate limit for this `SteamUser`.
208    ///
209    /// This limit is applied *in addition* to the global IP-wide limit.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if `requests_per_minute` or `burst` is zero.
214    pub fn with_rate_limit(mut self, requests_per_minute: u32, burst: u32) -> Result<Self, crate::SteamUserError> {
215        use std::num::NonZeroU32;
216
217        use governor::{Quota, RateLimiter};
218
219        let rpm = NonZeroU32::new(requests_per_minute).ok_or_else(|| crate::SteamUserError::InvalidInput("`requests_per_minute` must be non-zero".into()))?;
220        let burst = NonZeroU32::new(burst).ok_or_else(|| crate::SteamUserError::InvalidInput("`burst` must be non-zero".into()))?;
221
222        self.session_limiter = Some(Arc::new(RateLimiter::direct(Quota::per_minute(rpm).allow_burst(burst))));
223        Ok(self)
224    }
225
226    /// Returns the current `SteamID` if the user is logged in.
227    pub fn steam_id(&self) -> Option<SteamID> {
228        self.session.steam_id
229    }
230
231    /// Creates a GET request with default query parameters (e.g., language set
232    /// to English).
233    ///
234    /// # Arguments
235    ///
236    /// * `url` - The target URL for the GET request.
237    pub fn get(&self, url: impl reqwest::IntoUrl) -> SteamRequestBuilder {
238        // GET requests don't typically need sessionid in body/form, only query.
239        // The base request method already adds it to query.
240        self.request(reqwest::Method::GET, url)
241    }
242
243    /// Creates a POST request to the specified URL.
244    ///
245    /// This method returns a `SteamRequestBuilder` which automatically appends
246    /// the session ID to form or multipart data if it's available.
247    pub fn post(&self, url: impl reqwest::IntoUrl) -> SteamRequestBuilder {
248        self.request(reqwest::Method::POST, url)
249    }
250
251    /// Creates a GET request using a relative path resolved against the
252    /// current `#[steam_endpoint]`'s host.
253    ///
254    /// # Panics
255    ///
256    /// Panics if called outside a `#[steam_endpoint]`-annotated method.
257    pub fn get_path(&self, path: impl std::fmt::Display) -> SteamRequestBuilder {
258        self.request_path(reqwest::Method::GET, path)
259    }
260
261    /// Creates a POST request using a relative path resolved against the
262    /// current `#[steam_endpoint]`'s host.
263    ///
264    /// # Panics
265    ///
266    /// Panics if called outside a `#[steam_endpoint]`-annotated method.
267    pub fn post_path(&self, path: impl std::fmt::Display) -> SteamRequestBuilder {
268        self.request_path(reqwest::Method::POST, path)
269    }
270
271    fn request_path(&self, method: reqwest::Method, path: impl std::fmt::Display) -> SteamRequestBuilder {
272        let base = crate::endpoint::current_endpoint()
273            .expect("*_path() called outside a #[steam_endpoint] method")
274            .host
275            .base_url();
276        self.request(method, format!("{base}{path}"))
277    }
278
279    /// Creates a GET request against an explicit Steam host, regardless of
280    /// the current `#[steam_endpoint]` (if any).
281    ///
282    /// Use when a Steam endpoint is reached on a host other than the one
283    /// declared by the enclosing method's `#[steam_endpoint]` attribute —
284    /// for example, a Help-host URL composed at runtime from inside a
285    /// Community-host method.
286    ///
287    /// Cookies, `sessionid` injection, and the per-host rate limiter are
288    /// applied exactly as in `get_path`.
289    pub fn get_path_on(&self, host: crate::endpoint::Host, path: impl std::fmt::Display) -> SteamRequestBuilder {
290        self.request(reqwest::Method::GET, format!("{}{}", host.base_url(), path))
291    }
292
293    /// Creates a POST request against an explicit Steam host. See
294    /// [`Self::get_path_on`] for the use case.
295    pub fn post_path_on(&self, host: crate::endpoint::Host, path: impl std::fmt::Display) -> SteamRequestBuilder {
296        self.request(reqwest::Method::POST, format!("{}{}", host.base_url(), path))
297    }
298
299    /// Creates a GET request to a non-Steam URL using the external client.
300    ///
301    /// **Use only for URLs that are NOT on Steam-owned hosts** — for
302    /// example, fetching a user-supplied avatar image from an arbitrary
303    /// CDN. Steam cookies are not attached, the Steam rate limiter is not
304    /// engaged, and middleware (retry, tracing) is bypassed.
305    ///
306    /// Returns a plain `reqwest::RequestBuilder`, not a `SteamRequestBuilder`,
307    /// because session-id injection and Steam-specific behaviour are
308    /// inappropriate for external hosts.
309    pub fn external_get(&self, url: impl reqwest::IntoUrl) -> reqwest::RequestBuilder {
310        self.external_client.get(url)
311    }
312
313    /// Creates a POST request to a non-Steam URL using the external
314    /// client. See [`Self::external_get`] for the use case.
315    pub fn external_post(&self, url: impl reqwest::IntoUrl) -> reqwest::RequestBuilder {
316        self.external_client.post(url)
317    }
318
319    /// Creates a request with the specified HTTP method and URL, including
320    /// common query parameters.
321    ///
322    /// This is a low-level method used by `get` and `post`. It sets the default
323    /// language and includes the session ID in the query parameters if it's
324    /// available.
325    #[allow(clippy::disallowed_methods)] // This IS the low-level wrapper; must call inner client.
326    pub fn request(&self, method: reqwest::Method, url: impl reqwest::IntoUrl) -> SteamRequestBuilder {
327        let mut builder = self.client.0.request(method.clone(), url).query(&[("l", "english")]);
328
329        // Manually inject cookies if available
330        if !self.session.cookie_string.is_empty() {
331            builder = builder.header("Cookie", &self.session.cookie_string);
332        }
333
334        SteamRequestBuilder {
335            builder,
336            session_id: self.session.session_id.clone(),
337            session_limiter: self.session_limiter.clone(),
338            request_body: None,
339            // Tracking-only field for debug logging; populated lazily when
340            // `query()` is called. The `?l=english` is already on the
341            // underlying `builder`.
342            query: None,
343            http_method: method,
344        }
345    }
346
347    /// Steam host allowlist for redirect validation.
348    ///
349    /// A redirect `Location` whose host is not on this list (or a subdomain
350    /// thereof) is rejected to prevent header-stripping / open-redirect abuse.
351    fn is_steam_host(host: &str) -> bool {
352        const ALLOWED: &[&str] = &[
353            "steamcommunity.com",
354            "store.steampowered.com",
355            "help.steampowered.com",
356            "api.steampowered.com",
357            "steampowered.com",
358            "s.team",
359        ];
360        for allowed in ALLOWED {
361            if host == *allowed || host.ends_with(&format!(".{}", allowed)) {
362                return true;
363            }
364        }
365        false
366    }
367
368    /// Performs a GET request with manual redirect handling.
369    ///
370    /// This is needed for pages like trade offers where Steam redirects through
371    /// `/market/eligibilitycheck/` which can fail with automatic redirect
372    /// following. Uses a temporary no-redirect client and manually follows
373    /// the redirect chain.
374    pub async fn get_with_manual_redirects(&self, url: &str) -> Result<String, SteamUserError> {
375        let no_redirect_client = &self.no_redirect_client;
376
377        let mut current_url = url.to_string();
378        let max_redirects = 10;
379        let mut seen_urls = std::collections::HashSet::new();
380
381        for i in 0..max_redirects {
382            // Only add l=english on the first request; redirect URLs already contain it.
383            // Do NOT manually set the Cookie header — the cookie jar (cookie_provider)
384            // handles cookies automatically. This is critical: the eligibility
385            // check response sets cookies that must be sent on subsequent
386            // requests, and a static Cookie header would override the jar and
387            // prevent those new cookies from being included.
388            let mut request = no_redirect_client.get(&current_url);
389            if i == 0 {
390                request = request.query(&[("l", "english")]);
391            }
392
393            let response = request.send().await?;
394            let status = response.status();
395
396            if status.is_redirection() {
397                if let Some(location) = response.headers().get(reqwest::header::LOCATION) {
398                    let location_str = location.to_str().map_err(|e| SteamUserError::RedirectError(format!("Invalid Location header: {e}")))?;
399
400                    // Resolve relative URLs against the current URL
401                    let base = reqwest::Url::parse(&current_url).map_err(|e| SteamUserError::RedirectError(format!("Invalid base URL: {e}")))?;
402                    let next_url = base.join(location_str).map_err(|e| SteamUserError::RedirectError(format!("Invalid redirect URL: {e}")))?;
403
404                    // Reject redirects to non-Steam hosts to prevent open-redirect abuse.
405                    let next_host = next_url.host_str().unwrap_or("");
406                    if !Self::is_steam_host(next_host) {
407                        return Err(SteamUserError::RedirectError(format!(
408                            "Redirect to non-Steam host rejected: {}",
409                            next_host
410                        )));
411                    }
412
413                    tracing::debug!("[TradeOffers] Following redirect: {} -> {}", current_url, next_url);
414
415                    // Detect redirect loops by checking the URL path (ignore query params)
416                    let loop_key = next_url.path().to_string();
417                    if !seen_urls.insert(loop_key) {
418                        tracing::warn!("[TradeOffers] Redirect loop detected at {}, attempting direct fetch", next_url.path());
419                        let direct_response = no_redirect_client.get(next_url.as_str()).send().await?;
420                        if direct_response.status().is_success() {
421                            return direct_response.text().await.map_err(SteamUserError::HttpError);
422                        }
423                        return Err(SteamUserError::HttpStatus { status: direct_response.status().as_u16(), url: next_url.to_string() });
424                    }
425
426                    current_url = next_url.to_string();
427                    continue;
428                }
429                return Err(SteamUserError::RedirectError("Redirect without Location header".into()));
430            }
431
432            if status.is_success() {
433                return response.text().await.map_err(SteamUserError::HttpError);
434            }
435
436            return Err(SteamUserError::HttpStatus { status: status.as_u16(), url: current_url.clone() });
437        }
438
439        Err(SteamUserError::RedirectError("Too many redirects".into()))
440    }
441
442    /// Returns the current session ID, generating one from cookies if it's not
443    /// already cached.
444    pub fn get_session_id(&mut self) -> &str {
445        self.session.get_session_id()
446    }
447
448    /// Returns `true` if the client session has a Steam ID and session ID,
449    /// suggesting the user is logged in.
450    ///
451    /// Note: This does not verify the session with Steam servers; use
452    /// `logged_in()` for a definitive check.
453    pub fn is_logged_in(&self) -> bool {
454        self.session.is_logged_in()
455    }
456
457    /// Sets the mobile access token used for two-factor authentication (2FA)
458    /// operations.
459    pub fn set_mobile_access_token(&mut self, token: String) {
460        self.session.set_mobile_access_token(token);
461    }
462
463    /// Sets the refresh token used for token enumeration and renewal.
464    pub fn set_refresh_token(&mut self, token: String) {
465        self.session.set_refresh_token(token);
466    }
467
468    /// Sets the access token.
469    pub fn set_access_token(&mut self, token: String) {
470        self.session.set_access_token(token);
471    }
472
473    /// Returns the cookies formatted as a string for web requests.
474    ///
475    /// This constructs the cookies using the access token and Steam ID,
476    /// similar to the "simple" cookie logic in `steam-auth`.
477    pub fn get_web_cookies(&self) -> String {
478        let steam_id = self.session.steam_id.map(|id| id.steam_id64().to_string()).unwrap_or_default();
479        let access_token = self.session.access_token.as_deref().unwrap_or_default();
480        let session_id = self.session.session_id.as_deref().unwrap_or_default();
481
482        let mut cookies = Vec::new();
483        cookies.push(format!("steamLoginSecure={}||{}", steam_id, access_token));
484        cookies.push(format!("sessionid={}", session_id));
485
486        // Add domain-specific sessionid cookies if needed, but for string
487        // representation this is usually sufficient or we'd need to know the
488        // target domain. For general use, these two are the core authentication
489        // cookies.
490
491        cookies.join("; ")
492    }
493
494    /// Verifies the current login status by making a request to Steam.
495    ///
496    /// Returns a tuple containing:
497    /// * `bool` - Whether the user is actually logged in.
498    /// * `bool` - Whether the account is currently restricted by Family View.
499    #[tracing::instrument(skip(self))]
500    #[allow(clippy::disallowed_methods)] // Deliberately bypasses rate limiter for lightweight session check.
501    pub async fn logged_in(&self) -> Result<(bool, bool), SteamUserError> {
502        // Use the no-redirect client so we see the raw 302 that Steam issues
503        // when `/my` resolves. The middleware client auto-follows redirects,
504        // making the FOUND branch unreachable with it.
505        let mut request = self.no_redirect_client.get("https://steamcommunity.com/my");
506
507        // Manually inject cookies if available
508        if !self.session.cookie_string.is_empty() {
509            request = request.header("Cookie", &self.session.cookie_string);
510        }
511
512        let response = request.send().await?;
513
514        let status = response.status();
515
516        if status == StatusCode::FORBIDDEN {
517            // 403 = Family View restricted but logged in
518            return Ok((true, true));
519        }
520
521        if status == StatusCode::FOUND {
522            // Primary login signal: Steam redirects /my to the user's profile
523            // on success and to a login URL on failure.
524            if let Some(location) = response.headers().get("location") {
525                let loc_str = location.to_str().unwrap_or("");
526                // Redirected to login page → not logged in
527                if loc_str.contains("/login") || loc_str.contains("steampowered.com/login") {
528                    return Ok((false, false));
529                }
530                // Redirected to profile page → logged in
531                if loc_str.contains("/id/") || loc_str.contains("/profiles/") {
532                    return Ok((true, false));
533                }
534            }
535        }
536
537        // Secondary signal: follow through to the resolved page body (rare fallback).
538        if status == StatusCode::OK {
539            let body = response.text().await.unwrap_or_default();
540            // If we can see profile page content, we're logged in
541            if body.contains("g_rgProfileData") || body.contains("actual_persona_name") {
542                return Ok((true, false));
543            }
544        }
545
546        // Any other case means not logged in
547        Ok((false, false))
548    }
549
550    /// Renews the current access token using the stored refresh token.
551    ///
552    /// This is typically used when an access token has expired.
553    #[tracing::instrument(skip(self))]
554    #[allow(clippy::disallowed_methods)] // Uses raw API endpoint, not community page — rate limiter N/A.
555    pub async fn renew_access_token(&mut self) -> Result<(), SteamUserError> {
556        let refresh_token = self.session.refresh_token.clone().ok_or(SteamUserError::MissingCredential { field: "refresh_token" })?;
557
558        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
559
560        let request = CAuthenticationAccessTokenGenerateForAppRequest {
561            refresh_token: Some(refresh_token.clone()),
562            steamid: Some(steam_id.steam_id64()),
563            renewal_type: Some(ETokenRenewalType::None as i32),
564        };
565
566        let mut body = Vec::new();
567        request.encode(&mut body)?;
568
569        // Construct query parameters (access_token moved to Authorization: Bearer)
570        let params = [("origin", "https://store.steampowered.com")];
571
572        let mut builder = self.client.0.post("https://api.steampowered.com/IAuthenticationService/GenerateAccessTokenForApp/v1").query(&params).form(&[("input_protobuf_encoded", base64::engine::general_purpose::STANDARD.encode(body))]);
573
574        if let Some(token) = self.session.access_token.as_deref() {
575            builder = builder.bearer_auth(token);
576        }
577
578        let response = builder.send().await?;
579
580        if !response.status().is_success() {
581            let status = response.status();
582            let url = response.url().to_string();
583            let text = response.text().await.unwrap_or_default();
584            tracing::error!("Renew response error: {} - {}", status, text);
585            return Err(SteamUserError::HttpStatus { status: status.as_u16(), url });
586        }
587
588        let bytes = response.bytes().await?;
589        let response_proto = CAuthenticationAccessTokenGenerateForAppResponse::decode(bytes)?;
590
591        // Debug logging
592        tracing::debug!("[renew_access_token] Response received:");
593        tracing::debug!("  - access_token present: {}", response_proto.access_token.is_some());
594        tracing::debug!("  - refresh_token present: {}", response_proto.refresh_token.is_some());
595        if let Some(ref token) = response_proto.access_token {
596            tracing::info!(token_len = token.len(), "new access_token acquired");
597        }
598
599        if let Some(new_access_token) = response_proto.access_token {
600            self.session.access_token = Some(new_access_token);
601        } else {
602            tracing::warn!("No new access token returned by Steam!");
603        }
604
605        if let Some(new_refresh_token) = response_proto.refresh_token {
606            self.session.refresh_token = Some(new_refresh_token);
607        }
608
609        Ok(())
610    }
611
612    /// Retrieves authentication session information for a given QR challenge
613    /// URL.
614    ///
615    /// This is used during the QR code login process to get details about the
616    /// pending session.
617    #[tracing::instrument(skip(self))]
618    #[allow(clippy::disallowed_methods)] // Uses raw API endpoint, not community page — rate limiter N/A.
619    pub async fn get_auth_session_info(&self, qr_challenge_url: &str) -> Result<CAuthenticationGetAuthSessionInfoResponse, SteamUserError> {
620        let (client_id, _version) = decode_login_qr_url(qr_challenge_url).ok_or_else(|| SteamUserError::InvalidInput("Invalid QR challenge URL".into()))?;
621
622        let request = CAuthenticationGetAuthSessionInfoRequest { client_id: Some(client_id) };
623
624        let mut body = Vec::new();
625        request.encode(&mut body)?;
626
627        // We need an access token for this request
628        let access_token = self.session.access_token.as_deref().ok_or(SteamUserError::MissingCredential { field: "access_token" })?;
629
630        let params = [("access_token", access_token), ("spoof_steamid", ""), ("origin", "https://store.steampowered.com")];
631
632        let response = self.client.0.post("https://api.steampowered.com/IAuthenticationService/GetAuthSessionInfo/v1/").query(&params).multipart(reqwest::multipart::Form::new().part("input_protobuf_encoded", reqwest::multipart::Part::bytes(body))).send().await?;
633
634        self.check_response(&response)?;
635
636        let bytes = response.bytes().await?;
637        let response_proto = CAuthenticationGetAuthSessionInfoResponse::decode(bytes)?;
638
639        Ok(response_proto)
640    }
641
642    /// Retrieves the Steam Chat client JS token.
643    ///
644    /// This token is used for authenticating with the Steam Chat WebSocket
645    /// connection. It is fetched from `https://steamcommunity.com/chat/clientjstoken`.
646    ///
647    /// # Returns
648    ///
649    /// Returns a `ClientJsToken` struct if successful.
650    ///
651    /// # Errors
652    ///
653    /// Returns a `SteamUserError` if the request fails or the user is not
654    /// logged in.
655    #[allow(clippy::disallowed_methods)] // Utility on client itself, not a service endpoint.
656    pub async fn get_client_js_token(&self) -> Result<ClientJsToken, SteamUserError> {
657        let url = "https://steamcommunity.com/chat/clientjstoken";
658        let response = self.get(url).send().await?;
659        self.check_response(&response)?;
660
661        let token: ClientJsToken = response.json().await?;
662
663        if !token.logged_in {
664            return Err(SteamUserError::NotLoggedIn);
665        }
666
667        Ok(token)
668    }
669
670    // ========================================================================
671    // Internal HTTP helpers
672    // ========================================================================
673
674    /// Check response for common errors.
675    pub(crate) fn check_response(&self, response: &Response) -> Result<(), SteamUserError> {
676        let status = response.status();
677
678        // Detect login redirects that were auto-followed by reqwest.
679        // The final status is 200 but the URL reveals we landed on the login page.
680        // Strict host match (apex or true subdomain) prevents lookalikes like `evil-steamcommunity.com`.
681        // Note: reqwest follows 30x by default, so a 302→/login arrives here as 200; the legacy
682        // `is_redirection()` branch below would be dead code if we relied only on status.
683        let url = response.url();
684        let on_steam_host = url.host_str().is_some_and(|h| {
685            h == "steamcommunity.com" || h.ends_with(".steamcommunity.com") || h == "steampowered.com" || h.ends_with(".steampowered.com")
686        });
687        let path = url.path();
688        if on_steam_host && (path == "/login" || path.starts_with("/login/")) {
689            return Err(SteamUserError::NotLoggedIn);
690        }
691
692        if status.is_success() {
693            return Ok(());
694        }
695
696        if status == StatusCode::FORBIDDEN {
697            return Err(SteamUserError::FamilyViewRestricted);
698        }
699
700        if status.is_client_error() || status.is_server_error() {
701            let url = response.url().to_string();
702            return Err(SteamUserError::HttpStatus { status: status.as_u16(), url });
703        }
704
705        Ok(())
706    }
707
708    /// Check if a JSON response indicates success.
709    pub(crate) fn check_json_success<'a>(json: &'a serde_json::Value, error_msg: &str) -> Result<&'a serde_json::Value, SteamUserError> {
710        if let Some(success) = json.get("success") {
711            if let Some(b) = success.as_bool() {
712                if b {
713                    return Ok(json);
714                }
715            } else if let Some(i) = success.as_i64() {
716                if i == 1 {
717                    return Ok(json);
718                }
719            }
720        }
721
722        if let Some(eresult) = json.get("eresult").and_then(|v| v.as_i64()) {
723            if eresult != 1 {
724                return Err(SteamUserError::from_eresult(eresult as i32));
725            }
726        }
727
728        Err(SteamUserError::SteamError(error_msg.to_string()))
729    }
730}
731
732/// A wrapper around request builder that automatically appends
733/// the session ID to POST data.
734pub struct SteamRequestBuilder {
735    builder: reqwest_middleware::RequestBuilder,
736    session_id: Option<String>,
737    session_limiter: Option<Arc<crate::limiter::SteamRateLimiter>>,
738    request_body: Option<serde_json::Value>,
739    query: Option<serde_json::Value>,
740    http_method: reqwest::Method,
741}
742
743impl SteamRequestBuilder {
744    /// Adds a form body to the request.
745    ///
746    /// If a session ID is available, it is automatically injected as
747    /// `sessionid` and `sessionID` into the form data.
748    pub fn form<T: serde::Serialize + ?Sized>(mut self, form: &T) -> Self {
749        if let Some(session_id) = &self.session_id {
750            // We need to merge the session ID into the form.
751            // This is tricky because T is generic.
752            // We convert to a Value first.
753            if let Ok(mut value) = serde_json::to_value(form) {
754                if let Some(obj) = value.as_object_mut() {
755                    obj.insert("sessionid".to_string(), serde_json::Value::String(session_id.clone()));
756                    obj.insert("sessionID".to_string(), serde_json::Value::String(session_id.clone()));
757                    self.request_body = Some(serde_json::Value::Object(obj.clone()));
758                    self.builder = self.builder.form(&value);
759                    return self;
760                } else if let Some(arr) = value.as_array() {
761                    // Handle array of tuples [["key", "val"], ...] or [("key", "val"), ...]
762                    // Convert to a map for proper form serialization
763                    let mut map = serde_json::Map::new();
764                    for item in arr {
765                        if let Some(tuple_arr) = item.as_array() {
766                            if tuple_arr.len() == 2 {
767                                if let (Some(key), Some(val)) = (tuple_arr[0].as_str(), tuple_arr[1].as_str()) {
768                                    map.insert(key.to_string(), serde_json::Value::String(val.to_string()));
769                                }
770                            }
771                        }
772                    }
773                    map.insert("sessionid".to_string(), serde_json::Value::String(session_id.clone()));
774                    map.insert("sessionID".to_string(), serde_json::Value::String(session_id.clone()));
775                    self.request_body = Some(serde_json::Value::Object(map.clone()));
776                    self.builder = self.builder.form(&serde_json::Value::Object(map));
777                    return self;
778                }
779            }
780        }
781        self.request_body = serde_json::to_value(form).ok();
782        self.builder = self.builder.form(form);
783        self
784    }
785
786    /// Adds a multipart form body to the request.
787    ///
788    /// If a session ID is available, it is automatically appended to the
789    /// multipart data.
790    pub fn multipart(mut self, mut form: reqwest::multipart::Form) -> Self {
791        if let Some(session_id) = &self.session_id {
792            form = form.text("sessionid", session_id.clone()).text("sessionID", session_id.clone());
793        }
794        self.builder = self.builder.multipart(form);
795        self
796    }
797
798    /// Adds a query parameter to the request.
799    pub fn query<T: serde::Serialize + ?Sized>(mut self, query: &T) -> Self {
800        if let Ok(value) = serde_json::to_value(query) {
801            if let Some(ref mut existing) = self.query {
802                if let (Some(e_obj), Some(n_obj)) = (existing.as_object_mut(), value.as_object()) {
803                    for (k, v) in n_obj {
804                        e_obj.insert(k.clone(), v.clone());
805                    }
806                } else {
807                    self.query = Some(value);
808                }
809            } else {
810                self.query = Some(value);
811            }
812        }
813        self.builder = self.builder.query(query);
814        self
815    }
816
817    /// Adds a header to the request.
818    pub fn header<K, V>(mut self, key: K, value: V) -> Self
819    where
820        reqwest::header::HeaderName: TryFrom<K>,
821        <reqwest::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
822        reqwest::header::HeaderValue: TryFrom<V>,
823        <reqwest::header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
824    {
825        self.builder = self.builder.header(key, value);
826        self
827    }
828
829    /// Sends the request and returns the response.
830    pub async fn send(self) -> Result<reqwest::Response, reqwest_middleware::Error> {
831        // Record the HTTP method on the current tracing span so the
832        // `MongoLogLayer` can pick it up from the span tree.
833        tracing::Span::current().record("http_method", self.http_method.as_str());
834
835        // Resolve the active endpoint metadata (set by #[steam_endpoint]).
836        // `None` is fine — utility methods like `logged_in` and
837        // `renew_access_token` issue HTTP without going through an annotated
838        // method; they fall back to global rate limiting only.
839        let endpoint = crate::endpoint::current_endpoint();
840
841        // 1. Per-session limit (sequential — bounded to this caller, never blocks others)
842        if let Some(limiter) = &self.session_limiter {
843            limiter.until_ready().await;
844        }
845
846        // 2. Acquire global permit first, then per-host permit sequentially.
847        //    Acquiring them concurrently via tokio::join! wastes the host
848        //    permit when the global limiter is mid-lockout (the host permit
849        //    is consumed immediately, then we wait on global anyway).
850        crate::limiter::wait_for_permit().await;
851        if let Some(ep) = endpoint {
852            crate::limiter::wait_for_host_permit(ep.host).await;
853        }
854
855        // Send request
856        let response = self.builder.send().await?;
857
858        let status = response.status();
859        let url = response.url().clone();
860        let headers = response.headers().clone();
861
862        // Record the URL on the current span for the MongoLogLayer,
863        // stripping sensitive query parameters.
864        let safe_url = redact_url_params(&url);
865        tracing::Span::current().record("url", safe_url.as_str());
866
867        tracing::info!(status = %status, url = %safe_url, "steam response");
868
869        // Bump the metric counter once per request. Deliberately not
870        // status-code-aware in v1 — that lives on the tracing layer.
871        if let Some(ep) = endpoint {
872            crate::endpoint::metrics().record_call(ep);
873        }
874
875        // 4. Handle 429 Reactive Backoff (before consuming the body).
876        //    Read Retry-After; accept delta-seconds and HTTP-date formats.
877        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
878            let retry_after = headers
879                .get(reqwest::header::RETRY_AFTER)
880                .and_then(|v| v.to_str().ok())
881                .and_then(|s| {
882                    // Try delta-seconds first
883                    if let Ok(secs) = s.trim().parse::<u64>() {
884                        return Some(std::time::Duration::from_secs(secs));
885                    }
886                    // Try HTTP-date format (RFC 7231)
887                    if let Ok(dt) = httpdate::parse_http_date(s) {
888                        let now = std::time::SystemTime::now();
889                        return dt.duration_since(now).ok();
890                    }
891                    None
892                })
893                .unwrap_or_else(|| std::time::Duration::from_secs(60));
894            crate::limiter::penalize_abuse(retry_after);
895        }
896
897        if tracing::enabled!(tracing::Level::DEBUG) {
898            if let Some(ref req_body) = self.request_body {
899                tracing::debug!(body = %req_body, "request body");
900            }
901            if let Some(ref query) = self.query {
902                tracing::debug!(query = %query, "request query");
903            }
904        }
905
906        // Stash the final URL before consuming the response — reqwest::Response::from(http::Response)
907        // does not preserve it, so we capture it here for logging/debugging.
908        let final_url = response.url().clone();
909
910        // Buffer the response body so we can record `raw_response` on the
911        // span for MongoLogLayer, then reconstruct the response for the caller.
912        let version = response.version();
913        let headers = response.headers().clone();
914        let bytes = response.bytes().await?;
915
916        let content_type = headers
917            .get(reqwest::header::CONTENT_TYPE)
918            .and_then(|h| h.to_str().ok())
919            .unwrap_or("");
920
921        // Record response metadata on the span for MongoLogLayer.
922        if !content_type.is_empty() {
923            tracing::Span::current().record("content_type", content_type);
924        }
925        let response_type_str = if content_type.contains("json") {
926            "json"
927        } else if content_type.contains("html") {
928            "html"
929        } else if content_type.contains("xml") {
930            "xml"
931        } else if content_type.contains("javascript") || content_type.contains("text") {
932            "text"
933        } else if content_type.contains("protobuf")
934            || content_type.contains("octet-stream")
935            || content_type.contains("image")
936        {
937            "binary"
938        } else {
939            "unknown"
940        };
941        tracing::Span::current().record("response_type", response_type_str);
942
943        if content_type.contains("text")
944            || content_type.contains("json")
945            || content_type.contains("javascript")
946            || content_type.contains("xml")
947        {
948            let body_str = String::from_utf8_lossy(&bytes);
949            tracing::Span::current().record("raw_response", body_str.as_ref());
950            if tracing::enabled!(tracing::Level::DEBUG) {
951                tracing::debug!(body = %body_str, "response body");
952            }
953        } else if tracing::enabled!(tracing::Level::DEBUG) {
954            tracing::debug!(bytes = bytes.len(), content_type, url = %final_url, "response body (binary)");
955        }
956
957        let mut builder = http::Response::builder().status(status).version(version);
958        for (name, value) in headers.iter() {
959            builder = builder.header(name, value);
960        }
961        let http_resp = builder.body(bytes).map_err(|e| {
962            reqwest_middleware::Error::Middleware(anyhow::anyhow!(
963                "Failed to reconstruct response: {e}"
964            ))
965        })?;
966        Ok(reqwest::Response::from(http_resp))
967    }
968
969    /// Sets the request body.
970    pub fn body<T: Into<reqwest::Body>>(mut self, body: T) -> Self {
971        self.builder = self.builder.body(body);
972        self
973    }
974}
975
976const REDACTED_PARAMS: &[&str] = &["access_token", "key", "oauth_token", "webapi_token"];
977
978fn redact_url_params(url: &reqwest::Url) -> String {
979    if url.query().is_none() {
980        return url.to_string();
981    }
982
983    let has_sensitive = url
984        .query_pairs()
985        .any(|(k, _)| REDACTED_PARAMS.contains(&k.as_ref()));
986
987    if !has_sensitive {
988        return url.to_string();
989    }
990
991    let mut redacted = url.clone();
992    let filtered: Vec<(String, String)> = url
993        .query_pairs()
994        .map(|(k, v)| {
995            if REDACTED_PARAMS.contains(&k.as_ref()) {
996                (k.into_owned(), "[REDACTED]".to_string())
997            } else {
998                (k.into_owned(), v.into_owned())
999            }
1000        })
1001        .collect();
1002
1003    let qs: String = filtered
1004        .iter()
1005        .map(|(k, v)| format!("{k}={v}"))
1006        .collect::<Vec<_>>()
1007        .join("&");
1008    redacted.set_query(Some(&qs));
1009    redacted.to_string()
1010}
1011
1012/// Token response from /chat/clientjstoken endpoint.
1013#[derive(Debug, serde::Deserialize)]
1014pub struct ClientJsToken {
1015    pub logged_in: bool,
1016    pub steamid: Option<String>,
1017    pub account_name: Option<String>,
1018    pub token: Option<String>,
1019}