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        let mut response: SessionResponse = response.json().await?;
331        let session = response.get_session_v2(&security_headers);
332
333        Ok(session)
334    }
335
336    /// Performs login using API v3 (OAuth) with automatic retry on rate limit
337    async fn login_oauth(&self) -> Result<Session, AppError> {
338        let url = format!("{}/session", self.config.rest_api.base_url);
339
340        let body = serde_json::json!({
341            "identifier": self.config.credentials.username,
342            "password": self.config.credentials.password,
343        });
344
345        debug!("Sending OAuth login request to: {}", url);
346        let headers = vec![
347            ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
348            ("Content-Type", "application/json"),
349            ("Version", "3"),
350        ];
351
352        let response = make_http_request(
353            &self.client,
354            self.rate_limiter.clone(),
355            Method::POST,
356            &url,
357            headers,
358            &Some(body),
359            RetryConfig::infinite(),
360        )
361        .await?;
362
363        let response: SessionResponse = response.json().await?;
364        let mut session = response.get_session();
365        if session.account_id != self.config.credentials.account_id {
366            session.account_id = self.config.credentials.account_id.clone();
367        };
368
369        assert!(session.is_oauth());
370
371        Ok(session)
372    }
373
374    /// Refreshes an expired OAuth token with automatic retry on rate limit
375    ///
376    /// If refresh fails (e.g., refresh token expired), performs full re-authentication.
377    ///
378    /// # Returns
379    /// * `Ok(Session)` - New session with refreshed tokens
380    /// * `Err(AppError)` - If refresh and re-authentication both fail
381    pub async fn refresh_token(&self) -> Result<Session, AppError> {
382        let current_session = {
383            let session = self.session.read().await;
384            session.clone()
385        };
386
387        if let Some(sess) = current_session {
388            if sess.is_expired(Some(1)) {
389                debug!("Session expired, performing login");
390                self.login().await
391            } else {
392                Ok(sess)
393            }
394        } else {
395            warn!("No session to refresh, performing login");
396            self.login().await
397        }
398    }
399
400    /// Switches to a different trading account
401    ///
402    /// # Arguments
403    /// * `account_id` - The account ID to switch to
404    /// * `default_account` - Whether to set as default account
405    ///
406    /// # Returns
407    /// * `Ok(Session)` - New session for the switched account
408    /// * `Err(AppError)` - If account switch fails
409    pub async fn switch_account(
410        &self,
411        account_id: &str,
412        default_account: Option<bool>,
413    ) -> Result<Session, AppError> {
414        let current_session = self.get_session().await?;
415        if matches!(current_session.api_version, 3) {
416            return Err(AppError::InvalidInput(
417                "Cannot switch accounts with OAuth".to_string(),
418            ));
419        }
420
421        if current_session.account_id == account_id {
422            debug!("Already on account {}", account_id);
423            return Ok(current_session);
424        }
425
426        info!("Switching to account: {}", account_id);
427
428        let url = format!("{}/session", self.config.rest_api.base_url);
429
430        let mut body = serde_json::json!({
431            "accountId": account_id,
432        });
433
434        if let Some(default) = default_account {
435            body["defaultAccount"] = serde_json::json!(default);
436        }
437
438        // Build headers with authentication
439        let api_key = self.config.credentials.api_key.clone();
440        let auth_header_value;
441        let cst;
442        let x_security_token;
443
444        let mut headers = vec![
445            ("X-IG-API-KEY", api_key.as_str()),
446            ("Content-Type", "application/json"),
447            ("Version", "1"),
448        ];
449
450        // Add authentication headers based on session type
451        if let Some(oauth) = &current_session.oauth_token {
452            auth_header_value = format!("Bearer {}", oauth.access_token);
453            headers.push(("Authorization", auth_header_value.as_str()));
454        } else {
455            if let Some(cst_val) = &current_session.cst {
456                cst = cst_val.clone();
457                headers.push(("CST", cst.as_str()));
458            }
459            if let Some(token_val) = &current_session.x_security_token {
460                x_security_token = token_val.clone();
461                headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
462            }
463        }
464
465        let _response = make_http_request(
466            &self.client,
467            self.rate_limiter.clone(),
468            Method::PUT,
469            &url,
470            headers,
471            &Some(body),
472            RetryConfig::infinite(),
473        )
474        .await?;
475
476        // After switching, update the session
477        let mut new_session = current_session.clone();
478        new_session.account_id = account_id.to_string();
479
480        let mut session = self.session.write().await;
481        *session = Some(new_session.clone());
482
483        info!("✓ Switched to account: {}", account_id);
484        Ok(new_session)
485    }
486
487    /// Logs out and clears the current session
488    pub async fn logout(&self) -> Result<(), AppError> {
489        info!("Logging out");
490
491        let mut session = self.session.write().await;
492        *session = None;
493
494        info!("✓ Logged out successfully");
495        Ok(())
496    }
497}