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        let sess = self.login_v2().await.ok();
200        match sess {
201            Some(sess) => sess.get_websocket_info(),
202            None => WebsocketInfo::default(),
203        }
204    }
205
206    /// Gets the current session, ensuring tokens are valid
207    ///
208    /// This method automatically refreshes expired OAuth tokens or re-authenticates if needed.
209    ///
210    /// # Returns
211    /// * `Ok(Session)` - Valid session with fresh tokens
212    /// * `Err(AppError)` - If authentication fails
213    pub async fn get_session(&self) -> Result<Session, AppError> {
214        let session = self.session.read().await;
215
216        if let Some(sess) = session.as_ref() {
217            // Check if OAuth token needs refresh
218            if sess.needs_token_refresh(Some(300)) {
219                drop(session); // Release read lock
220                debug!("OAuth token needs refresh");
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
252        let mut sess = self.session.write().await;
253        *sess = Some(session.clone());
254
255        info!("✓ Login successful, account: {}", session.account_id);
256        Ok(session)
257    }
258
259    /// Performs login using API v2 (CST/X-SECURITY-TOKEN) with automatic retry on rate limit
260    async fn login_v2(&self) -> Result<Session, AppError> {
261        let url = format!("{}/session", self.config.rest_api.base_url);
262
263        let body = serde_json::json!({
264            "identifier": self.config.credentials.username,
265            "password": self.config.credentials.password,
266        });
267
268        debug!("Sending v2 login request to: {}", url);
269
270        let headers = vec![
271            ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
272            ("Content-Type", "application/json"),
273            ("Version", "2"),
274        ];
275
276        let response = make_http_request(
277            &self.client,
278            self.rate_limiter.clone(),
279            Method::POST,
280            &url,
281            headers,
282            &Some(body),
283            RetryConfig::infinite(),
284        )
285        .await?;
286
287        // Extract CST and X-SECURITY-TOKEN from headers
288        let cst: String = match response
289            .headers()
290            .get("CST")
291            .and_then(|v| v.to_str().ok())
292            .map(String::from)
293        {
294            Some(token) => token,
295            None => {
296                error!("CST header not found in response");
297                return Err(AppError::InvalidInput("CST missing".to_string()));
298            }
299        };
300        let x_security_token: String = match response
301            .headers()
302            .get("X-SECURITY-TOKEN")
303            .and_then(|v| v.to_str().ok())
304            .map(String::from)
305        {
306            Some(token) => token,
307            None => {
308                error!("X-SECURITY-TOKEN header not found in response");
309                return Err(AppError::InvalidInput(
310                    "X-SECURITY-TOKEN missing".to_string(),
311                ));
312            }
313        };
314
315        let x_ig_api_key: String = response
316            .headers()
317            .get("X-IG-API-KEY")
318            .and_then(|v| v.to_str().ok())
319            .map(String::from)
320            .unwrap_or_else(|| self.config.credentials.api_key.clone());
321
322        let security_headers: SecurityHeaders = SecurityHeaders {
323            cst,
324            x_security_token,
325            x_ig_api_key,
326        };
327
328        let mut response: SessionResponse = response.json().await?;
329        let session = response.get_session_v2(&security_headers);
330
331        Ok(session)
332    }
333
334    /// Performs login using API v3 (OAuth) with automatic retry on rate limit
335    async fn login_oauth(&self) -> Result<Session, AppError> {
336        let url = format!("{}/session", self.config.rest_api.base_url);
337
338        let body = serde_json::json!({
339            "identifier": self.config.credentials.username,
340            "password": self.config.credentials.password,
341        });
342
343        debug!("Sending OAuth login request to: {}", url);
344        let headers = vec![
345            ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
346            ("Content-Type", "application/json"),
347            ("Version", "3"),
348        ];
349
350        let response = make_http_request(
351            &self.client,
352            self.rate_limiter.clone(),
353            Method::POST,
354            &url,
355            headers,
356            &Some(body),
357            RetryConfig::infinite(),
358        )
359        .await?;
360
361        let response: SessionResponse = response.json().await?;
362        let mut session = response.get_session();
363        if session.account_id != self.config.credentials.account_id {
364            session.account_id = self.config.credentials.account_id.clone();
365        };
366
367        assert!(session.is_oauth());
368
369        Ok(session)
370    }
371
372    /// Refreshes an expired OAuth token with automatic retry on rate limit
373    ///
374    /// If refresh fails (e.g., refresh token expired), performs full re-authentication.
375    ///
376    /// # Returns
377    /// * `Ok(Session)` - New session with refreshed tokens
378    /// * `Err(AppError)` - If refresh and re-authentication both fail
379    pub async fn refresh_token(&self) -> Result<Session, AppError> {
380        let current_session = {
381            let session = self.session.read().await;
382            session.clone()
383        };
384
385        if let Some(sess) = current_session {
386            if sess.is_expired(Some(1)) {
387                debug!("Session expired, performing login");
388                self.login().await
389            } else {
390                Ok(sess)
391            }
392        } else {
393            warn!("No session to refresh, performing login");
394            self.login().await
395        }
396    }
397
398    /// Switches to a different trading account
399    ///
400    /// # Arguments
401    /// * `account_id` - The account ID to switch to
402    /// * `default_account` - Whether to set as default account
403    ///
404    /// # Returns
405    /// * `Ok(Session)` - New session for the switched account
406    /// * `Err(AppError)` - If account switch fails
407    pub async fn switch_account(
408        &self,
409        account_id: &str,
410        default_account: Option<bool>,
411    ) -> Result<Session, AppError> {
412        let current_session = self.get_session().await?;
413        if matches!(current_session.api_version, 3) {
414            return Err(AppError::InvalidInput(
415                "Cannot switch accounts with OAuth".to_string(),
416            ));
417        }
418
419        if current_session.account_id == account_id {
420            debug!("Already on account {}", account_id);
421            return Ok(current_session);
422        }
423
424        info!("Switching to account: {}", account_id);
425
426        let url = format!("{}/session", self.config.rest_api.base_url);
427
428        let mut body = serde_json::json!({
429            "accountId": account_id,
430        });
431
432        if let Some(default) = default_account {
433            body["defaultAccount"] = serde_json::json!(default);
434        }
435
436        // Build headers with authentication
437        let api_key = self.config.credentials.api_key.clone();
438        let auth_header_value;
439        let cst;
440        let x_security_token;
441
442        let mut headers = vec![
443            ("X-IG-API-KEY", api_key.as_str()),
444            ("Content-Type", "application/json"),
445            ("Version", "1"),
446        ];
447
448        // Add authentication headers based on session type
449        if let Some(oauth) = &current_session.oauth_token {
450            auth_header_value = format!("Bearer {}", oauth.access_token);
451            headers.push(("Authorization", auth_header_value.as_str()));
452        } else {
453            if let Some(cst_val) = &current_session.cst {
454                cst = cst_val.clone();
455                headers.push(("CST", cst.as_str()));
456            }
457            if let Some(token_val) = &current_session.x_security_token {
458                x_security_token = token_val.clone();
459                headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
460            }
461        }
462
463        let _response = make_http_request(
464            &self.client,
465            self.rate_limiter.clone(),
466            Method::PUT,
467            &url,
468            headers,
469            &Some(body),
470            RetryConfig::infinite(),
471        )
472        .await?;
473
474        // After switching, update the session
475        let mut new_session = current_session.clone();
476        new_session.account_id = account_id.to_string();
477
478        let mut session = self.session.write().await;
479        *session = Some(new_session.clone());
480
481        info!("✓ Switched to account: {}", account_id);
482        Ok(new_session)
483    }
484
485    /// Logs out and clears the current session
486    pub async fn logout(&self) -> Result<(), AppError> {
487        info!("Logging out");
488
489        let mut session = self.session.write().await;
490        *session = None;
491
492        info!("✓ Logged out successfully");
493        Ok(())
494    }
495}