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