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::rate_limiter::RateLimiter;
17use crate::error::AppError;
18pub(crate) use crate::model::auth::{OAuthToken, SecurityHeaders, SessionResponse};
19use crate::model::http::make_http_request;
20use crate::model::retry::RetryConfig;
21use crate::prelude::Deserialize;
22use chrono::Utc;
23use pretty_simple_display::{DebugPretty, DisplaySimple};
24use reqwest::{Client, Method};
25use serde::Serialize;
26use std::sync::Arc;
27use tokio::sync::RwLock;
28use tracing::{debug, error, info, warn};
29
30const USER_AGENT: &str = "ig-client/0.6.0";
31
32/// WebSocket connection information for Lightstreamer
33///
34/// Contains the necessary credentials and endpoint information
35/// to establish a WebSocket connection to IG's Lightstreamer service.
36#[derive(DebugPretty, Clone, Default, Serialize, Deserialize, DisplaySimple)]
37pub struct WebsocketInfo {
38    /// Lightstreamer endpoint URL
39    pub server: String,
40    /// CST token for authentication (API v2)
41    pub cst: Option<String>,
42    /// X-SECURITY-TOKEN for authentication (API v2)
43    pub x_security_token: Option<String>,
44    /// Account ID for the WebSocket connection
45    pub account_id: String,
46}
47
48impl WebsocketInfo {
49    /// Generates the WebSocket password for Lightstreamer authentication
50    ///
51    /// # Returns
52    /// * Password in format "CST-{cst}|XST-{token}" if both tokens are available
53    /// * Empty string if tokens are not available
54    #[must_use]
55    pub fn get_ws_password(&self) -> String {
56        match (&self.cst, &self.x_security_token) {
57            (Some(cst), Some(x_security_token)) => {
58                format!("CST-{}|XST-{}", cst, x_security_token)
59            }
60            _ => String::new(),
61        }
62    }
63}
64
65/// Session information for authenticated requests
66#[derive(Debug, Clone)]
67pub struct Session {
68    /// Account ID
69    pub account_id: String,
70    /// Client ID (for OAuth)
71    pub client_id: String,
72    /// Lightstreamer endpoint
73    pub lightstreamer_endpoint: String,
74    /// CST token (API v2)
75    pub cst: Option<String>,
76    /// X-SECURITY-TOKEN (API v2)
77    pub x_security_token: Option<String>,
78    /// OAuth token (API v3)
79    pub oauth_token: Option<OAuthToken>,
80    /// API version used
81    pub api_version: u8,
82    /// Unix timestamp when session expires (seconds since epoch)
83    /// - OAuth (v3): expires in 30 seconds
84    /// - API v2: expires in 6 hours (21600 seconds)
85    pub expires_at: u64,
86}
87
88impl Session {
89    /// Checks if this session uses OAuth authentication
90    #[must_use]
91    #[inline]
92    pub fn is_oauth(&self) -> bool {
93        self.oauth_token.is_some()
94    }
95
96    /// Checks if session is expired or will expire soon
97    ///
98    /// # Arguments
99    /// * `margin_seconds` - Safety margin in seconds (default: 60 = 1 minute)
100    ///
101    /// # Returns
102    /// * `true` if session is expired or will expire within margin
103    /// * `false` if session is still valid
104    #[must_use]
105    #[inline]
106    pub fn is_expired(&self, margin_seconds: Option<u64>) -> bool {
107        let margin = margin_seconds.unwrap_or(60);
108        let now = Utc::now().timestamp() as u64;
109        now >= (self.expires_at - margin)
110    }
111
112    /// Gets the number of seconds until session expires
113    ///
114    /// # Returns
115    /// * Positive number if session is still valid
116    /// * Negative number if session is already expired
117    #[must_use]
118    #[inline]
119    pub fn seconds_until_expiry(&self) -> u64 {
120        self.expires_at - Utc::now().timestamp() as u64
121    }
122
123    /// Checks if OAuth token needs refresh (alias for is_expired for backwards compatibility)
124    ///
125    /// # Arguments
126    /// * `margin_seconds` - Safety margin in seconds (default: 60 = 1 minute)
127    #[must_use]
128    #[inline]
129    pub fn needs_token_refresh(&self, margin_seconds: Option<u64>) -> bool {
130        self.is_expired(margin_seconds)
131    }
132
133    /// Extracts WebSocket connection information from the session
134    ///
135    /// # Returns
136    /// * `WebsocketInfo` containing endpoint and authentication tokens
137    #[must_use]
138    pub fn get_websocket_info(&self) -> WebsocketInfo {
139        // Ensure the server URL has the https:// prefix
140        let server = if self.lightstreamer_endpoint.starts_with("http://")
141            || self.lightstreamer_endpoint.starts_with("https://")
142        {
143            format!("{}/lightstreamer", self.lightstreamer_endpoint)
144        } else {
145            format!("https://{}/lightstreamer", self.lightstreamer_endpoint)
146        };
147
148        WebsocketInfo {
149            server,
150            cst: self.cst.clone(),
151            x_security_token: self.x_security_token.clone(),
152            account_id: self.account_id.clone(),
153        }
154    }
155}
156
157impl From<SessionResponse> for Session {
158    fn from(v: SessionResponse) -> Self {
159        v.get_session()
160    }
161}
162
163/// Authentication manager for IG Markets API
164///
165/// Handles all authentication operations including:
166/// - Login with API v2 or v3
167/// - Automatic OAuth token refresh
168/// - Account switching
169/// - Session management
170/// - Rate limiting for API requests
171pub struct Auth {
172    config: Arc<Config>,
173    client: Client,
174    session: Arc<RwLock<Option<Session>>>,
175    rate_limiter: Arc<RwLock<RateLimiter>>,
176}
177
178impl Auth {
179    /// Creates a new Auth instance
180    ///
181    /// # Arguments
182    /// * `config` - Configuration containing credentials and API settings
183    pub fn new(config: Arc<Config>) -> Self {
184        let client = Client::builder()
185            .user_agent(USER_AGENT)
186            .build()
187            .expect("Failed to create HTTP client");
188
189        let rate_limiter = Arc::new(RwLock::new(RateLimiter::new(&config.rate_limiter)));
190
191        Self {
192            config,
193            client,
194            session: Arc::new(RwLock::new(None)),
195            rate_limiter,
196        }
197    }
198
199    /// Gets the WebSocket password for Lightstreamer authentication
200    ///
201    /// # Returns
202    /// * WebSocket password in format "CST-{cst}|XST-{token}" or empty string if session is not available
203    pub async fn get_ws_info(&self) -> WebsocketInfo {
204        match self.login_v2().await {
205            Ok(sess) => sess.get_websocket_info(),
206            Err(e) => {
207                error!("Failed to get WebSocket info, login failed: {}", e);
208                WebsocketInfo::default()
209            }
210        }
211    }
212
213    /// Gets the current session, ensuring tokens are valid
214    ///
215    /// This method automatically refreshes expired OAuth tokens or re-authenticates if needed.
216    ///
217    /// # Returns
218    /// * `Ok(Session)` - Valid session with fresh tokens
219    /// * `Err(AppError)` - If authentication fails
220    pub async fn get_session(&self) -> Result<Session, AppError> {
221        let session = self.session.read().await;
222
223        if let Some(sess) = session.as_ref() {
224            // Check if OAuth token needs refresh
225            if sess.needs_token_refresh(Some(300)) {
226                drop(session); // Release read lock
227                debug!("OAuth token needs refresh");
228                return self.refresh_token().await;
229            }
230            return Ok(sess.clone());
231        }
232
233        drop(session);
234
235        // No session exists, need to login
236        info!("No active session, logging in");
237        self.login().await
238    }
239
240    /// Performs initial login to IG Markets API
241    ///
242    /// Automatically detects API version from config and uses appropriate authentication method.
243    ///
244    /// # Returns
245    /// * `Ok(Session)` - Authenticated session
246    /// * `Err(AppError)` - If login fails
247    pub async fn login(&self) -> Result<Session, AppError> {
248        let api_version = self.config.api_version.unwrap_or(2);
249
250        debug!("Logging in with API v{}", api_version);
251
252        let session = if api_version == 3 {
253            self.login_oauth().await?
254        } else {
255            self.login_v2().await?
256        };
257
258        // Store session
259        let mut sess = self.session.write().await;
260        *sess = Some(session.clone());
261
262        info!("✓ Login successful, account: {}", session.account_id);
263        Ok(session)
264    }
265
266    /// Performs login using API v2 (CST/X-SECURITY-TOKEN) with automatic retry on rate limit
267    async fn login_v2(&self) -> Result<Session, AppError> {
268        let url = format!("{}/session", self.config.rest_api.base_url);
269
270        let body = serde_json::json!({
271            "identifier": self.config.credentials.username,
272            "password": self.config.credentials.password,
273        });
274
275        debug!("Sending v2 login request to: {}", url);
276
277        let headers = vec![
278            ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
279            ("Content-Type", "application/json"),
280            ("Version", "2"),
281        ];
282
283        let response = make_http_request(
284            &self.client,
285            self.rate_limiter.clone(),
286            Method::POST,
287            &url,
288            headers,
289            &Some(body),
290            RetryConfig::infinite(),
291        )
292        .await?;
293
294        // Extract CST and X-SECURITY-TOKEN from headers
295        let cst: String = match response
296            .headers()
297            .get("CST")
298            .and_then(|v| v.to_str().ok())
299            .map(String::from)
300        {
301            Some(token) => token,
302            None => {
303                error!("CST header not found in response");
304                return Err(AppError::InvalidInput("CST missing".to_string()));
305            }
306        };
307        let x_security_token: String = match response
308            .headers()
309            .get("X-SECURITY-TOKEN")
310            .and_then(|v| v.to_str().ok())
311            .map(String::from)
312        {
313            Some(token) => token,
314            None => {
315                error!("X-SECURITY-TOKEN header not found in response");
316                return Err(AppError::InvalidInput(
317                    "X-SECURITY-TOKEN missing".to_string(),
318                ));
319            }
320        };
321
322        let x_ig_api_key: String = response
323            .headers()
324            .get("X-IG-API-KEY")
325            .and_then(|v| v.to_str().ok())
326            .map(String::from)
327            .unwrap_or_else(|| self.config.credentials.api_key.clone());
328
329        let security_headers: SecurityHeaders = SecurityHeaders {
330            cst,
331            x_security_token,
332            x_ig_api_key,
333        };
334
335        // Get response body as text first for debugging
336        let body_text = response.text().await.map_err(|e| {
337            error!("Failed to read response body: {}", e);
338            AppError::Network(e)
339        })?;
340        debug!("Login response body length: {} bytes", body_text.len());
341
342        // Parse the JSON
343        let mut response: SessionResponse = serde_json::from_str(&body_text).map_err(|e| {
344            error!("Failed to parse login response JSON: {}", e);
345            error!("Response body: {}", body_text);
346            AppError::Deserialization(format!("Failed to parse login response: {}", e))
347        })?;
348        let session = response.get_session_v2(&security_headers);
349
350        Ok(session)
351    }
352
353    /// Performs login using API v3 (OAuth) with automatic retry on rate limit
354    async fn login_oauth(&self) -> Result<Session, AppError> {
355        let url = format!("{}/session", self.config.rest_api.base_url);
356
357        let body = serde_json::json!({
358            "identifier": self.config.credentials.username,
359            "password": self.config.credentials.password,
360        });
361
362        debug!("Sending OAuth login request to: {}", url);
363        let headers = vec![
364            ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
365            ("Content-Type", "application/json"),
366            ("Version", "3"),
367        ];
368
369        let response = make_http_request(
370            &self.client,
371            self.rate_limiter.clone(),
372            Method::POST,
373            &url,
374            headers,
375            &Some(body),
376            RetryConfig::infinite(),
377        )
378        .await?;
379
380        let response: SessionResponse = response.json().await?;
381        let mut session = response.get_session();
382        if session.account_id != self.config.credentials.account_id {
383            session.account_id = self.config.credentials.account_id.clone();
384        };
385
386        assert!(session.is_oauth());
387
388        Ok(session)
389    }
390
391    /// Refreshes an expired OAuth token with automatic retry on rate limit
392    ///
393    /// If refresh fails (e.g., refresh token expired), performs full re-authentication.
394    ///
395    /// # Returns
396    /// * `Ok(Session)` - New session with refreshed tokens
397    /// * `Err(AppError)` - If refresh and re-authentication both fail
398    pub async fn refresh_token(&self) -> Result<Session, AppError> {
399        let current_session = {
400            let session = self.session.read().await;
401            session.clone()
402        };
403
404        if let Some(sess) = current_session {
405            if sess.is_expired(Some(1)) {
406                debug!("Session expired, performing login");
407                self.login().await
408            } else {
409                Ok(sess)
410            }
411        } else {
412            warn!("No session to refresh, performing login");
413            self.login().await
414        }
415    }
416
417    /// Switches to a different trading account
418    ///
419    /// # Arguments
420    /// * `account_id` - The account ID to switch to
421    /// * `default_account` - Whether to set as default account
422    ///
423    /// # Returns
424    /// * `Ok(Session)` - New session for the switched account
425    /// * `Err(AppError)` - If account switch fails
426    pub async fn switch_account(
427        &self,
428        account_id: &str,
429        default_account: Option<bool>,
430    ) -> Result<Session, AppError> {
431        let current_session = self.get_session().await?;
432        if matches!(current_session.api_version, 3) {
433            return Err(AppError::InvalidInput(
434                "Cannot switch accounts with OAuth".to_string(),
435            ));
436        }
437
438        if current_session.account_id == account_id {
439            debug!("Already on account {}", account_id);
440            return Ok(current_session);
441        }
442
443        info!("Switching to account: {}", account_id);
444
445        let url = format!("{}/session", self.config.rest_api.base_url);
446
447        let mut body = serde_json::json!({
448            "accountId": account_id,
449        });
450
451        if let Some(default) = default_account {
452            body["defaultAccount"] = serde_json::json!(default);
453        }
454
455        // Build headers with authentication
456        let api_key = self.config.credentials.api_key.clone();
457        let auth_header_value;
458        let cst;
459        let x_security_token;
460
461        let mut headers = vec![
462            ("X-IG-API-KEY", api_key.as_str()),
463            ("Content-Type", "application/json"),
464            ("Version", "1"),
465        ];
466
467        // Add authentication headers based on session type
468        if let Some(oauth) = &current_session.oauth_token {
469            auth_header_value = format!("Bearer {}", oauth.access_token);
470            headers.push(("Authorization", auth_header_value.as_str()));
471        } else {
472            if let Some(cst_val) = &current_session.cst {
473                cst = cst_val.clone();
474                headers.push(("CST", cst.as_str()));
475            }
476            if let Some(token_val) = &current_session.x_security_token {
477                x_security_token = token_val.clone();
478                headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
479            }
480        }
481
482        let _response = make_http_request(
483            &self.client,
484            self.rate_limiter.clone(),
485            Method::PUT,
486            &url,
487            headers,
488            &Some(body),
489            RetryConfig::infinite(),
490        )
491        .await?;
492
493        // After switching, update the session
494        let mut new_session = current_session.clone();
495        new_session.account_id = account_id.to_string();
496
497        let mut session = self.session.write().await;
498        *session = Some(new_session.clone());
499
500        info!("✓ Switched to account: {}", account_id);
501        Ok(new_session)
502    }
503
504    /// Logs out and clears the current session
505    pub async fn logout(&self) -> Result<(), AppError> {
506        info!("Logging out");
507
508        let mut session = self.session.write().await;
509        *session = None;
510
511        info!("✓ Logged out successfully");
512        Ok(())
513    }
514}
515
516#[test]
517fn test_v2_response_deserialization() -> Result<(), serde_json::Error> {
518    let json = 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}"#;
519
520    let result: crate::model::auth::SessionResponse = serde_json::from_str(json)?;
521    println!("Success: is_v2={}", result.is_v2());
522
523    let response: crate::model::auth::V2Response = serde_json::from_str(json)?;
524    assert_eq!(response.account_type, "CFD");
525    Ok(())
526}
527
528#[test]
529fn test_v2_response_deserialization_prod() -> Result<(), serde_json::Error> {
530    let json = r#"{"accountType":"CFD","accountInfo":{"balance":18791.56,"deposit":3300.18,"profitLoss":187.42,"available":14952.68},"currencyIsoCode":"EUR","currencySymbol":"E","currentAccountId":"BS0Y3","lightstreamerEndpoint":"https://apd.marketdatasystems.com","accounts":[{"accountId":"BS0Y3","accountName":"Opciones Prod","preferred":true,"accountType":"CFD"},{"accountId":"BSI1I","accountName":"Barreras y Opciones","preferred":false,"accountType":"CFD"},{"accountId":"BSU96","accountName":"Turbos","preferred":false,"accountType":"PHYSICAL"},{"accountId":"BTCKN","accountName":"CFD","preferred":false,"accountType":"CFD"},{"accountId":"BXNIZ","accountName":"Principal","preferred":false,"accountType":"CFD"}],"clientId":"102828353","timezoneOffset":1,"hasActiveDemoAccounts":true,"hasActiveLiveAccounts":true,"trailingStopsEnabled":false,"reroutingEnvironment":null,"dealingEnabled":true}"#;
531
532    let result: crate::model::auth::SessionResponse = serde_json::from_str(json)?;
533    println!("Success: is_v2={}", result.is_v2());
534
535    let response: crate::model::auth::V2Response = serde_json::from_str(json)?;
536    assert_eq!(response.account_type, "CFD");
537    assert_eq!(response.current_account_id, "BS0Y3");
538    Ok(())
539}