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        WebsocketInfo {
135            server: self.lightstreamer_endpoint.clone() + "/lightstreamer",
136            cst: self.cst.clone(),
137            x_security_token: self.x_security_token.clone(),
138            account_id: self.account_id.clone(),
139        }
140    }
141}
142
143impl From<SessionResponse> for Session {
144    fn from(v: SessionResponse) -> Self {
145        v.get_session()
146    }
147}
148
149/// Authentication manager for IG Markets API
150///
151/// Handles all authentication operations including:
152/// - Login with API v2 or v3
153/// - Automatic OAuth token refresh
154/// - Account switching
155/// - Session management
156/// - Rate limiting for API requests
157pub struct Auth {
158    config: Arc<Config>,
159    client: Client,
160    session: Arc<RwLock<Option<Session>>>,
161    rate_limiter: Arc<RwLock<RateLimiter>>,
162}
163
164impl Auth {
165    /// Creates a new Auth instance
166    ///
167    /// # Arguments
168    /// * `config` - Configuration containing credentials and API settings
169    pub fn new(config: Arc<Config>) -> Self {
170        let client = Client::builder()
171            .user_agent(USER_AGENT)
172            .build()
173            .expect("Failed to create HTTP client");
174
175        let rate_limiter = Arc::new(RwLock::new(RateLimiter::new(&config.rate_limiter)));
176
177        Self {
178            config,
179            client,
180            session: Arc::new(RwLock::new(None)),
181            rate_limiter,
182        }
183    }
184
185    /// Gets the WebSocket password for Lightstreamer authentication
186    ///
187    /// # Returns
188    /// * WebSocket password in format "CST-{cst}|XST-{token}" or empty string if session is not available
189    pub async fn get_ws_info(&self) -> WebsocketInfo {
190        let sess = self.login_v2().await.ok();
191        match sess {
192            Some(sess) => sess.get_websocket_info(),
193            None => WebsocketInfo::default(),
194        }
195    }
196
197    /// Gets the current session, ensuring tokens are valid
198    ///
199    /// This method automatically refreshes expired OAuth tokens or re-authenticates if needed.
200    ///
201    /// # Returns
202    /// * `Ok(Session)` - Valid session with fresh tokens
203    /// * `Err(AppError)` - If authentication fails
204    pub async fn get_session(&self) -> Result<Session, AppError> {
205        let session = self.session.read().await;
206
207        if let Some(sess) = session.as_ref() {
208            // Check if OAuth token needs refresh
209            if sess.needs_token_refresh(Some(300)) {
210                drop(session); // Release read lock
211                debug!("OAuth token needs refresh");
212                return self.refresh_token().await;
213            }
214            return Ok(sess.clone());
215        }
216
217        drop(session);
218
219        // No session exists, need to login
220        info!("No active session, logging in");
221        self.login().await
222    }
223
224    /// Performs initial login to IG Markets API
225    ///
226    /// Automatically detects API version from config and uses appropriate authentication method.
227    ///
228    /// # Returns
229    /// * `Ok(Session)` - Authenticated session
230    /// * `Err(AppError)` - If login fails
231    pub async fn login(&self) -> Result<Session, AppError> {
232        let api_version = self.config.api_version.unwrap_or(2);
233
234        debug!("Logging in with API v{}", api_version);
235
236        let session = if api_version == 3 {
237            self.login_oauth().await?
238        } else {
239            self.login_v2().await?
240        };
241
242        // Store session
243        let mut sess = self.session.write().await;
244        *sess = Some(session.clone());
245
246        info!("✓ Login successful, account: {}", session.account_id);
247        Ok(session)
248    }
249
250    /// Performs login using API v2 (CST/X-SECURITY-TOKEN) with automatic retry on rate limit
251    async fn login_v2(&self) -> Result<Session, AppError> {
252        let url = format!("{}/session", self.config.rest_api.base_url);
253
254        let body = serde_json::json!({
255            "identifier": self.config.credentials.username,
256            "password": self.config.credentials.password,
257        });
258
259        debug!("Sending v2 login request to: {}", url);
260
261        let headers = vec![
262            ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
263            ("Content-Type", "application/json"),
264            ("Version", "2"),
265        ];
266
267        let response = make_http_request(
268            &self.client,
269            self.rate_limiter.clone(),
270            Method::POST,
271            &url,
272            headers,
273            &Some(body),
274            RetryConfig::infinite(),
275        )
276        .await?;
277
278        // Extract CST and X-SECURITY-TOKEN from headers
279        let cst: String = match response
280            .headers()
281            .get("CST")
282            .and_then(|v| v.to_str().ok())
283            .map(String::from)
284        {
285            Some(token) => token,
286            None => {
287                error!("CST header not found in response");
288                return Err(AppError::InvalidInput("CST missing".to_string()));
289            }
290        };
291        let x_security_token: String = match response
292            .headers()
293            .get("X-SECURITY-TOKEN")
294            .and_then(|v| v.to_str().ok())
295            .map(String::from)
296        {
297            Some(token) => token,
298            None => {
299                error!("X-SECURITY-TOKEN header not found in response");
300                return Err(AppError::InvalidInput(
301                    "X-SECURITY-TOKEN missing".to_string(),
302                ));
303            }
304        };
305
306        let x_ig_api_key: String = response
307            .headers()
308            .get("X-IG-API-KEY")
309            .and_then(|v| v.to_str().ok())
310            .map(String::from)
311            .unwrap_or_else(|| self.config.credentials.api_key.clone());
312
313        let security_headers: SecurityHeaders = SecurityHeaders {
314            cst,
315            x_security_token,
316            x_ig_api_key,
317        };
318
319        let mut response: SessionResponse = response.json().await?;
320        let session = response.get_session_v2(&security_headers);
321
322        Ok(session)
323    }
324
325    /// Performs login using API v3 (OAuth) with automatic retry on rate limit
326    async fn login_oauth(&self) -> Result<Session, AppError> {
327        let url = format!("{}/session", self.config.rest_api.base_url);
328
329        let body = serde_json::json!({
330            "identifier": self.config.credentials.username,
331            "password": self.config.credentials.password,
332        });
333
334        debug!("Sending OAuth login request to: {}", url);
335
336        let headers = vec![
337            ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
338            ("Content-Type", "application/json"),
339            ("Version", "3"),
340        ];
341
342        let response = make_http_request(
343            &self.client,
344            self.rate_limiter.clone(),
345            Method::POST,
346            &url,
347            headers,
348            &Some(body),
349            RetryConfig::infinite(),
350        )
351        .await?;
352
353        let response: SessionResponse = response.json().await?;
354        let session = response.get_session();
355        assert!(session.is_oauth());
356
357        Ok(session)
358    }
359
360    /// Refreshes an expired OAuth token with automatic retry on rate limit
361    ///
362    /// If refresh fails (e.g., refresh token expired), performs full re-authentication.
363    ///
364    /// # Returns
365    /// * `Ok(Session)` - New session with refreshed tokens
366    /// * `Err(AppError)` - If refresh and re-authentication both fail
367    pub async fn refresh_token(&self) -> Result<Session, AppError> {
368        let current_session = {
369            let session = self.session.read().await;
370            session.clone()
371        };
372
373        if let Some(sess) = current_session {
374            if sess.is_expired(Some(1)) {
375                debug!("Session expired, performing login");
376                self.login().await
377            } else {
378                Ok(sess)
379            }
380        } else {
381            warn!("No session to refresh, performing login");
382            self.login().await
383        }
384    }
385
386    /// Switches to a different trading account
387    ///
388    /// # Arguments
389    /// * `account_id` - The account ID to switch to
390    /// * `default_account` - Whether to set as default account
391    ///
392    /// # Returns
393    /// * `Ok(Session)` - New session for the switched account
394    /// * `Err(AppError)` - If account switch fails
395    pub async fn switch_account(
396        &self,
397        account_id: &str,
398        default_account: Option<bool>,
399    ) -> Result<Session, AppError> {
400        let current_session = self.get_session().await?;
401        if matches!(current_session.api_version, 3) {
402            return Err(AppError::InvalidInput(
403                "Cannot switch accounts with OAuth".to_string(),
404            ));
405        }
406
407        if current_session.account_id == account_id {
408            debug!("Already on account {}", account_id);
409            return Ok(current_session);
410        }
411
412        info!("Switching to account: {}", account_id);
413
414        let url = format!("{}/session", self.config.rest_api.base_url);
415
416        let mut body = serde_json::json!({
417            "accountId": account_id,
418        });
419
420        if let Some(default) = default_account {
421            body["defaultAccount"] = serde_json::json!(default);
422        }
423
424        // Build headers with authentication
425        let api_key = self.config.credentials.api_key.clone();
426        let auth_header_value;
427        let cst;
428        let x_security_token;
429
430        let mut headers = vec![
431            ("X-IG-API-KEY", api_key.as_str()),
432            ("Content-Type", "application/json"),
433            ("Version", "1"),
434        ];
435
436        // Add authentication headers based on session type
437        if let Some(oauth) = &current_session.oauth_token {
438            auth_header_value = format!("Bearer {}", oauth.access_token);
439            headers.push(("Authorization", auth_header_value.as_str()));
440        } else {
441            if let Some(cst_val) = &current_session.cst {
442                cst = cst_val.clone();
443                headers.push(("CST", cst.as_str()));
444            }
445            if let Some(token_val) = &current_session.x_security_token {
446                x_security_token = token_val.clone();
447                headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
448            }
449        }
450
451        let _response = make_http_request(
452            &self.client,
453            self.rate_limiter.clone(),
454            Method::PUT,
455            &url,
456            headers,
457            &Some(body),
458            RetryConfig::infinite(),
459        )
460        .await?;
461
462        // After switching, update the session
463        let mut new_session = current_session.clone();
464        new_session.account_id = account_id.to_string();
465
466        let mut session = self.session.write().await;
467        *session = Some(new_session.clone());
468
469        info!("✓ Switched to account: {}", account_id);
470        Ok(new_session)
471    }
472
473    /// Logs out and clears the current session
474    pub async fn logout(&self) -> Result<(), AppError> {
475        info!("Logging out");
476
477        let mut session = self.session.write().await;
478        *session = None;
479
480        info!("✓ Logged out successfully");
481        Ok(())
482    }
483}