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