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::{Pacing, make_http_request_with_account};
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    /// Budget shared with every other key of the account, charged on each
142    /// `/session` call just as it is on data requests: IG counts a login too.
143    account_limiter: Option<RateLimiter>,
144}
145
146impl Auth {
147    /// Creates a new Auth instance, returning an error if the HTTP client
148    /// cannot be constructed.
149    ///
150    /// This is the sole constructor for [`Auth`]: it surfaces a TLS /
151    /// client-builder failure as a typed [`AppError`] instead of panicking, so
152    /// callers can handle a broken TLS backend gracefully.
153    ///
154    /// # Arguments
155    /// * `config` - Configuration containing credentials and API settings
156    ///
157    /// # Errors
158    /// Returns [`AppError::Network`] if the underlying `reqwest` client cannot
159    /// be built (e.g. the system TLS backend fails to initialize).
160    pub fn try_new(config: Arc<Config>) -> Result<Self, AppError> {
161        let rate_limiter = RateLimiter::new(&config.rate_limiter);
162        Self::with_rate_limiter(config, rate_limiter)
163    }
164
165    /// Builds an `Auth` paced against both this key's limiter and the
166    /// account-wide one.
167    ///
168    /// # Errors
169    /// Returns [`AppError::Network`] if the HTTP client cannot be built.
170    pub fn with_limiters(
171        config: Arc<Config>,
172        rate_limiter: RateLimiter,
173        account_limiter: RateLimiter,
174    ) -> Result<Self, AppError> {
175        let mut auth = Self::with_rate_limiter(config, rate_limiter)?;
176        auth.account_limiter = Some(account_limiter);
177        Ok(auth)
178    }
179
180    /// Builds an `Auth` that paces against a caller-supplied limiter.
181    ///
182    /// IG meters its allowance per API key, and a login is one of the requests
183    /// it counts. When a key's session and its data requests pace against
184    /// separate limiters, the key's real budget is the sum of the two and IG
185    /// rejects requests the client believed were within budget. Sharing one
186    /// limiter per key is what makes the local pacing match the remote one.
187    ///
188    /// # Arguments
189    /// * `config` - Configuration containing credentials and API settings
190    /// * `rate_limiter` - The limiter owned by this key
191    ///
192    /// # Errors
193    /// Returns [`AppError::Network`] if the underlying `reqwest` client cannot
194    /// be built (e.g. the system TLS backend fails to initialize).
195    pub fn with_rate_limiter(
196        config: Arc<Config>,
197        rate_limiter: RateLimiter,
198    ) -> Result<Self, AppError> {
199        let client = Client::builder().user_agent(USER_AGENT).build()?;
200
201        Ok(Self {
202            config,
203            client,
204            session: Arc::new(RwLock::new(None)),
205            rate_limiter,
206            account_limiter: None,
207        })
208    }
209
210    /// Gets WebSocket connection information for Lightstreamer, reusing the
211    /// cached session.
212    ///
213    /// This calls [`Auth::get_session`], which returns the cached session when
214    /// it is still valid and only logs in (storing the new session) when none
215    /// exists or it has expired. The configured API version is honoured — no
216    /// per-call v2 re-login is performed.
217    ///
218    /// # Returns
219    /// * `Ok(WebsocketInfo)` - Endpoint and authentication tokens for the
220    ///   current session.
221    /// * `Err(AppError)` - If session retrieval (login / refresh) fails.
222    ///
223    /// # Errors
224    /// Returns [`AppError`] when the session cannot be retrieved (login or token
225    /// refresh failure).
226    pub async fn ws_info(&self) -> Result<WebsocketInfo, AppError> {
227        let session = self.get_session().await?;
228        Ok(session.get_websocket_info())
229    }
230
231    /// Gets the WebSocket password for Lightstreamer authentication
232    ///
233    /// # Returns
234    /// * WebSocket password in format "CST-{cst}|XST-{token}" or empty string if session is not available
235    #[deprecated(
236        note = "use ws_info() which reuses the cached session and returns a typed error instead of a default-on-error WebsocketInfo"
237    )]
238    pub async fn get_ws_info(&self) -> WebsocketInfo {
239        self.ws_info().await.unwrap_or_default()
240    }
241
242    /// The budgets a `/session` request must pass: this key's, then the
243    /// account's.
244    fn pacing(&self) -> Pacing<'_> {
245        Pacing {
246            key: &self.rate_limiter,
247            account: self.account_limiter.as_ref(),
248        }
249    }
250
251    /// Whether the cached session authenticates with OAuth (v3).
252    ///
253    /// `false` when nothing is cached yet: with no session, the caller cannot
254    /// assume the per-request account model applies.
255    pub async fn is_oauth_session(&self) -> bool {
256        let session = self.session.read().await;
257        session.as_ref().is_some_and(|s| s.api_version == 3)
258    }
259
260    /// Whether a session is cached and not within its refresh margin.
261    ///
262    /// Lets a caller tell "this key can send right now" from "this key would
263    /// have to log in first" without triggering the login. The key pool needs
264    /// that distinction: probing every key with `get_session` would
265    /// authenticate the whole pool to serve one request.
266    pub async fn has_ready_session(&self) -> bool {
267        let session = self.session.read().await;
268        session.as_ref().is_some_and(|sess| {
269            !sess.needs_token_refresh(Some(proactive_refresh_margin_secs(sess)))
270        })
271    }
272
273    /// Gets the current session, ensuring tokens are valid
274    ///
275    /// This method automatically refreshes expired OAuth tokens or re-authenticates if needed.
276    ///
277    /// # Returns
278    /// * `Ok(Session)` - Valid session with fresh tokens
279    /// * `Err(AppError)` - If authentication fails
280    ///
281    /// # Errors
282    /// Returns [`AppError`] when login or token refresh fails.
283    pub async fn get_session(&self) -> Result<Session, AppError> {
284        let session = self.session.read().await;
285
286        if let Some(sess) = session.as_ref() {
287            // Refresh proactively once the session enters its refresh margin. The
288            // margin is derived from the session type so it matches the one
289            // `refresh_token` re-checks with — otherwise the refresh detour would
290            // hand back the same near-expired session (the old 300s/1s mismatch).
291            let margin = proactive_refresh_margin_secs(sess);
292            if sess.needs_token_refresh(Some(margin)) {
293                drop(session); // Release read lock
294                debug!(margin_secs = margin, "session within refresh margin");
295                return self.refresh_token().await;
296            }
297            return Ok(sess.clone());
298        }
299
300        drop(session);
301
302        // No session exists, need to login
303        info!("No active session, logging in");
304        self.login().await
305    }
306
307    /// Performs initial login to IG Markets API
308    ///
309    /// Automatically detects API version from config and uses appropriate authentication method.
310    ///
311    /// # Returns
312    /// * `Ok(Session)` - Authenticated session
313    /// * `Err(AppError)` - If login fails
314    pub async fn login(&self) -> Result<Session, AppError> {
315        let api_version = self.config.api_version.unwrap_or(2);
316
317        debug!("Logging in with API v{}", api_version);
318
319        let session = if api_version == 3 {
320            self.login_oauth().await?
321        } else {
322            self.login_v2().await?
323        };
324
325        // Store session. The write guard is scoped so it is released before any
326        // account-selection switch below: `switch_account` reads `self.session`
327        // via `get_session`, and holding the guard across that call would
328        // deadlock.
329        {
330            let mut sess = self.session.write().await;
331            *sess = Some(session.clone());
332        }
333
334        info!("✓ Login successful, account: {}", session.account_id);
335
336        // v2 account selection: the v2 `/session` response reports IG's
337        // current/default account, which may differ from the configured one.
338        // Switch to the configured account when it is set and differs. This
339        // cannot recurse: the session was just stored above, so
340        // `switch_account` -> `get_session` returns the cached (valid) session
341        // and never triggers another login. OAuth (v3) already pins the account
342        // in `login_oauth`, so it is skipped here.
343        if api_version != 3 {
344            let configured = self.config.credentials.account_id.clone();
345            if should_switch_account(api_version, &configured, &session.account_id) {
346                info!("selecting configured account after v2 login");
347                // `Box::pin` breaks the compile-time async recursion cycle
348                // (login -> switch_account -> get_session -> login). The cycle
349                // is runtime-bounded: the session was stored above, so
350                // `get_session` returns it without logging in again.
351                // The session stored above is the *login's* account, not the
352                // configured one. If the switch fails, leaving it cached would
353                // let the next request run silently against the wrong account -
354                // or spin on 401 - so the session is cleared and the caller sees
355                // the failure. Local state and the selected account move
356                // together or not at all.
357                return match Box::pin(self.switch_account(&configured, None)).await {
358                    Ok(session) => Ok(session),
359                    Err(e) => {
360                        {
361                            let mut sess = self.session.write().await;
362                            *sess = None;
363                        }
364                        warn!(
365                            "account selection failed after login; cleared the \
366                             session rather than keeping the login's default account"
367                        );
368                        Err(e)
369                    }
370                };
371            }
372        }
373
374        Ok(session)
375    }
376
377    /// Performs login using API v2 (CST/X-SECURITY-TOKEN) with automatic retry on rate limit
378    async fn login_v2(&self) -> Result<Session, AppError> {
379        let url = format!("{}/session", self.config.rest_api.base_url);
380
381        let body = serde_json::json!({
382            "identifier": self.config.credentials.username,
383            "password": self.config.credentials.password,
384        });
385
386        debug!("Sending v2 login request to: {}", url);
387
388        let headers = vec![
389            ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
390            ("Content-Type", "application/json"),
391            ("Version", "2"),
392        ];
393
394        let response = make_http_request_with_account(
395            &self.client,
396            self.pacing(),
397            Method::POST,
398            &url,
399            headers,
400            &Some(body),
401            RetryConfig::default(),
402        )
403        .await?;
404
405        // Extract CST and X-SECURITY-TOKEN from headers
406        let cst: String = match response
407            .headers()
408            .get("CST")
409            .and_then(|v| v.to_str().ok())
410            .map(String::from)
411        {
412            Some(token) => token,
413            None => {
414                // A rejected / malformed auth response, not bad caller input:
415                // surface a typed auth error naming the missing header.
416                error!("missing cst header in login response");
417                return Err(AuthError::MissingSessionToken("cst".to_string()).into());
418            }
419        };
420        let x_security_token: String = match response
421            .headers()
422            .get("X-SECURITY-TOKEN")
423            .and_then(|v| v.to_str().ok())
424            .map(String::from)
425        {
426            Some(token) => token,
427            None => {
428                // A rejected / malformed auth response, not bad caller input:
429                // surface a typed auth error naming the missing header.
430                error!("missing x-security-token header in login response");
431                return Err(AuthError::MissingSessionToken("x-security-token".to_string()).into());
432            }
433        };
434
435        let x_ig_api_key: String = response
436            .headers()
437            .get("X-IG-API-KEY")
438            .and_then(|v| v.to_str().ok())
439            .map(String::from)
440            .unwrap_or_else(|| self.config.credentials.api_key.clone());
441
442        let security_headers: SecurityHeaders = SecurityHeaders {
443            cst,
444            x_security_token,
445            x_ig_api_key,
446        };
447
448        // Get response body as text first for debugging
449        let body_text = response.text().await.map_err(|e| {
450            error!("Failed to read response body: {}", e);
451            AppError::Network(e)
452        })?;
453        debug!("Login response body length: {} bytes", body_text.len());
454
455        // Parse the JSON
456        let mut response: SessionResponse = serde_json::from_str(&body_text).map_err(|e| {
457            // Never log the body: the `/session` response carries credentials.
458            error!(
459                endpoint = %url,
460                body_len = body_text.len(),
461                "failed to parse login response: {}",
462                e
463            );
464            AppError::Deserialization(format!("Failed to parse login response: {}", e))
465        })?;
466        let session = response.get_session_v2(&security_headers);
467
468        Ok(session)
469    }
470
471    /// Performs login using API v3 (OAuth) with automatic retry on rate limit
472    async fn login_oauth(&self) -> Result<Session, AppError> {
473        let url = format!("{}/session", self.config.rest_api.base_url);
474
475        let body = serde_json::json!({
476            "identifier": self.config.credentials.username,
477            "password": self.config.credentials.password,
478        });
479
480        debug!("Sending OAuth login request to: {}", url);
481        let headers = vec![
482            ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
483            ("Content-Type", "application/json"),
484            ("Version", "3"),
485        ];
486
487        let response = make_http_request_with_account(
488            &self.client,
489            self.pacing(),
490            Method::POST,
491            &url,
492            headers,
493            &Some(body),
494            RetryConfig::default(),
495        )
496        .await?;
497
498        let response: SessionResponse = response.json().await?;
499        let mut session = response.get_session();
500        if session.account_id != self.config.credentials.account_id {
501            session.account_id = self.config.credentials.account_id.clone();
502        };
503
504        // A v2-shaped body on the v3 path (server-side mismatch) must not panic;
505        // surface it as a typed error instead.
506        ensure_oauth_session(session)
507    }
508
509    /// Proactively refreshes the session when it is within its refresh margin.
510    ///
511    /// This is the *proactive* path (driven by the local clock). It re-checks the
512    /// cached session against the same margin
513    /// [`get_session`](Self::get_session) used to decide a refresh was due, so
514    /// the two stay consistent and the refresh actually fires when the session is
515    /// close to expiry. If the session is still comfortably valid it is returned
516    /// unchanged; otherwise a full [`login`](Self::login) is performed.
517    ///
518    /// For the reactive 401 / server-side-invalidation path — where the local
519    /// clock still considers the token valid but IG has already rejected it — use
520    /// [`force_refresh`](Self::force_refresh), which re-authenticates
521    /// unconditionally.
522    ///
523    /// # Returns
524    /// * `Ok(Session)` - A valid session (refreshed if it was within margin).
525    /// * `Err(AppError)` - If re-authentication fails.
526    ///
527    /// # Errors
528    /// Returns [`AppError`] when a required login fails (network, credentials, or
529    /// rate limiting).
530    pub async fn refresh_token(&self) -> Result<Session, AppError> {
531        let current_session = {
532            let session = self.session.read().await;
533            session.clone()
534        };
535
536        if let Some(sess) = current_session {
537            // Honour the SAME margin `get_session` used to route here, so a
538            // session inside the proactive window is actually re-authenticated
539            // instead of being handed back near-expired.
540            let margin = proactive_refresh_margin_secs(&sess);
541            if sess.is_expired(Some(margin)) {
542                debug!(
543                    margin_secs = margin,
544                    "session within refresh margin, logging in"
545                );
546                self.login().await
547            } else {
548                Ok(sess)
549            }
550        } else {
551            warn!("No session to refresh, performing login");
552            self.login().await
553        }
554    }
555
556    /// Forces a fresh re-authentication regardless of local expiry state.
557    ///
558    /// This is the reactive 401 / server-side-invalidation path. When IG rejects
559    /// a token that the local clock still considers valid (server-side
560    /// invalidation, a concurrent login elsewhere, or clock skew), a proactive
561    /// [`refresh_token`](Self::refresh_token) would see a "valid" session and
562    /// hand back the *same* stale token, so the replayed request would fail
563    /// again. `force_refresh` ignores local expiry and performs a full
564    /// [`login`](Self::login), which fetches and stores a brand-new session.
565    ///
566    /// It cannot loop back through the 401 handler: [`login`](Self::login) issues
567    /// its HTTP requests through
568    /// [`make_http_request_with_account`] directly, not
569    /// through the [`HttpClient`](crate::application::http::HttpClient) refresh-and-replay
570    /// path, so a 401 encountered *during* login surfaces as a typed error rather
571    /// than recursing into `force_refresh`.
572    ///
573    /// # Returns
574    /// * `Ok(Session)` - A freshly authenticated session with new tokens.
575    /// * `Err(AppError)` - If re-authentication fails.
576    ///
577    /// # Errors
578    /// Returns [`AppError`] when the login request fails (network, credentials,
579    /// or rate limiting).
580    pub async fn force_refresh(&self) -> Result<Session, AppError> {
581        debug!("forcing re-authentication, ignoring local expiry");
582        self.login().await
583    }
584
585    /// Switches to a different trading account
586    ///
587    /// # Arguments
588    /// * `account_id` - The account ID to switch to
589    /// * `default_account` - Whether to set as default account
590    ///
591    /// # Returns
592    /// * `Ok(Session)` - New session for the switched account
593    /// * `Err(AppError)` - If account switch fails
594    pub async fn switch_account(
595        &self,
596        account_id: &str,
597        default_account: Option<bool>,
598    ) -> Result<Session, AppError> {
599        let current_session = self.get_session().await?;
600
601        if matches!(current_session.api_version, 3) {
602            return Err(AppError::InvalidInput(
603                "Cannot switch accounts with OAuth".to_string(),
604            ));
605        }
606
607        if current_session.account_id == account_id {
608            debug!("Already on account {}", account_id);
609            return Ok(current_session);
610        }
611
612        info!("Switching to account: {}", account_id);
613
614        let url = format!("{}/session", self.config.rest_api.base_url);
615
616        let mut body = serde_json::json!({
617            "accountId": account_id,
618        });
619
620        if let Some(default) = default_account {
621            body["defaultAccount"] = serde_json::json!(default);
622        }
623
624        // Build headers with authentication. Only the v2 (CST /
625        // X-SECURITY-TOKEN) path is reachable here: OAuth sessions
626        // (`api_version == 3`) are rejected above, so no `Authorization: Bearer`
627        // branch is needed.
628        let api_key = self.config.credentials.api_key.clone();
629        let cst;
630        let x_security_token;
631
632        let mut headers = vec![
633            ("X-IG-API-KEY", api_key.as_str()),
634            ("Content-Type", "application/json"),
635            ("Version", "1"),
636        ];
637
638        if let Some(cst_val) = &current_session.cst {
639            cst = cst_val.clone();
640            headers.push(("CST", cst.as_str()));
641        }
642        if let Some(token_val) = &current_session.x_security_token {
643            x_security_token = token_val.clone();
644            headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
645        }
646
647        let response = make_http_request_with_account(
648            &self.client,
649            self.pacing(),
650            Method::PUT,
651            &url,
652            headers,
653            &Some(body),
654            RetryConfig::default(),
655        )
656        .await?;
657
658        // IG re-issues the X-SECURITY-TOKEN (and sometimes CST) in the switch
659        // response. Read them from the headers and merge them in; a missing
660        // header keeps the existing token rather than nulling it.
661        let new_cst = response
662            .headers()
663            .get("CST")
664            .and_then(|v| v.to_str().ok())
665            .map(String::from);
666        let new_x_security_token = response
667            .headers()
668            .get("X-SECURITY-TOKEN")
669            .and_then(|v| v.to_str().ok())
670            .map(String::from);
671
672        // IG re-issues X-SECURITY-TOKEN on a successful switch. Its absence on a
673        // 2xx response is anomalous (proxy stripping / response-shape drift): the
674        // old token is kept below, but flag it since it will 401 the next call.
675        if new_x_security_token.is_none() {
676            warn!("switch response carried no X-SECURITY-TOKEN; keeping the previous token");
677        }
678
679        // After switching, update the session with the fresh tokens and the new
680        // account id.
681        let mut new_session = apply_switch_headers(
682            current_session.clone(),
683            new_cst.as_deref(),
684            new_x_security_token.as_deref(),
685        );
686        new_session.account_id = account_id.to_string();
687
688        {
689            let mut session = self.session.write().await;
690            *session = Some(new_session.clone());
691        }
692
693        info!("✓ Switched to account: {}", account_id);
694        Ok(new_session)
695    }
696
697    /// Logs out and clears the current session.
698    ///
699    /// Before clearing local state, this issues `DELETE /session` (IG API
700    /// Version 1) with the current authentication headers. For a **v2** session
701    /// (CST / X-SECURITY-TOKEN) this invalidates the session server-side. For a
702    /// **v3 / OAuth** session there is no IG token-revocation endpoint and the
703    /// short-lived access token has usually already expired, so `DELETE /session`
704    /// is best-effort: logout clears local state and the OAuth tokens lapse on
705    /// their own expiry rather than being actively revoked. A `401 Unauthorized`
706    /// (or an already-invalid OAuth token) is treated as already-logged-out and
707    /// reported as success.
708    ///
709    /// The local session is always cleared, even when the server-side call
710    /// fails, so the client is never wedged in an authenticated-but-unusable
711    /// state; the underlying failure is still returned as a typed error.
712    ///
713    /// # Errors
714    /// Returns [`AppError`] when the server-side logout request fails for a
715    /// reason other than an already-invalid session (401). The local session is
716    /// cleared regardless.
717    pub async fn logout(&self) -> Result<(), AppError> {
718        info!("Logging out");
719
720        // Snapshot the current session without holding the lock across the HTTP
721        // call. With no session there is nothing to revoke server-side.
722        let current_session = {
723            let session = self.session.read().await;
724            session.clone()
725        };
726
727        let server_result = match current_session {
728            Some(sess) => self.revoke_session(&sess).await,
729            None => Ok(()),
730        };
731
732        // Always clear local state so the client is never wedged, regardless of
733        // the server-side outcome.
734        {
735            let mut session = self.session.write().await;
736            *session = None;
737        }
738
739        match server_result {
740            Ok(()) => {
741                info!("✓ Logged out successfully");
742                Ok(())
743            }
744            Err(e) => {
745                // The error type carries no secrets (see `AppError`), so it is
746                // safe to log; local state has already been cleared.
747                error!("server-side logout failed: {}", e);
748                Err(e)
749            }
750        }
751    }
752
753    /// Issues `DELETE /session` (IG API Version 1) to terminate the session
754    /// server-side.
755    ///
756    /// A `401 Unauthorized` (or an already-invalid OAuth token) means the
757    /// session is no longer valid server-side and is treated as success.
758    ///
759    /// # Errors
760    /// Returns [`AppError`] if the request fails for any reason other than an
761    /// already-invalid session.
762    async fn revoke_session(&self, session: &Session) -> Result<(), AppError> {
763        let url = format!("{}/session", self.config.rest_api.base_url);
764        let api_key = self.config.credentials.api_key.clone();
765
766        let auth_header_value;
767        let cst;
768        let x_security_token;
769
770        let mut headers = vec![
771            ("X-IG-API-KEY", api_key.as_str()),
772            ("Content-Type", "application/json"),
773            ("Version", "1"),
774        ];
775
776        if let Some(oauth) = &session.oauth_token {
777            auth_header_value = format!("Bearer {}", oauth.access_token);
778            headers.push(("Authorization", auth_header_value.as_str()));
779            headers.push(("IG-ACCOUNT-ID", session.account_id.as_str()));
780        } else {
781            if let Some(cst_val) = &session.cst {
782                cst = cst_val.clone();
783                headers.push(("CST", cst.as_str()));
784            }
785            if let Some(token_val) = &session.x_security_token {
786                x_security_token = token_val.clone();
787                headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
788            }
789        }
790
791        match make_http_request_with_account(
792            &self.client,
793            self.pacing(),
794            Method::DELETE,
795            &url,
796            headers,
797            &None::<()>,
798            RetryConfig::default(),
799        )
800        .await
801        {
802            Ok(_) => Ok(()),
803            // A 401 (or an expired OAuth token) means the session is already
804            // invalid server-side: the logout goal is met.
805            Err(AppError::Unauthorized | AppError::OAuthTokenExpired) => {
806                debug!("session already invalid server-side; treating as logged out");
807                Ok(())
808            }
809            Err(e) => Err(e),
810        }
811    }
812}
813
814#[cfg(test)]
815mod session_lifecycle_tests {
816    use super::*;
817
818    fn v2_session(cst: Option<&str>, xst: Option<&str>) -> Session {
819        Session {
820            account_id: "ACC-OLD".to_string(),
821            client_id: "CLIENT1".to_string(),
822            lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
823            cst: cst.map(String::from),
824            x_security_token: xst.map(String::from),
825            oauth_token: None,
826            api_version: 2,
827            expires_at: 123,
828        }
829    }
830
831    #[test]
832    fn test_apply_switch_headers_replaces_xst_and_keeps_other_fields() {
833        let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
834        // A switch response that only re-issues the X-SECURITY-TOKEN.
835        let updated = apply_switch_headers(session, None, Some("NEW-XST"));
836
837        assert_eq!(updated.x_security_token.as_deref(), Some("NEW-XST"));
838        // CST is preserved when the header is absent.
839        assert_eq!(updated.cst.as_deref(), Some("OLD-CST"));
840        // Unrelated fields carry through unchanged.
841        assert_eq!(updated.account_id, "ACC-OLD");
842        assert_eq!(updated.expires_at, 123);
843        assert_eq!(updated.api_version, 2);
844    }
845
846    #[test]
847    fn test_apply_switch_headers_updates_both_tokens() {
848        let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
849        let updated = apply_switch_headers(session, Some("NEW-CST"), Some("NEW-XST"));
850
851        assert_eq!(updated.cst.as_deref(), Some("NEW-CST"));
852        assert_eq!(updated.x_security_token.as_deref(), Some("NEW-XST"));
853    }
854
855    #[test]
856    fn test_apply_switch_headers_none_preserves_existing_tokens() {
857        let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
858        // A switch response carrying neither header must not null the tokens.
859        let updated = apply_switch_headers(session, None, None);
860
861        assert_eq!(updated.cst.as_deref(), Some("OLD-CST"));
862        assert_eq!(updated.x_security_token.as_deref(), Some("OLD-XST"));
863    }
864
865    #[test]
866    fn test_should_switch_account_guards_the_sentinel_and_v3() {
867        use crate::constants::DEFAULT_ACCOUNT_ID;
868
869        // Real configured account that differs from the current one: switch.
870        assert!(should_switch_account(2, "ACC-B", "ACC-A"));
871        // Unconfigured default sentinel: must NOT switch (would fail login).
872        assert!(!should_switch_account(2, DEFAULT_ACCOUNT_ID, "ACC-A"));
873        // Empty configured account: must not switch.
874        assert!(!should_switch_account(2, "", "ACC-A"));
875        // Already on the configured account: no switch needed.
876        assert!(!should_switch_account(2, "ACC-A", "ACC-A"));
877        // OAuth (v3) is never switched through this path.
878        assert!(!should_switch_account(3, "ACC-B", "ACC-A"));
879    }
880
881    #[tokio::test]
882    async fn test_ws_info_uses_cached_session_without_login() {
883        let auth =
884            Auth::try_new(Arc::new(Config::default())).expect("auth construction should succeed");
885
886        // Seed a valid (non-expired) v2 session with known tokens. Because the
887        // session is valid, `ws_info` -> `get_session` must return it without a
888        // network login (a login would require real credentials and fail).
889        let expires_at = (Utc::now().timestamp() + 3600) as u64;
890        let seeded = Session {
891            account_id: "ACC123".to_string(),
892            client_id: "CLIENT1".to_string(),
893            lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
894            cst: Some("CST-TOKEN".to_string()),
895            x_security_token: Some("XST-TOKEN".to_string()),
896            oauth_token: None,
897            api_version: 2,
898            expires_at,
899        };
900        {
901            let mut guard = auth.session.write().await;
902            *guard = Some(seeded);
903        }
904
905        let ws = match auth.ws_info().await {
906            Ok(ws) => ws,
907            Err(e) => panic!("ws_info should return Ok for a cached session: {e}"),
908        };
909
910        // The returned info derives from the cached session's tokens, proving no
911        // fresh login occurred.
912        assert_eq!(ws.account_id, "ACC123");
913        assert_eq!(ws.cst.as_deref(), Some("CST-TOKEN"));
914        assert_eq!(ws.x_security_token.as_deref(), Some("XST-TOKEN"));
915        assert!(ws.server.contains("demo-apd.marketdatasystems.com"));
916    }
917}
918
919#[cfg(test)]
920mod expiry_and_refresh_tests {
921    use super::*;
922
923    fn v2_session(expires_at: u64) -> Session {
924        Session {
925            account_id: "ACC123".to_string(),
926            client_id: "CLIENT1".to_string(),
927            lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
928            cst: Some("CST-TOKEN".to_string()),
929            x_security_token: Some("XST-TOKEN".to_string()),
930            oauth_token: None,
931            api_version: 2,
932            expires_at,
933        }
934    }
935
936    fn v3_session(expires_at: u64) -> Session {
937        Session {
938            account_id: "ACC123".to_string(),
939            client_id: "CLIENT1".to_string(),
940            lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
941            cst: None,
942            x_security_token: None,
943            oauth_token: Some(OAuthToken {
944                access_token: "ACCESS".to_string(),
945                refresh_token: "REFRESH".to_string(),
946                scope: "read write".to_string(),
947                token_type: "Bearer".to_string(),
948                expires_in: "60".to_string(),
949                created_at: Utc::now(),
950            }),
951            api_version: 3,
952            expires_at,
953        }
954    }
955
956    #[test]
957    fn test_seconds_until_expiry_expired_session_returns_zero() {
958        // expires_at 100s in the past -> saturating to 0, never a huge u64.
959        let past = u64::try_from(Utc::now().timestamp())
960            .unwrap_or(0)
961            .saturating_sub(100);
962        let session = v2_session(past);
963        assert_eq!(session.seconds_until_expiry(), 0);
964    }
965
966    #[test]
967    fn test_seconds_until_expiry_valid_session_is_positive() {
968        let future = u64::try_from(Utc::now().timestamp())
969            .unwrap_or(0)
970            .saturating_add(3600);
971        let session = v2_session(future);
972        // Allow a small slack for the wall-clock read inside the method.
973        assert!(session.seconds_until_expiry() > 3500);
974    }
975
976    #[test]
977    fn test_time_until_expiry_expired_session_is_zero() {
978        let past = u64::try_from(Utc::now().timestamp())
979            .unwrap_or(0)
980            .saturating_sub(100);
981        let session = v2_session(past);
982        assert_eq!(session.time_until_expiry(), std::time::Duration::ZERO);
983    }
984
985    #[test]
986    fn test_is_expired_large_margin_does_not_underflow() {
987        // Tiny expires_at with an enormous margin must not underflow / panic;
988        // it simply reports the session as expired.
989        let session = v2_session(1);
990        assert!(session.is_expired(Some(u64::MAX)));
991        assert!(session.is_expired(Some(1000)));
992    }
993
994    #[test]
995    fn test_is_expired_valid_session_within_margin_still_valid() {
996        let future = u64::try_from(Utc::now().timestamp())
997            .unwrap_or(0)
998            .saturating_add(3600);
999        let session = v2_session(future);
1000        assert!(!session.is_expired(Some(60)));
1001    }
1002
1003    #[test]
1004    fn test_proactive_refresh_margin_v3_is_small_v2_is_large() {
1005        let v3 = v3_session(0);
1006        let v2 = v2_session(0);
1007        assert_eq!(
1008            proactive_refresh_margin_secs(&v3),
1009            crate::constants::PROACTIVE_REFRESH_MARGIN_V3_SECS
1010        );
1011        assert_eq!(
1012            proactive_refresh_margin_secs(&v2),
1013            crate::constants::PROACTIVE_REFRESH_MARGIN_V2_SECS
1014        );
1015        // v3's short-lived tokens get a tighter margin than v2's 6h sessions.
1016        assert!(proactive_refresh_margin_secs(&v3) < proactive_refresh_margin_secs(&v2));
1017    }
1018
1019    #[tokio::test]
1020    async fn test_refresh_token_returns_cached_valid_session_without_login() {
1021        // refresh_token has an expiry gate: a comfortably-valid session is
1022        // returned unchanged, so no network login is attempted. This is the
1023        // behaviour that differs from force_refresh (which always re-logs in).
1024        let auth =
1025            Auth::try_new(Arc::new(Config::default())).expect("auth construction should succeed");
1026        let future = u64::try_from(Utc::now().timestamp())
1027            .unwrap_or(0)
1028            .saturating_add(3600);
1029        let seeded = v2_session(future);
1030        {
1031            let mut guard = auth.session.write().await;
1032            *guard = Some(seeded);
1033        }
1034
1035        let refreshed = match auth.refresh_token().await {
1036            Ok(session) => session,
1037            Err(e) => panic!("refresh_token should return the cached session: {e}"),
1038        };
1039        // Same cached tokens returned: no re-authentication happened.
1040        assert_eq!(refreshed.account_id, "ACC123");
1041        assert_eq!(refreshed.cst.as_deref(), Some("CST-TOKEN"));
1042        assert_eq!(refreshed.expires_at, future);
1043    }
1044
1045    #[test]
1046    fn test_force_refresh_is_public_and_present() {
1047        // Compile-time proof that the additive 401-path API exists. Invoking it
1048        // would perform a real login, so it is not called here (no network in
1049        // unit tests).
1050        let _ = Auth::force_refresh;
1051    }
1052}
1053
1054#[cfg(test)]
1055mod oauth_login_guard_tests {
1056    use super::*;
1057
1058    // Captured real IG demo v2 `/session` body (same shape as the existing
1059    // deserialization tests). Deserialized through the untagged
1060    // `SessionResponse` it lands on the V2 variant, so the derived session
1061    // carries no OAuth token — exactly the mismatch `login_oauth` must reject.
1062    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}"#;
1063
1064    #[test]
1065    fn test_ensure_oauth_session_v2_body_on_v3_path_yields_unauthorized()
1066    -> Result<(), serde_json::Error> {
1067        // Deserialize a v2-shaped body the way `login_oauth` does after a v3
1068        // request, then run it through the same guard.
1069        let response: SessionResponse = serde_json::from_str(V2_BODY)?;
1070        let session = response.get_session();
1071        // A v2 body carries no OAuth token.
1072        assert!(!session.is_oauth());
1073
1074        // The guard maps this to a typed error instead of panicking (the old
1075        // `assert!(session.is_oauth())` would have aborted here).
1076        match ensure_oauth_session(session) {
1077            Err(AppError::Unauthorized) => Ok(()),
1078            other => panic!("expected AppError::Unauthorized, got {other:?}"),
1079        }
1080    }
1081
1082    #[test]
1083    fn test_ensure_oauth_session_oauth_body_passes_through() {
1084        // A genuine v3 session passes the guard unchanged.
1085        let session = Session {
1086            account_id: "ACC123".to_string(),
1087            client_id: "CLIENT1".to_string(),
1088            lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
1089            cst: None,
1090            x_security_token: None,
1091            oauth_token: Some(OAuthToken {
1092                access_token: "ACCESS".to_string(),
1093                refresh_token: "REFRESH".to_string(),
1094                scope: "read write".to_string(),
1095                token_type: "Bearer".to_string(),
1096                expires_in: "60".to_string(),
1097                created_at: Utc::now(),
1098            }),
1099            api_version: 3,
1100            expires_at: 0,
1101        };
1102        assert!(ensure_oauth_session(session).is_ok());
1103    }
1104}
1105
1106#[cfg(test)]
1107mod redaction_tests {
1108    use super::*;
1109
1110    fn secret_session() -> Session {
1111        Session {
1112            account_id: "ACC123".to_string(),
1113            client_id: "CLIENT1".to_string(),
1114            lightstreamer_endpoint: "https://ls.example.com".to_string(),
1115            cst: Some("SECRET-CST-VALUE".to_string()),
1116            x_security_token: Some("SECRET-XST-VALUE".to_string()),
1117            oauth_token: Some(OAuthToken {
1118                access_token: "SECRET-ACCESS-VALUE".to_string(),
1119                refresh_token: "SECRET-REFRESH-VALUE".to_string(),
1120                scope: "read write".to_string(),
1121                token_type: "Bearer".to_string(),
1122                expires_in: "60".to_string(),
1123                created_at: chrono::Utc::now(),
1124            }),
1125            api_version: 3,
1126            expires_at: 0,
1127        }
1128    }
1129
1130    #[test]
1131    fn test_session_debug_redacts_tokens() {
1132        let session = secret_session();
1133        let rendered = format!("{session:?}");
1134
1135        assert!(!rendered.contains("SECRET-CST-VALUE"));
1136        assert!(!rendered.contains("SECRET-XST-VALUE"));
1137        assert!(!rendered.contains("SECRET-ACCESS-VALUE"));
1138        assert!(!rendered.contains("SECRET-REFRESH-VALUE"));
1139        assert!(rendered.contains("<redacted>"));
1140        // Non-secret fields stay visible.
1141        assert!(rendered.contains("ACC123"));
1142        assert!(rendered.contains("ls.example.com"));
1143    }
1144
1145    #[test]
1146    fn test_websocket_info_debug_redacts_tokens() {
1147        let ws = WebsocketInfo {
1148            server: "https://ls.example.com/lightstreamer".to_string(),
1149            cst: Some("SECRET-CST-VALUE".to_string()),
1150            x_security_token: Some("SECRET-XST-VALUE".to_string()),
1151            account_id: "ACC123".to_string(),
1152        };
1153        let rendered = format!("{ws:?}");
1154
1155        assert!(!rendered.contains("SECRET-CST-VALUE"));
1156        assert!(!rendered.contains("SECRET-XST-VALUE"));
1157        assert!(rendered.contains("Some(<redacted>)"));
1158        assert!(rendered.contains("ACC123"));
1159        assert!(rendered.contains("ls.example.com"));
1160    }
1161
1162    #[test]
1163    fn test_websocket_info_display_redacts_tokens() {
1164        let ws = WebsocketInfo {
1165            server: "https://ls.example.com/lightstreamer".to_string(),
1166            cst: Some("SECRET-CST-VALUE".to_string()),
1167            x_security_token: Some("SECRET-XST-VALUE".to_string()),
1168            account_id: "ACC123".to_string(),
1169        };
1170        let rendered = format!("{ws}");
1171
1172        assert!(!rendered.contains("SECRET-CST-VALUE"));
1173        assert!(!rendered.contains("SECRET-XST-VALUE"));
1174        assert!(rendered.contains("<redacted>"));
1175        assert!(rendered.contains("ACC123"));
1176
1177        // `None` tokens render as `None`, not as a redacted placeholder.
1178        let ws_none = WebsocketInfo {
1179            server: "https://ls.example.com/lightstreamer".to_string(),
1180            cst: None,
1181            x_security_token: None,
1182            account_id: "ACC123".to_string(),
1183        };
1184        assert!(format!("{ws_none}").contains("None"));
1185    }
1186}