Skip to main content

ig_client/application/
auth.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 19/10/25
5******************************************************************************/
6
7//! Authentication module for IG Markets API
8//!
9//! This module provides a simplified authentication interface that handles:
10//! - API v2 (CST/X-SECURITY-TOKEN) authentication
11//! - API v3 (OAuth) authentication with automatic token refresh
12//! - Account switching
13//! - Automatic re-authentication when tokens expire
14
15use crate::application::config::Config;
16use crate::application::http::make_http_request;
17use crate::application::rate_limiter::RateLimiter;
18use crate::constants::USER_AGENT;
19use crate::error::{AppError, AuthError};
20pub(crate) use crate::model::auth::{SecurityHeaders, SessionResponse};
21use crate::model::retry::RetryConfig;
22use reqwest::{Client, Method};
23use std::sync::Arc;
24use tokio::sync::RwLock;
25use tracing::{debug, error, info, warn};
26// `OAuthToken` and `chrono::Utc` are only referenced from the unit tests below
27// (the `Session` data type that used them in production moved to
28// `crate::model::auth`), so they are imported under `cfg(test)` to keep the
29// non-test build free of unused-import warnings.
30#[cfg(test)]
31use crate::model::auth::OAuthToken;
32#[cfg(test)]
33use chrono::Utc;
34
35// `Session` and `WebsocketInfo` are pure data types and live in the model
36// layer (`crate::model::auth`). They are re-exported here so the historical
37// public paths `crate::application::auth::Session` /
38// `crate::application::auth::WebsocketInfo` keep resolving, and so the auth
39// I/O manager below can reference them unqualified.
40pub use crate::model::auth::{Session, WebsocketInfo};
41
42/// Merges freshly-issued v2 security tokens into a session after an account
43/// switch.
44///
45/// IG returns a new `X-SECURITY-TOKEN` (and sometimes a new `CST`) in the
46/// response to `PUT /session`. When a token is present it replaces the stale
47/// one; when absent the existing token is preserved — the switch response does
48/// not always re-issue both, and nulling a token would break the next request.
49/// All other session fields are carried through unchanged.
50#[must_use]
51fn apply_switch_headers(mut session: Session, cst: Option<&str>, xst: Option<&str>) -> Session {
52    if let Some(cst) = cst {
53        session.cst = Some(cst.to_string());
54    }
55    if let Some(xst) = xst {
56        session.x_security_token = Some(xst.to_string());
57    }
58    session
59}
60
61/// Decides whether a v2 login should switch to the configured account.
62///
63/// Returns `true` only when a *real* account was configured (not empty and not
64/// the [`DEFAULT_ACCOUNT_ID`](crate::constants::DEFAULT_ACCOUNT_ID) sentinel) and
65/// it differs from the account the login landed on. OAuth (v3) pins the account
66/// elsewhere and is never switched here. Guarding the sentinel is essential: it
67/// is non-empty, so without this check an unconfigured client would try to
68/// switch to `"default_account_id"`, which IG rejects, failing an otherwise-valid
69/// login.
70#[must_use]
71fn should_switch_account(api_version: u8, configured: &str, current: &str) -> bool {
72    api_version != 3
73        && !configured.is_empty()
74        && configured != crate::constants::DEFAULT_ACCOUNT_ID
75        && configured != current
76}
77
78/// Selects the proactive-refresh safety margin (in seconds) for a session based
79/// on its authentication model.
80///
81/// v3 (OAuth) access tokens are short-lived (~60s), so the v2 margin would keep
82/// them permanently "about to expire" and force a login on every call; a small
83/// [`PROACTIVE_REFRESH_MARGIN_V3_SECS`](crate::constants::PROACTIVE_REFRESH_MARGIN_V3_SECS)
84/// margin is used instead. v2 (CST / X-SECURITY-TOKEN) sessions last ~6h, so the
85/// larger
86/// [`PROACTIVE_REFRESH_MARGIN_V2_SECS`](crate::constants::PROACTIVE_REFRESH_MARGIN_V2_SECS)
87/// margin gives ample lead time.
88///
89/// The *same* margin is used by both [`Auth::get_session`] (to decide a refresh
90/// is due) and [`Auth::refresh_token`] (to actually perform it), so the
91/// proactive-refresh window is consistent and always fires — the previous 300s /
92/// 1s mismatch meant the advertised margin never triggered a refresh.
93#[must_use]
94fn proactive_refresh_margin_secs(session: &Session) -> u64 {
95    if session.is_oauth() {
96        crate::constants::PROACTIVE_REFRESH_MARGIN_V3_SECS
97    } else {
98        crate::constants::PROACTIVE_REFRESH_MARGIN_V2_SECS
99    }
100}
101
102/// Ensures a v3 (OAuth) login actually produced an OAuth session.
103///
104/// The v3 `/session` endpoint is expected to return an OAuth body. Because
105/// [`SessionResponse`] is untagged, a v2-shaped body sent to the v3 request
106/// still deserializes successfully — as a v2 session carrying no OAuth token.
107/// That is a server-side / protocol mismatch, not a client bug. On the reactive
108/// [`force_refresh`](Auth::force_refresh) -> [`login`](Auth::login) path a 401
109/// reaches this code more often, so a mismatched body must surface as a typed
110/// error rather than panic.
111///
112/// The offending response body / token is never logged.
113///
114/// # Errors
115/// Returns [`AppError::Unauthorized`] when the session lacks an OAuth token.
116fn ensure_oauth_session(session: Session) -> Result<Session, AppError> {
117    if session.is_oauth() {
118        Ok(session)
119    } else {
120        error!("v3 login response did not contain an OAuth token");
121        Err(AppError::Unauthorized)
122    }
123}
124
125/// Authentication manager for IG Markets API
126///
127/// Handles all authentication operations including:
128/// - Login with API v2 or v3
129/// - Automatic OAuth token refresh
130/// - Account switching
131/// - Session management
132/// - Rate limiting for API requests
133pub struct Auth {
134    config: Arc<Config>,
135    client: Client,
136    session: Arc<RwLock<Option<Session>>>,
137    // `RateLimiter` is `Clone` and already wraps each governor bucket in an
138    // `Arc`, so it is shared directly without an outer `RwLock`: the limiter is
139    // configured once at construction and never write-swapped.
140    rate_limiter: RateLimiter,
141}
142
143impl Auth {
144    /// Creates a new Auth instance, returning an error if the HTTP client
145    /// cannot be constructed.
146    ///
147    /// This is the sole constructor for [`Auth`]: it surfaces a TLS /
148    /// client-builder failure as a typed [`AppError`] instead of panicking, so
149    /// callers can handle a broken TLS backend gracefully.
150    ///
151    /// # Arguments
152    /// * `config` - Configuration containing credentials and API settings
153    ///
154    /// # Errors
155    /// Returns [`AppError::Network`] if the underlying `reqwest` client cannot
156    /// be built (e.g. the system TLS backend fails to initialize).
157    pub fn try_new(config: Arc<Config>) -> Result<Self, AppError> {
158        let client = Client::builder().user_agent(USER_AGENT).build()?;
159
160        let rate_limiter = RateLimiter::new(&config.rate_limiter);
161
162        Ok(Self {
163            config,
164            client,
165            session: Arc::new(RwLock::new(None)),
166            rate_limiter,
167        })
168    }
169
170    /// Gets WebSocket connection information for Lightstreamer, reusing the
171    /// cached session.
172    ///
173    /// This calls [`Auth::get_session`], which returns the cached session when
174    /// it is still valid and only logs in (storing the new session) when none
175    /// exists or it has expired. The configured API version is honoured — no
176    /// per-call v2 re-login is performed.
177    ///
178    /// # Returns
179    /// * `Ok(WebsocketInfo)` - Endpoint and authentication tokens for the
180    ///   current session.
181    /// * `Err(AppError)` - If session retrieval (login / refresh) fails.
182    ///
183    /// # Errors
184    /// Returns [`AppError`] when the session cannot be retrieved (login or token
185    /// refresh failure).
186    pub async fn ws_info(&self) -> Result<WebsocketInfo, AppError> {
187        let session = self.get_session().await?;
188        Ok(session.get_websocket_info())
189    }
190
191    /// Gets the WebSocket password for Lightstreamer authentication
192    ///
193    /// # Returns
194    /// * WebSocket password in format "CST-{cst}|XST-{token}" or empty string if session is not available
195    #[deprecated(
196        note = "use ws_info() which reuses the cached session and returns a typed error instead of a default-on-error WebsocketInfo"
197    )]
198    pub async fn get_ws_info(&self) -> WebsocketInfo {
199        self.ws_info().await.unwrap_or_default()
200    }
201
202    /// Gets the current session, ensuring tokens are valid
203    ///
204    /// This method automatically refreshes expired OAuth tokens or re-authenticates if needed.
205    ///
206    /// # Returns
207    /// * `Ok(Session)` - Valid session with fresh tokens
208    /// * `Err(AppError)` - If authentication fails
209    pub async fn get_session(&self) -> Result<Session, AppError> {
210        let session = self.session.read().await;
211
212        if let Some(sess) = session.as_ref() {
213            // Refresh proactively once the session enters its refresh margin. The
214            // margin is derived from the session type so it matches the one
215            // `refresh_token` re-checks with — otherwise the refresh detour would
216            // hand back the same near-expired session (the old 300s/1s mismatch).
217            let margin = proactive_refresh_margin_secs(sess);
218            if sess.needs_token_refresh(Some(margin)) {
219                drop(session); // Release read lock
220                debug!(margin_secs = margin, "session within refresh margin");
221                return self.refresh_token().await;
222            }
223            return Ok(sess.clone());
224        }
225
226        drop(session);
227
228        // No session exists, need to login
229        info!("No active session, logging in");
230        self.login().await
231    }
232
233    /// Performs initial login to IG Markets API
234    ///
235    /// Automatically detects API version from config and uses appropriate authentication method.
236    ///
237    /// # Returns
238    /// * `Ok(Session)` - Authenticated session
239    /// * `Err(AppError)` - If login fails
240    pub async fn login(&self) -> Result<Session, AppError> {
241        let api_version = self.config.api_version.unwrap_or(2);
242
243        debug!("Logging in with API v{}", api_version);
244
245        let session = if api_version == 3 {
246            self.login_oauth().await?
247        } else {
248            self.login_v2().await?
249        };
250
251        // Store session. The write guard is scoped so it is released before any
252        // account-selection switch below: `switch_account` reads `self.session`
253        // via `get_session`, and holding the guard across that call would
254        // deadlock.
255        {
256            let mut sess = self.session.write().await;
257            *sess = Some(session.clone());
258        }
259
260        info!("✓ Login successful, account: {}", session.account_id);
261
262        // v2 account selection: the v2 `/session` response reports IG's
263        // current/default account, which may differ from the configured one.
264        // Switch to the configured account when it is set and differs. This
265        // cannot recurse: the session was just stored above, so
266        // `switch_account` -> `get_session` returns the cached (valid) session
267        // and never triggers another login. OAuth (v3) already pins the account
268        // in `login_oauth`, so it is skipped here.
269        if api_version != 3 {
270            let configured = self.config.credentials.account_id.clone();
271            if should_switch_account(api_version, &configured, &session.account_id) {
272                info!("selecting configured account after v2 login");
273                // `Box::pin` breaks the compile-time async recursion cycle
274                // (login -> switch_account -> get_session -> login). The cycle
275                // is runtime-bounded: the session was stored above, so
276                // `get_session` returns it without logging in again.
277                return Box::pin(self.switch_account(&configured, None)).await;
278            }
279        }
280
281        Ok(session)
282    }
283
284    /// Performs login using API v2 (CST/X-SECURITY-TOKEN) with automatic retry on rate limit
285    async fn login_v2(&self) -> Result<Session, AppError> {
286        let url = format!("{}/session", self.config.rest_api.base_url);
287
288        let body = serde_json::json!({
289            "identifier": self.config.credentials.username,
290            "password": self.config.credentials.password,
291        });
292
293        debug!("Sending v2 login request to: {}", url);
294
295        let headers = vec![
296            ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
297            ("Content-Type", "application/json"),
298            ("Version", "2"),
299        ];
300
301        let response = make_http_request(
302            &self.client,
303            &self.rate_limiter,
304            Method::POST,
305            &url,
306            headers,
307            &Some(body),
308            RetryConfig::default(),
309        )
310        .await?;
311
312        // Extract CST and X-SECURITY-TOKEN from headers
313        let cst: String = match response
314            .headers()
315            .get("CST")
316            .and_then(|v| v.to_str().ok())
317            .map(String::from)
318        {
319            Some(token) => token,
320            None => {
321                // A rejected / malformed auth response, not bad caller input:
322                // surface a typed auth error naming the missing header.
323                error!("missing cst header in login response");
324                return Err(AuthError::MissingSessionToken("cst".to_string()).into());
325            }
326        };
327        let x_security_token: String = match response
328            .headers()
329            .get("X-SECURITY-TOKEN")
330            .and_then(|v| v.to_str().ok())
331            .map(String::from)
332        {
333            Some(token) => token,
334            None => {
335                // A rejected / malformed auth response, not bad caller input:
336                // surface a typed auth error naming the missing header.
337                error!("missing x-security-token header in login response");
338                return Err(AuthError::MissingSessionToken("x-security-token".to_string()).into());
339            }
340        };
341
342        let x_ig_api_key: String = response
343            .headers()
344            .get("X-IG-API-KEY")
345            .and_then(|v| v.to_str().ok())
346            .map(String::from)
347            .unwrap_or_else(|| self.config.credentials.api_key.clone());
348
349        let security_headers: SecurityHeaders = SecurityHeaders {
350            cst,
351            x_security_token,
352            x_ig_api_key,
353        };
354
355        // Get response body as text first for debugging
356        let body_text = response.text().await.map_err(|e| {
357            error!("Failed to read response body: {}", e);
358            AppError::Network(e)
359        })?;
360        debug!("Login response body length: {} bytes", body_text.len());
361
362        // Parse the JSON
363        let mut response: SessionResponse = serde_json::from_str(&body_text).map_err(|e| {
364            // Never log the body: the `/session` response carries credentials.
365            error!(
366                endpoint = %url,
367                body_len = body_text.len(),
368                "failed to parse login response: {}",
369                e
370            );
371            AppError::Deserialization(format!("Failed to parse login response: {}", e))
372        })?;
373        let session = response.get_session_v2(&security_headers);
374
375        Ok(session)
376    }
377
378    /// Performs login using API v3 (OAuth) with automatic retry on rate limit
379    async fn login_oauth(&self) -> Result<Session, AppError> {
380        let url = format!("{}/session", self.config.rest_api.base_url);
381
382        let body = serde_json::json!({
383            "identifier": self.config.credentials.username,
384            "password": self.config.credentials.password,
385        });
386
387        debug!("Sending OAuth login request to: {}", url);
388        let headers = vec![
389            ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
390            ("Content-Type", "application/json"),
391            ("Version", "3"),
392        ];
393
394        let response = make_http_request(
395            &self.client,
396            &self.rate_limiter,
397            Method::POST,
398            &url,
399            headers,
400            &Some(body),
401            RetryConfig::default(),
402        )
403        .await?;
404
405        let response: SessionResponse = response.json().await?;
406        let mut session = response.get_session();
407        if session.account_id != self.config.credentials.account_id {
408            session.account_id = self.config.credentials.account_id.clone();
409        };
410
411        // A v2-shaped body on the v3 path (server-side mismatch) must not panic;
412        // surface it as a typed error instead.
413        ensure_oauth_session(session)
414    }
415
416    /// Proactively refreshes the session when it is within its refresh margin.
417    ///
418    /// This is the *proactive* path (driven by the local clock). It re-checks the
419    /// cached session against the same margin
420    /// [`get_session`](Self::get_session) used to decide a refresh was due, so
421    /// the two stay consistent and the refresh actually fires when the session is
422    /// close to expiry. If the session is still comfortably valid it is returned
423    /// unchanged; otherwise a full [`login`](Self::login) is performed.
424    ///
425    /// For the reactive 401 / server-side-invalidation path — where the local
426    /// clock still considers the token valid but IG has already rejected it — use
427    /// [`force_refresh`](Self::force_refresh), which re-authenticates
428    /// unconditionally.
429    ///
430    /// # Returns
431    /// * `Ok(Session)` - A valid session (refreshed if it was within margin).
432    /// * `Err(AppError)` - If re-authentication fails.
433    ///
434    /// # Errors
435    /// Returns [`AppError`] when a required login fails (network, credentials, or
436    /// rate limiting).
437    pub async fn refresh_token(&self) -> Result<Session, AppError> {
438        let current_session = {
439            let session = self.session.read().await;
440            session.clone()
441        };
442
443        if let Some(sess) = current_session {
444            // Honour the SAME margin `get_session` used to route here, so a
445            // session inside the proactive window is actually re-authenticated
446            // instead of being handed back near-expired.
447            let margin = proactive_refresh_margin_secs(&sess);
448            if sess.is_expired(Some(margin)) {
449                debug!(
450                    margin_secs = margin,
451                    "session within refresh margin, logging in"
452                );
453                self.login().await
454            } else {
455                Ok(sess)
456            }
457        } else {
458            warn!("No session to refresh, performing login");
459            self.login().await
460        }
461    }
462
463    /// Forces a fresh re-authentication regardless of local expiry state.
464    ///
465    /// This is the reactive 401 / server-side-invalidation path. When IG rejects
466    /// a token that the local clock still considers valid (server-side
467    /// invalidation, a concurrent login elsewhere, or clock skew), a proactive
468    /// [`refresh_token`](Self::refresh_token) would see a "valid" session and
469    /// hand back the *same* stale token, so the replayed request would fail
470    /// again. `force_refresh` ignores local expiry and performs a full
471    /// [`login`](Self::login), which fetches and stores a brand-new session.
472    ///
473    /// It cannot loop back through the 401 handler: [`login`](Self::login) issues
474    /// its HTTP requests through
475    /// [`make_http_request`] directly, not
476    /// through the [`HttpClient`](crate::application::http::HttpClient) refresh-and-replay
477    /// path, so a 401 encountered *during* login surfaces as a typed error rather
478    /// than recursing into `force_refresh`.
479    ///
480    /// # Returns
481    /// * `Ok(Session)` - A freshly authenticated session with new tokens.
482    /// * `Err(AppError)` - If re-authentication fails.
483    ///
484    /// # Errors
485    /// Returns [`AppError`] when the login request fails (network, credentials,
486    /// or rate limiting).
487    pub async fn force_refresh(&self) -> Result<Session, AppError> {
488        debug!("forcing re-authentication, ignoring local expiry");
489        self.login().await
490    }
491
492    /// Switches to a different trading account
493    ///
494    /// # Arguments
495    /// * `account_id` - The account ID to switch to
496    /// * `default_account` - Whether to set as default account
497    ///
498    /// # Returns
499    /// * `Ok(Session)` - New session for the switched account
500    /// * `Err(AppError)` - If account switch fails
501    pub async fn switch_account(
502        &self,
503        account_id: &str,
504        default_account: Option<bool>,
505    ) -> Result<Session, AppError> {
506        let current_session = self.get_session().await?;
507        if matches!(current_session.api_version, 3) {
508            return Err(AppError::InvalidInput(
509                "Cannot switch accounts with OAuth".to_string(),
510            ));
511        }
512
513        if current_session.account_id == account_id {
514            debug!("Already on account {}", account_id);
515            return Ok(current_session);
516        }
517
518        info!("Switching to account: {}", account_id);
519
520        let url = format!("{}/session", self.config.rest_api.base_url);
521
522        let mut body = serde_json::json!({
523            "accountId": account_id,
524        });
525
526        if let Some(default) = default_account {
527            body["defaultAccount"] = serde_json::json!(default);
528        }
529
530        // Build headers with authentication. Only the v2 (CST /
531        // X-SECURITY-TOKEN) path is reachable here: OAuth sessions
532        // (`api_version == 3`) are rejected above, so no `Authorization: Bearer`
533        // branch is needed.
534        let api_key = self.config.credentials.api_key.clone();
535        let cst;
536        let x_security_token;
537
538        let mut headers = vec![
539            ("X-IG-API-KEY", api_key.as_str()),
540            ("Content-Type", "application/json"),
541            ("Version", "1"),
542        ];
543
544        if let Some(cst_val) = &current_session.cst {
545            cst = cst_val.clone();
546            headers.push(("CST", cst.as_str()));
547        }
548        if let Some(token_val) = &current_session.x_security_token {
549            x_security_token = token_val.clone();
550            headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
551        }
552
553        let response = make_http_request(
554            &self.client,
555            &self.rate_limiter,
556            Method::PUT,
557            &url,
558            headers,
559            &Some(body),
560            RetryConfig::default(),
561        )
562        .await?;
563
564        // IG re-issues the X-SECURITY-TOKEN (and sometimes CST) in the switch
565        // response. Read them from the headers and merge them in; a missing
566        // header keeps the existing token rather than nulling it.
567        let new_cst = response
568            .headers()
569            .get("CST")
570            .and_then(|v| v.to_str().ok())
571            .map(String::from);
572        let new_x_security_token = response
573            .headers()
574            .get("X-SECURITY-TOKEN")
575            .and_then(|v| v.to_str().ok())
576            .map(String::from);
577
578        // IG re-issues X-SECURITY-TOKEN on a successful switch. Its absence on a
579        // 2xx response is anomalous (proxy stripping / response-shape drift): the
580        // old token is kept below, but flag it since it will 401 the next call.
581        if new_x_security_token.is_none() {
582            warn!("switch response carried no X-SECURITY-TOKEN; keeping the previous token");
583        }
584
585        // After switching, update the session with the fresh tokens and the new
586        // account id.
587        let mut new_session = apply_switch_headers(
588            current_session.clone(),
589            new_cst.as_deref(),
590            new_x_security_token.as_deref(),
591        );
592        new_session.account_id = account_id.to_string();
593
594        {
595            let mut session = self.session.write().await;
596            *session = Some(new_session.clone());
597        }
598
599        info!("✓ Switched to account: {}", account_id);
600        Ok(new_session)
601    }
602
603    /// Logs out and clears the current session.
604    ///
605    /// Before clearing local state, this issues `DELETE /session` (IG API
606    /// Version 1) with the current authentication headers. For a **v2** session
607    /// (CST / X-SECURITY-TOKEN) this invalidates the session server-side. For a
608    /// **v3 / OAuth** session there is no IG token-revocation endpoint and the
609    /// short-lived access token has usually already expired, so `DELETE /session`
610    /// is best-effort: logout clears local state and the OAuth tokens lapse on
611    /// their own expiry rather than being actively revoked. A `401 Unauthorized`
612    /// (or an already-invalid OAuth token) is treated as already-logged-out and
613    /// reported as success.
614    ///
615    /// The local session is always cleared, even when the server-side call
616    /// fails, so the client is never wedged in an authenticated-but-unusable
617    /// state; the underlying failure is still returned as a typed error.
618    ///
619    /// # Errors
620    /// Returns [`AppError`] when the server-side logout request fails for a
621    /// reason other than an already-invalid session (401). The local session is
622    /// cleared regardless.
623    pub async fn logout(&self) -> Result<(), AppError> {
624        info!("Logging out");
625
626        // Snapshot the current session without holding the lock across the HTTP
627        // call. With no session there is nothing to revoke server-side.
628        let current_session = {
629            let session = self.session.read().await;
630            session.clone()
631        };
632
633        let server_result = match current_session {
634            Some(sess) => self.revoke_session(&sess).await,
635            None => Ok(()),
636        };
637
638        // Always clear local state so the client is never wedged, regardless of
639        // the server-side outcome.
640        {
641            let mut session = self.session.write().await;
642            *session = None;
643        }
644
645        match server_result {
646            Ok(()) => {
647                info!("✓ Logged out successfully");
648                Ok(())
649            }
650            Err(e) => {
651                // The error type carries no secrets (see `AppError`), so it is
652                // safe to log; local state has already been cleared.
653                error!("server-side logout failed: {}", e);
654                Err(e)
655            }
656        }
657    }
658
659    /// Issues `DELETE /session` (IG API Version 1) to terminate the session
660    /// server-side.
661    ///
662    /// A `401 Unauthorized` (or an already-invalid OAuth token) means the
663    /// session is no longer valid server-side and is treated as success.
664    ///
665    /// # Errors
666    /// Returns [`AppError`] if the request fails for any reason other than an
667    /// already-invalid session.
668    async fn revoke_session(&self, session: &Session) -> Result<(), AppError> {
669        let url = format!("{}/session", self.config.rest_api.base_url);
670        let api_key = self.config.credentials.api_key.clone();
671
672        let auth_header_value;
673        let cst;
674        let x_security_token;
675
676        let mut headers = vec![
677            ("X-IG-API-KEY", api_key.as_str()),
678            ("Content-Type", "application/json"),
679            ("Version", "1"),
680        ];
681
682        if let Some(oauth) = &session.oauth_token {
683            auth_header_value = format!("Bearer {}", oauth.access_token);
684            headers.push(("Authorization", auth_header_value.as_str()));
685            headers.push(("IG-ACCOUNT-ID", session.account_id.as_str()));
686        } else {
687            if let Some(cst_val) = &session.cst {
688                cst = cst_val.clone();
689                headers.push(("CST", cst.as_str()));
690            }
691            if let Some(token_val) = &session.x_security_token {
692                x_security_token = token_val.clone();
693                headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
694            }
695        }
696
697        match make_http_request(
698            &self.client,
699            &self.rate_limiter,
700            Method::DELETE,
701            &url,
702            headers,
703            &None::<()>,
704            RetryConfig::default(),
705        )
706        .await
707        {
708            Ok(_) => Ok(()),
709            // A 401 (or an expired OAuth token) means the session is already
710            // invalid server-side: the logout goal is met.
711            Err(AppError::Unauthorized | AppError::OAuthTokenExpired) => {
712                debug!("session already invalid server-side; treating as logged out");
713                Ok(())
714            }
715            Err(e) => Err(e),
716        }
717    }
718}
719
720#[cfg(test)]
721mod session_lifecycle_tests {
722    use super::*;
723
724    fn v2_session(cst: Option<&str>, xst: Option<&str>) -> Session {
725        Session {
726            account_id: "ACC-OLD".to_string(),
727            client_id: "CLIENT1".to_string(),
728            lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
729            cst: cst.map(String::from),
730            x_security_token: xst.map(String::from),
731            oauth_token: None,
732            api_version: 2,
733            expires_at: 123,
734        }
735    }
736
737    #[test]
738    fn test_apply_switch_headers_replaces_xst_and_keeps_other_fields() {
739        let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
740        // A switch response that only re-issues the X-SECURITY-TOKEN.
741        let updated = apply_switch_headers(session, None, Some("NEW-XST"));
742
743        assert_eq!(updated.x_security_token.as_deref(), Some("NEW-XST"));
744        // CST is preserved when the header is absent.
745        assert_eq!(updated.cst.as_deref(), Some("OLD-CST"));
746        // Unrelated fields carry through unchanged.
747        assert_eq!(updated.account_id, "ACC-OLD");
748        assert_eq!(updated.expires_at, 123);
749        assert_eq!(updated.api_version, 2);
750    }
751
752    #[test]
753    fn test_apply_switch_headers_updates_both_tokens() {
754        let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
755        let updated = apply_switch_headers(session, Some("NEW-CST"), Some("NEW-XST"));
756
757        assert_eq!(updated.cst.as_deref(), Some("NEW-CST"));
758        assert_eq!(updated.x_security_token.as_deref(), Some("NEW-XST"));
759    }
760
761    #[test]
762    fn test_apply_switch_headers_none_preserves_existing_tokens() {
763        let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
764        // A switch response carrying neither header must not null the tokens.
765        let updated = apply_switch_headers(session, None, None);
766
767        assert_eq!(updated.cst.as_deref(), Some("OLD-CST"));
768        assert_eq!(updated.x_security_token.as_deref(), Some("OLD-XST"));
769    }
770
771    #[test]
772    fn test_should_switch_account_guards_the_sentinel_and_v3() {
773        use crate::constants::DEFAULT_ACCOUNT_ID;
774
775        // Real configured account that differs from the current one: switch.
776        assert!(should_switch_account(2, "ACC-B", "ACC-A"));
777        // Unconfigured default sentinel: must NOT switch (would fail login).
778        assert!(!should_switch_account(2, DEFAULT_ACCOUNT_ID, "ACC-A"));
779        // Empty configured account: must not switch.
780        assert!(!should_switch_account(2, "", "ACC-A"));
781        // Already on the configured account: no switch needed.
782        assert!(!should_switch_account(2, "ACC-A", "ACC-A"));
783        // OAuth (v3) is never switched through this path.
784        assert!(!should_switch_account(3, "ACC-B", "ACC-A"));
785    }
786
787    #[tokio::test]
788    async fn test_ws_info_uses_cached_session_without_login() {
789        let auth =
790            Auth::try_new(Arc::new(Config::default())).expect("auth construction should succeed");
791
792        // Seed a valid (non-expired) v2 session with known tokens. Because the
793        // session is valid, `ws_info` -> `get_session` must return it without a
794        // network login (a login would require real credentials and fail).
795        let expires_at = (Utc::now().timestamp() + 3600) as u64;
796        let seeded = Session {
797            account_id: "ACC123".to_string(),
798            client_id: "CLIENT1".to_string(),
799            lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
800            cst: Some("CST-TOKEN".to_string()),
801            x_security_token: Some("XST-TOKEN".to_string()),
802            oauth_token: None,
803            api_version: 2,
804            expires_at,
805        };
806        {
807            let mut guard = auth.session.write().await;
808            *guard = Some(seeded);
809        }
810
811        let ws = match auth.ws_info().await {
812            Ok(ws) => ws,
813            Err(e) => panic!("ws_info should return Ok for a cached session: {e}"),
814        };
815
816        // The returned info derives from the cached session's tokens, proving no
817        // fresh login occurred.
818        assert_eq!(ws.account_id, "ACC123");
819        assert_eq!(ws.cst.as_deref(), Some("CST-TOKEN"));
820        assert_eq!(ws.x_security_token.as_deref(), Some("XST-TOKEN"));
821        assert!(ws.server.contains("demo-apd.marketdatasystems.com"));
822    }
823}
824
825#[cfg(test)]
826mod expiry_and_refresh_tests {
827    use super::*;
828
829    fn v2_session(expires_at: u64) -> Session {
830        Session {
831            account_id: "ACC123".to_string(),
832            client_id: "CLIENT1".to_string(),
833            lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
834            cst: Some("CST-TOKEN".to_string()),
835            x_security_token: Some("XST-TOKEN".to_string()),
836            oauth_token: None,
837            api_version: 2,
838            expires_at,
839        }
840    }
841
842    fn v3_session(expires_at: u64) -> Session {
843        Session {
844            account_id: "ACC123".to_string(),
845            client_id: "CLIENT1".to_string(),
846            lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
847            cst: None,
848            x_security_token: None,
849            oauth_token: Some(OAuthToken {
850                access_token: "ACCESS".to_string(),
851                refresh_token: "REFRESH".to_string(),
852                scope: "read write".to_string(),
853                token_type: "Bearer".to_string(),
854                expires_in: "60".to_string(),
855                created_at: Utc::now(),
856            }),
857            api_version: 3,
858            expires_at,
859        }
860    }
861
862    #[test]
863    fn test_seconds_until_expiry_expired_session_returns_zero() {
864        // expires_at 100s in the past -> saturating to 0, never a huge u64.
865        let past = u64::try_from(Utc::now().timestamp())
866            .unwrap_or(0)
867            .saturating_sub(100);
868        let session = v2_session(past);
869        assert_eq!(session.seconds_until_expiry(), 0);
870    }
871
872    #[test]
873    fn test_seconds_until_expiry_valid_session_is_positive() {
874        let future = u64::try_from(Utc::now().timestamp())
875            .unwrap_or(0)
876            .saturating_add(3600);
877        let session = v2_session(future);
878        // Allow a small slack for the wall-clock read inside the method.
879        assert!(session.seconds_until_expiry() > 3500);
880    }
881
882    #[test]
883    fn test_time_until_expiry_expired_session_is_zero() {
884        let past = u64::try_from(Utc::now().timestamp())
885            .unwrap_or(0)
886            .saturating_sub(100);
887        let session = v2_session(past);
888        assert_eq!(session.time_until_expiry(), std::time::Duration::ZERO);
889    }
890
891    #[test]
892    fn test_is_expired_large_margin_does_not_underflow() {
893        // Tiny expires_at with an enormous margin must not underflow / panic;
894        // it simply reports the session as expired.
895        let session = v2_session(1);
896        assert!(session.is_expired(Some(u64::MAX)));
897        assert!(session.is_expired(Some(1000)));
898    }
899
900    #[test]
901    fn test_is_expired_valid_session_within_margin_still_valid() {
902        let future = u64::try_from(Utc::now().timestamp())
903            .unwrap_or(0)
904            .saturating_add(3600);
905        let session = v2_session(future);
906        assert!(!session.is_expired(Some(60)));
907    }
908
909    #[test]
910    fn test_proactive_refresh_margin_v3_is_small_v2_is_large() {
911        let v3 = v3_session(0);
912        let v2 = v2_session(0);
913        assert_eq!(
914            proactive_refresh_margin_secs(&v3),
915            crate::constants::PROACTIVE_REFRESH_MARGIN_V3_SECS
916        );
917        assert_eq!(
918            proactive_refresh_margin_secs(&v2),
919            crate::constants::PROACTIVE_REFRESH_MARGIN_V2_SECS
920        );
921        // v3's short-lived tokens get a tighter margin than v2's 6h sessions.
922        assert!(proactive_refresh_margin_secs(&v3) < proactive_refresh_margin_secs(&v2));
923    }
924
925    #[tokio::test]
926    async fn test_refresh_token_returns_cached_valid_session_without_login() {
927        // refresh_token has an expiry gate: a comfortably-valid session is
928        // returned unchanged, so no network login is attempted. This is the
929        // behaviour that differs from force_refresh (which always re-logs in).
930        let auth =
931            Auth::try_new(Arc::new(Config::default())).expect("auth construction should succeed");
932        let future = u64::try_from(Utc::now().timestamp())
933            .unwrap_or(0)
934            .saturating_add(3600);
935        let seeded = v2_session(future);
936        {
937            let mut guard = auth.session.write().await;
938            *guard = Some(seeded);
939        }
940
941        let refreshed = match auth.refresh_token().await {
942            Ok(session) => session,
943            Err(e) => panic!("refresh_token should return the cached session: {e}"),
944        };
945        // Same cached tokens returned: no re-authentication happened.
946        assert_eq!(refreshed.account_id, "ACC123");
947        assert_eq!(refreshed.cst.as_deref(), Some("CST-TOKEN"));
948        assert_eq!(refreshed.expires_at, future);
949    }
950
951    #[test]
952    fn test_force_refresh_is_public_and_present() {
953        // Compile-time proof that the additive 401-path API exists. Invoking it
954        // would perform a real login, so it is not called here (no network in
955        // unit tests).
956        let _ = Auth::force_refresh;
957    }
958}
959
960#[cfg(test)]
961mod oauth_login_guard_tests {
962    use super::*;
963
964    // Captured real IG demo v2 `/session` body (same shape as the existing
965    // deserialization tests). Deserialized through the untagged
966    // `SessionResponse` it lands on the V2 variant, so the derived session
967    // carries no OAuth token — exactly the mismatch `login_oauth` must reject.
968    const V2_BODY: &str = r#"{"accountType":"CFD","accountInfo":{"balance":21065.86,"deposit":3033.31,"profitLoss":-285.27,"available":16659.01},"currencyIsoCode":"EUR","currencySymbol":"E","currentAccountId":"ZZZZZ","lightstreamerEndpoint":"https://demo-apd.marketdatasystems.com","accounts":[{"accountId":"Z405P5","accountName":"Turbo24","preferred":false,"accountType":"PHYSICAL"},{"accountId":"ZHJ5N","accountName":"DEMO_A","preferred":false,"accountType":"CFD"},{"accountId":"ZZZZZ","accountName":"Opciones","preferred":true,"accountType":"CFD"}],"clientId":"101290216","timezoneOffset":1,"hasActiveDemoAccounts":true,"hasActiveLiveAccounts":true,"trailingStopsEnabled":false,"reroutingEnvironment":null,"dealingEnabled":true}"#;
969
970    #[test]
971    fn test_ensure_oauth_session_v2_body_on_v3_path_yields_unauthorized()
972    -> Result<(), serde_json::Error> {
973        // Deserialize a v2-shaped body the way `login_oauth` does after a v3
974        // request, then run it through the same guard.
975        let response: SessionResponse = serde_json::from_str(V2_BODY)?;
976        let session = response.get_session();
977        // A v2 body carries no OAuth token.
978        assert!(!session.is_oauth());
979
980        // The guard maps this to a typed error instead of panicking (the old
981        // `assert!(session.is_oauth())` would have aborted here).
982        match ensure_oauth_session(session) {
983            Err(AppError::Unauthorized) => Ok(()),
984            other => panic!("expected AppError::Unauthorized, got {other:?}"),
985        }
986    }
987
988    #[test]
989    fn test_ensure_oauth_session_oauth_body_passes_through() {
990        // A genuine v3 session passes the guard unchanged.
991        let session = Session {
992            account_id: "ACC123".to_string(),
993            client_id: "CLIENT1".to_string(),
994            lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
995            cst: None,
996            x_security_token: None,
997            oauth_token: Some(OAuthToken {
998                access_token: "ACCESS".to_string(),
999                refresh_token: "REFRESH".to_string(),
1000                scope: "read write".to_string(),
1001                token_type: "Bearer".to_string(),
1002                expires_in: "60".to_string(),
1003                created_at: Utc::now(),
1004            }),
1005            api_version: 3,
1006            expires_at: 0,
1007        };
1008        assert!(ensure_oauth_session(session).is_ok());
1009    }
1010}
1011
1012#[cfg(test)]
1013mod redaction_tests {
1014    use super::*;
1015
1016    fn secret_session() -> Session {
1017        Session {
1018            account_id: "ACC123".to_string(),
1019            client_id: "CLIENT1".to_string(),
1020            lightstreamer_endpoint: "https://ls.example.com".to_string(),
1021            cst: Some("SECRET-CST-VALUE".to_string()),
1022            x_security_token: Some("SECRET-XST-VALUE".to_string()),
1023            oauth_token: Some(OAuthToken {
1024                access_token: "SECRET-ACCESS-VALUE".to_string(),
1025                refresh_token: "SECRET-REFRESH-VALUE".to_string(),
1026                scope: "read write".to_string(),
1027                token_type: "Bearer".to_string(),
1028                expires_in: "60".to_string(),
1029                created_at: chrono::Utc::now(),
1030            }),
1031            api_version: 3,
1032            expires_at: 0,
1033        }
1034    }
1035
1036    #[test]
1037    fn test_session_debug_redacts_tokens() {
1038        let session = secret_session();
1039        let rendered = format!("{session:?}");
1040
1041        assert!(!rendered.contains("SECRET-CST-VALUE"));
1042        assert!(!rendered.contains("SECRET-XST-VALUE"));
1043        assert!(!rendered.contains("SECRET-ACCESS-VALUE"));
1044        assert!(!rendered.contains("SECRET-REFRESH-VALUE"));
1045        assert!(rendered.contains("<redacted>"));
1046        // Non-secret fields stay visible.
1047        assert!(rendered.contains("ACC123"));
1048        assert!(rendered.contains("ls.example.com"));
1049    }
1050
1051    #[test]
1052    fn test_websocket_info_debug_redacts_tokens() {
1053        let ws = WebsocketInfo {
1054            server: "https://ls.example.com/lightstreamer".to_string(),
1055            cst: Some("SECRET-CST-VALUE".to_string()),
1056            x_security_token: Some("SECRET-XST-VALUE".to_string()),
1057            account_id: "ACC123".to_string(),
1058        };
1059        let rendered = format!("{ws:?}");
1060
1061        assert!(!rendered.contains("SECRET-CST-VALUE"));
1062        assert!(!rendered.contains("SECRET-XST-VALUE"));
1063        assert!(rendered.contains("Some(<redacted>)"));
1064        assert!(rendered.contains("ACC123"));
1065        assert!(rendered.contains("ls.example.com"));
1066    }
1067
1068    #[test]
1069    fn test_websocket_info_display_redacts_tokens() {
1070        let ws = WebsocketInfo {
1071            server: "https://ls.example.com/lightstreamer".to_string(),
1072            cst: Some("SECRET-CST-VALUE".to_string()),
1073            x_security_token: Some("SECRET-XST-VALUE".to_string()),
1074            account_id: "ACC123".to_string(),
1075        };
1076        let rendered = format!("{ws}");
1077
1078        assert!(!rendered.contains("SECRET-CST-VALUE"));
1079        assert!(!rendered.contains("SECRET-XST-VALUE"));
1080        assert!(rendered.contains("<redacted>"));
1081        assert!(rendered.contains("ACC123"));
1082
1083        // `None` tokens render as `None`, not as a redacted placeholder.
1084        let ws_none = WebsocketInfo {
1085            server: "https://ls.example.com/lightstreamer".to_string(),
1086            cst: None,
1087            x_security_token: None,
1088            account_id: "ACC123".to_string(),
1089        };
1090        assert!(format!("{ws_none}").contains("None"));
1091    }
1092}