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        let Some(new_access_token) = response_proto.access_token else {
600            // HTTP 200 but no access token: Steam declined to renew. The
601            // refresh token is no longer renewable (server-side revoked or
602            // expired) even if its JWT `exp` is still in the future. Surface
603            // this as an error so callers re-login instead of silently reusing
604            // the stale token — returning Ok here was a false success that made
605            // renewal crons loop forever on dead accounts.
606            tracing::warn!("Renewal rejected: Steam returned no access token (refresh token no longer renewable)");
607            return Err(SteamUserError::RenewalRejected);
608        };
609        self.session.access_token = Some(new_access_token);
610
611        if let Some(new_refresh_token) = response_proto.refresh_token {
612            self.session.refresh_token = Some(new_refresh_token);
613        }
614
615        Ok(())
616    }
617
618    /// Retrieves authentication session information for a given QR challenge
619    /// URL.
620    ///
621    /// This is used during the QR code login process to get details about the
622    /// pending session.
623    #[tracing::instrument(skip(self))]
624    #[allow(clippy::disallowed_methods)] // Uses raw API endpoint, not community page — rate limiter N/A.
625    pub async fn get_auth_session_info(&self, qr_challenge_url: &str) -> Result<CAuthenticationGetAuthSessionInfoResponse, SteamUserError> {
626        let (client_id, _version) = decode_login_qr_url(qr_challenge_url).ok_or_else(|| SteamUserError::InvalidInput("Invalid QR challenge URL".into()))?;
627
628        let request = CAuthenticationGetAuthSessionInfoRequest { client_id: Some(client_id) };
629
630        let mut body = Vec::new();
631        request.encode(&mut body)?;
632
633        // We need an access token for this request
634        let access_token = self.session.access_token.as_deref().ok_or(SteamUserError::MissingCredential { field: "access_token" })?;
635
636        let params = [("access_token", access_token), ("spoof_steamid", ""), ("origin", "https://store.steampowered.com")];
637
638        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?;
639
640        self.check_response(&response)?;
641
642        let bytes = response.bytes().await?;
643        let response_proto = CAuthenticationGetAuthSessionInfoResponse::decode(bytes)?;
644
645        Ok(response_proto)
646    }
647
648    /// Retrieves the Steam Chat client JS token.
649    ///
650    /// This token is used for authenticating with the Steam Chat WebSocket
651    /// connection. It is fetched from `https://steamcommunity.com/chat/clientjstoken`.
652    ///
653    /// # Returns
654    ///
655    /// Returns a `ClientJsToken` struct if successful.
656    ///
657    /// # Errors
658    ///
659    /// Returns a `SteamUserError` if the request fails or the user is not
660    /// logged in.
661    #[allow(clippy::disallowed_methods)] // Utility on client itself, not a service endpoint.
662    pub async fn get_client_js_token(&self) -> Result<ClientJsToken, SteamUserError> {
663        let url = "https://steamcommunity.com/chat/clientjstoken";
664        let response = self.get(url).send().await?;
665        self.check_response(&response)?;
666
667        let token: ClientJsToken = response.json().await?;
668
669        if !token.logged_in {
670            return Err(SteamUserError::NotLoggedIn);
671        }
672
673        Ok(token)
674    }
675
676    // ========================================================================
677    // Internal HTTP helpers
678    // ========================================================================
679
680    /// Check response for common errors.
681    pub(crate) fn check_response(&self, response: &Response) -> Result<(), SteamUserError> {
682        let status = response.status();
683
684        // Detect login redirects that were auto-followed by reqwest.
685        // The final status is 200 but the URL reveals we landed on the login page.
686        // Strict host match (apex or true subdomain) prevents lookalikes like `evil-steamcommunity.com`.
687        // Note: reqwest follows 30x by default, so a 302→/login arrives here as 200; the legacy
688        // `is_redirection()` branch below would be dead code if we relied only on status.
689        let url = response.url();
690        let on_steam_host = url.host_str().is_some_and(|h| {
691            h == "steamcommunity.com" || h.ends_with(".steamcommunity.com") || h == "steampowered.com" || h.ends_with(".steampowered.com")
692        });
693        let path = url.path();
694        if on_steam_host && (path == "/login" || path.starts_with("/login/")) {
695            return Err(SteamUserError::NotLoggedIn);
696        }
697
698        if status.is_success() {
699            return Ok(());
700        }
701
702        if status == StatusCode::FORBIDDEN {
703            return Err(SteamUserError::FamilyViewRestricted);
704        }
705
706        if status.is_client_error() || status.is_server_error() {
707            let url = response.url().to_string();
708            return Err(SteamUserError::HttpStatus { status: status.as_u16(), url });
709        }
710
711        Ok(())
712    }
713
714    /// Check if a JSON response indicates success.
715    pub(crate) fn check_json_success<'a>(json: &'a serde_json::Value, error_msg: &str) -> Result<&'a serde_json::Value, SteamUserError> {
716        if let Some(success) = json.get("success") {
717            if let Some(b) = success.as_bool() {
718                if b {
719                    return Ok(json);
720                }
721            } else if let Some(i) = success.as_i64() {
722                if i == 1 {
723                    return Ok(json);
724                }
725            }
726        }
727
728        if let Some(eresult) = json.get("eresult").and_then(|v| v.as_i64()) {
729            if eresult != 1 {
730                return Err(SteamUserError::from_eresult(eresult as i32));
731            }
732        }
733
734        Err(SteamUserError::SteamError(error_msg.to_string()))
735    }
736}
737
738/// A wrapper around request builder that automatically appends
739/// the session ID to POST data.
740pub struct SteamRequestBuilder {
741    builder: reqwest_middleware::RequestBuilder,
742    session_id: Option<String>,
743    session_limiter: Option<Arc<crate::limiter::SteamRateLimiter>>,
744    request_body: Option<serde_json::Value>,
745    query: Option<serde_json::Value>,
746    http_method: reqwest::Method,
747}
748
749impl SteamRequestBuilder {
750    /// Adds a form body to the request.
751    ///
752    /// If a session ID is available, it is automatically injected as
753    /// `sessionid` and `sessionID` into the form data.
754    pub fn form<T: serde::Serialize + ?Sized>(mut self, form: &T) -> Self {
755        if let Some(session_id) = &self.session_id {
756            // We need to merge the session ID into the form.
757            // This is tricky because T is generic.
758            // We convert to a Value first.
759            if let Ok(mut value) = serde_json::to_value(form) {
760                if let Some(obj) = value.as_object_mut() {
761                    obj.insert("sessionid".to_string(), serde_json::Value::String(session_id.clone()));
762                    obj.insert("sessionID".to_string(), serde_json::Value::String(session_id.clone()));
763                    self.request_body = Some(serde_json::Value::Object(obj.clone()));
764                    self.builder = self.builder.form(&value);
765                    return self;
766                } else if let Some(arr) = value.as_array() {
767                    // Handle array of tuples [["key", "val"], ...] or [("key", "val"), ...]
768                    // Convert to a map for proper form serialization
769                    let mut map = serde_json::Map::new();
770                    for item in arr {
771                        if let Some(tuple_arr) = item.as_array() {
772                            if tuple_arr.len() == 2 {
773                                if let (Some(key), Some(val)) = (tuple_arr[0].as_str(), tuple_arr[1].as_str()) {
774                                    map.insert(key.to_string(), serde_json::Value::String(val.to_string()));
775                                }
776                            }
777                        }
778                    }
779                    map.insert("sessionid".to_string(), serde_json::Value::String(session_id.clone()));
780                    map.insert("sessionID".to_string(), serde_json::Value::String(session_id.clone()));
781                    self.request_body = Some(serde_json::Value::Object(map.clone()));
782                    self.builder = self.builder.form(&serde_json::Value::Object(map));
783                    return self;
784                }
785            }
786        }
787        self.request_body = serde_json::to_value(form).ok();
788        self.builder = self.builder.form(form);
789        self
790    }
791
792    /// Adds a multipart form body to the request.
793    ///
794    /// If a session ID is available, it is automatically appended to the
795    /// multipart data.
796    pub fn multipart(mut self, mut form: reqwest::multipart::Form) -> Self {
797        if let Some(session_id) = &self.session_id {
798            form = form.text("sessionid", session_id.clone()).text("sessionID", session_id.clone());
799        }
800        self.builder = self.builder.multipart(form);
801        self
802    }
803
804    /// Adds a query parameter to the request.
805    pub fn query<T: serde::Serialize + ?Sized>(mut self, query: &T) -> Self {
806        if let Ok(value) = serde_json::to_value(query) {
807            if let Some(ref mut existing) = self.query {
808                if let (Some(e_obj), Some(n_obj)) = (existing.as_object_mut(), value.as_object()) {
809                    for (k, v) in n_obj {
810                        e_obj.insert(k.clone(), v.clone());
811                    }
812                } else {
813                    self.query = Some(value);
814                }
815            } else {
816                self.query = Some(value);
817            }
818        }
819        self.builder = self.builder.query(query);
820        self
821    }
822
823    /// Adds a header to the request.
824    pub fn header<K, V>(mut self, key: K, value: V) -> Self
825    where
826        reqwest::header::HeaderName: TryFrom<K>,
827        <reqwest::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
828        reqwest::header::HeaderValue: TryFrom<V>,
829        <reqwest::header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
830    {
831        self.builder = self.builder.header(key, value);
832        self
833    }
834
835    /// Sends the request and returns the response.
836    pub async fn send(self) -> Result<reqwest::Response, reqwest_middleware::Error> {
837        // Record the HTTP method on the current tracing span so the
838        // `MongoLogLayer` can pick it up from the span tree.
839        tracing::Span::current().record("http_method", self.http_method.as_str());
840
841        // Resolve the active endpoint metadata (set by #[steam_endpoint]).
842        // `None` is fine — utility methods like `logged_in` and
843        // `renew_access_token` issue HTTP without going through an annotated
844        // method; they fall back to global rate limiting only.
845        let endpoint = crate::endpoint::current_endpoint();
846
847        // 1. Per-session limit (sequential — bounded to this caller, never blocks others)
848        if let Some(limiter) = &self.session_limiter {
849            limiter.until_ready().await;
850        }
851
852        // 2. Acquire global permit first, then per-host permit sequentially.
853        //    Acquiring them concurrently via tokio::join! wastes the host
854        //    permit when the global limiter is mid-lockout (the host permit
855        //    is consumed immediately, then we wait on global anyway).
856        crate::limiter::wait_for_permit().await;
857        if let Some(ep) = endpoint {
858            crate::limiter::wait_for_host_permit(ep.host).await;
859        }
860
861        // Send request
862        let response = self.builder.send().await?;
863
864        let status = response.status();
865        let url = response.url().clone();
866        let headers = response.headers().clone();
867
868        // Record the URL on the current span for the MongoLogLayer,
869        // stripping sensitive query parameters.
870        let safe_url = redact_url_params(&url);
871        tracing::Span::current().record("url", safe_url.as_str());
872
873        tracing::info!(status = %status, url = %safe_url, "steam response");
874
875        // Bump the metric counter once per request. Deliberately not
876        // status-code-aware in v1 — that lives on the tracing layer.
877        if let Some(ep) = endpoint {
878            crate::endpoint::metrics().record_call(ep);
879        }
880
881        // 4. Handle 429 Reactive Backoff (before consuming the body).
882        //    Read Retry-After; accept delta-seconds and HTTP-date formats.
883        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
884            let retry_after = headers
885                .get(reqwest::header::RETRY_AFTER)
886                .and_then(|v| v.to_str().ok())
887                .and_then(|s| {
888                    // Try delta-seconds first
889                    if let Ok(secs) = s.trim().parse::<u64>() {
890                        return Some(std::time::Duration::from_secs(secs));
891                    }
892                    // Try HTTP-date format (RFC 7231)
893                    if let Ok(dt) = httpdate::parse_http_date(s) {
894                        let now = std::time::SystemTime::now();
895                        return dt.duration_since(now).ok();
896                    }
897                    None
898                })
899                .unwrap_or_else(|| std::time::Duration::from_secs(60));
900            crate::limiter::penalize_abuse(retry_after);
901        }
902
903        if tracing::enabled!(tracing::Level::DEBUG) {
904            if let Some(ref req_body) = self.request_body {
905                tracing::debug!(body = %req_body, "request body");
906            }
907            if let Some(ref query) = self.query {
908                tracing::debug!(query = %query, "request query");
909            }
910        }
911
912        // Stash the final URL before consuming the response — reqwest::Response::from(http::Response)
913        // does not preserve it, so we capture it here for logging/debugging.
914        let final_url = response.url().clone();
915
916        // Buffer the response body so we can record `raw_response` on the
917        // span for MongoLogLayer, then reconstruct the response for the caller.
918        let version = response.version();
919        let headers = response.headers().clone();
920        let bytes = response.bytes().await?;
921
922        let content_type = headers
923            .get(reqwest::header::CONTENT_TYPE)
924            .and_then(|h| h.to_str().ok())
925            .unwrap_or("");
926
927        // Record response metadata on the span for MongoLogLayer.
928        if !content_type.is_empty() {
929            tracing::Span::current().record("content_type", content_type);
930        }
931        let response_type_str = if content_type.contains("json") {
932            "json"
933        } else if content_type.contains("html") {
934            "html"
935        } else if content_type.contains("xml") {
936            "xml"
937        } else if content_type.contains("javascript") || content_type.contains("text") {
938            "text"
939        } else if content_type.contains("protobuf")
940            || content_type.contains("octet-stream")
941            || content_type.contains("image")
942        {
943            "binary"
944        } else {
945            "unknown"
946        };
947        tracing::Span::current().record("response_type", response_type_str);
948
949        if content_type.contains("text")
950            || content_type.contains("json")
951            || content_type.contains("javascript")
952            || content_type.contains("xml")
953        {
954            let body_str = String::from_utf8_lossy(&bytes);
955            tracing::Span::current().record("raw_response", body_str.as_ref());
956            if tracing::enabled!(tracing::Level::DEBUG) {
957                tracing::debug!(body = %body_str, "response body");
958            }
959        } else if tracing::enabled!(tracing::Level::DEBUG) {
960            tracing::debug!(bytes = bytes.len(), content_type, url = %final_url, "response body (binary)");
961        }
962
963        let mut builder = http::Response::builder().status(status).version(version);
964        for (name, value) in headers.iter() {
965            builder = builder.header(name, value);
966        }
967        let http_resp = builder.body(bytes).map_err(|e| {
968            reqwest_middleware::Error::Middleware(anyhow::anyhow!(
969                "Failed to reconstruct response: {e}"
970            ))
971        })?;
972        Ok(reqwest::Response::from(http_resp))
973    }
974
975    /// Sets the request body.
976    pub fn body<T: Into<reqwest::Body>>(mut self, body: T) -> Self {
977        self.builder = self.builder.body(body);
978        self
979    }
980}
981
982const REDACTED_PARAMS: &[&str] = &["access_token", "key", "oauth_token", "webapi_token"];
983
984fn redact_url_params(url: &reqwest::Url) -> String {
985    if url.query().is_none() {
986        return url.to_string();
987    }
988
989    let has_sensitive = url
990        .query_pairs()
991        .any(|(k, _)| REDACTED_PARAMS.contains(&k.as_ref()));
992
993    if !has_sensitive {
994        return url.to_string();
995    }
996
997    let mut redacted = url.clone();
998    let filtered: Vec<(String, String)> = url
999        .query_pairs()
1000        .map(|(k, v)| {
1001            if REDACTED_PARAMS.contains(&k.as_ref()) {
1002                (k.into_owned(), "[REDACTED]".to_string())
1003            } else {
1004                (k.into_owned(), v.into_owned())
1005            }
1006        })
1007        .collect();
1008
1009    let qs: String = filtered
1010        .iter()
1011        .map(|(k, v)| format!("{k}={v}"))
1012        .collect::<Vec<_>>()
1013        .join("&");
1014    redacted.set_query(Some(&qs));
1015    redacted.to_string()
1016}
1017
1018/// Token response from /chat/clientjstoken endpoint.
1019#[derive(Debug, serde::Deserialize)]
1020pub struct ClientJsToken {
1021    pub logged_in: bool,
1022    pub steamid: Option<String>,
1023    pub account_name: Option<String>,
1024    pub token: Option<String>,
1025}