ig_client/session/
auth.rs

1// Authentication module for IG Markets API
2
3use crate::constants::USER_AGENT;
4use crate::{
5    config::Config,
6    error::AuthError,
7    session::interface::{IgAuthenticator, IgSession},
8    session::response::{AccountSwitchRequest, AccountSwitchResponse, SessionResp},
9    utils::rate_limiter::app_non_trading_limiter,
10};
11use async_trait::async_trait;
12use rand;
13use reqwest::{Client, StatusCode};
14use std::time::Duration;
15use tracing::{debug, error, info, trace, warn};
16
17/// Authentication handler for IG Markets API
18pub struct IgAuth<'a> {
19    pub(crate) cfg: &'a Config,
20    http: Client,
21}
22
23impl<'a> IgAuth<'a> {
24    /// Creates a new IG authentication handler
25    ///
26    /// # Arguments
27    /// * `cfg` - Reference to the configuration
28    ///
29    /// # Returns
30    /// * A new IgAuth instance
31    pub fn new(cfg: &'a Config) -> Self {
32        Self {
33            cfg,
34            http: Client::builder()
35                .user_agent(USER_AGENT)
36                .build()
37                .expect("reqwest client"),
38        }
39    }
40
41    /// Returns the correct base URL (demo vs live) according to the configuration
42    fn rest_url(&self, path: &str) -> String {
43        format!(
44            "{}/{}",
45            self.cfg.rest_api.base_url.trim_end_matches('/'),
46            path.trim_start_matches('/')
47        )
48    }
49
50    /// Retrieves a reference to the `Client` instance.
51    ///
52    /// This method returns a reference to the `Client` object,
53    /// which is typically used for making HTTP requests or interacting
54    /// with other network-related services.
55    ///
56    /// # Returns
57    ///
58    /// * `&Client` - A reference to the internally stored `Client` object.
59    ///
60    #[allow(dead_code)]
61    fn get_client(&self) -> &Client {
62        &self.http
63    }
64}
65
66#[async_trait]
67impl IgAuthenticator for IgAuth<'_> {
68    async fn login(&self) -> Result<IgSession, AuthError> {
69        // Configuration for retries
70        const MAX_RETRIES: u32 = 3;
71        const INITIAL_RETRY_DELAY_MS: u64 = 10000; // 10 seconds
72
73        let mut retry_count = 0;
74        let mut retry_delay_ms = INITIAL_RETRY_DELAY_MS;
75
76        loop {
77            // Use the global app rate limiter for unauthenticated requests
78            let limiter = app_non_trading_limiter();
79            limiter.wait().await;
80
81            // Following the exact approach from trading-ig Python library
82            let url = self.rest_url("session");
83
84            // Ensure the API key is trimmed and has no whitespace
85            let api_key = self.cfg.credentials.api_key.trim();
86            let username = self.cfg.credentials.username.trim();
87            let password = self.cfg.credentials.password.trim();
88
89            // Log the request details for debugging
90            debug!("Login request to URL: {}", url);
91            debug!("Using API key (length): {}", api_key.len());
92            debug!("Using username: {}", username);
93
94            if retry_count > 0 {
95                debug!("Retry attempt {} of {}", retry_count, MAX_RETRIES);
96            }
97
98            // Create the body exactly as in the Python library
99            let body = serde_json::json!({
100                "identifier": username,
101                "password": password,
102                "encryptedPassword": false
103            });
104
105            debug!(
106                "Request body: {}",
107                serde_json::to_string(&body).unwrap_or_default()
108            );
109
110            // Create a new client for each request to avoid any potential issues with cached state
111            let client = Client::builder()
112                .user_agent(USER_AGENT)
113                .build()
114                .expect("reqwest client");
115
116            // Add headers exactly as in the Python library
117            let resp = match client
118                .post(url.clone())
119                .header("X-IG-API-KEY", api_key)
120                .header("Content-Type", "application/json; charset=UTF-8")
121                .header("Accept", "application/json; charset=UTF-8")
122                .header("Version", "2")
123                .json(&body)
124                .send()
125                .await
126            {
127                Ok(resp) => resp,
128                Err(e) => {
129                    error!("Failed to send login request: {}", e);
130                    return Err(AuthError::Unexpected(StatusCode::INTERNAL_SERVER_ERROR));
131                }
132            };
133
134            // Log the response status and headers for debugging
135            debug!("Login response status: {}", resp.status());
136            trace!("Response headers: {:#?}", resp.headers());
137
138            match resp.status() {
139                StatusCode::OK => {
140                    // Extract CST and X-SECURITY-TOKEN from headers
141                    let cst = match resp.headers().get("CST") {
142                        Some(value) => {
143                            let cst_str = value
144                                .to_str()
145                                .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
146                            debug!(
147                                "Successfully obtained CST token of length: {}",
148                                cst_str.len()
149                            );
150                            cst_str.to_owned()
151                        }
152                        None => {
153                            error!("CST header not found in response");
154                            return Err(AuthError::Unexpected(StatusCode::OK));
155                        }
156                    };
157
158                    let token = match resp.headers().get("X-SECURITY-TOKEN") {
159                        Some(value) => {
160                            let token_str = value
161                                .to_str()
162                                .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
163                            debug!(
164                                "Successfully obtained X-SECURITY-TOKEN of length: {}",
165                                token_str.len()
166                            );
167                            token_str.to_owned()
168                        }
169                        None => {
170                            error!("X-SECURITY-TOKEN header not found in response");
171                            return Err(AuthError::Unexpected(StatusCode::OK));
172                        }
173                    };
174
175                    // Extract account ID from the response
176                    let json: SessionResp = resp.json().await?;
177                    let account_id = json.account_id.clone();
178
179                    // Return a new session with the CST, token, and account ID
180                    // Use the rate limit type and safety margin from the config
181                    let session =
182                        IgSession::from_config(cst.clone(), token.clone(), account_id, self.cfg);
183
184                    // Log rate limiter stats if available
185                    if let Some(stats) = session.get_rate_limit_stats().await {
186                        debug!("Rate limiter initialized: {}", stats);
187                    }
188
189                    return Ok(session);
190                }
191                StatusCode::UNAUTHORIZED => {
192                    error!("Authentication failed with UNAUTHORIZED");
193                    let body = resp
194                        .text()
195                        .await
196                        .unwrap_or_else(|_| "Could not read response body".to_string());
197                    error!("Response body: {}", body);
198                    return Err(AuthError::BadCredentials);
199                }
200                StatusCode::FORBIDDEN => {
201                    error!("Authentication failed with FORBIDDEN");
202                    let body = resp
203                        .text()
204                        .await
205                        .unwrap_or_else(|_| "Could not read response body".to_string());
206
207                    if body.contains("exceeded-api-key-allowance") {
208                        error!("Rate Limit Exceeded: {}", &body);
209
210                        // Implementing retry with exponential backoff for this specific case
211                        if retry_count < MAX_RETRIES {
212                            retry_count += 1;
213                            // Using a longer delay and adding some randomness to avoid patterns
214                            let jitter = rand::random::<u64>() % 5000; // Hasta 5 segundos de jitter
215                            let delay = retry_delay_ms + jitter;
216                            warn!(
217                                "Rate limit exceeded. Retrying in {} ms (attempt {} of {})",
218                                delay, retry_count, MAX_RETRIES
219                            );
220
221                            // Esperar antes de reintentar
222                            tokio::time::sleep(Duration::from_millis(delay)).await;
223
224                            // Increase the waiting time exponentially for the next retry
225                            retry_delay_ms *= 2; // Exponential backoff
226                            continue;
227                        } else {
228                            error!(
229                                "Maximum retry attempts ({}) reached. Giving up.",
230                                MAX_RETRIES
231                            );
232                            return Err(AuthError::RateLimitExceeded);
233                        }
234                    }
235
236                    error!("Response body: {}", body);
237                    return Err(AuthError::BadCredentials);
238                }
239                other => {
240                    error!("Authentication failed with unexpected status: {}", other);
241                    let body = resp
242                        .text()
243                        .await
244                        .unwrap_or_else(|_| "Could not read response body".to_string());
245                    error!("Response body: {}", body);
246                    return Err(AuthError::Unexpected(other));
247                }
248            }
249        }
250    }
251
252    // only valid for Bearer tokens
253    async fn refresh(&self, sess: &IgSession) -> Result<IgSession, AuthError> {
254        let url = self.rest_url("session/refresh-token");
255
256        // Ensure the API key is trimmed and has no whitespace
257        let api_key = self.cfg.credentials.api_key.trim();
258
259        // Log the request details for debugging
260        debug!("Refresh request to URL: {}", url);
261        debug!("Using API key (length): {}", api_key.len());
262        debug!("Using CST token (length): {}", sess.cst.len());
263        debug!("Using X-SECURITY-TOKEN (length): {}", sess.token.len());
264
265        // Create a new client for each request to avoid any potential issues with cached state
266        let client = Client::builder()
267            .user_agent(USER_AGENT)
268            .build()
269            .expect("reqwest client");
270
271        let resp = client
272            .post(url)
273            .header("X-IG-API-KEY", api_key)
274            .header("CST", &sess.cst)
275            .header("X-SECURITY-TOKEN", &sess.token)
276            .header("Version", "3")
277            .header("Content-Type", "application/json; charset=UTF-8")
278            .header("Accept", "application/json; charset=UTF-8")
279            .send()
280            .await?;
281
282        // Log the response status and headers for debugging
283        debug!("Refresh response status: {}", resp.status());
284        trace!("Response headers: {:#?}", resp.headers());
285
286        match resp.status() {
287            StatusCode::OK => {
288                // Extract CST and X-SECURITY-TOKEN from headers
289                let cst = match resp.headers().get("CST") {
290                    Some(value) => {
291                        let cst_str = value
292                            .to_str()
293                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
294                        debug!(
295                            "Successfully obtained refreshed CST token of length: {}",
296                            cst_str.len()
297                        );
298                        cst_str.to_owned()
299                    }
300                    None => {
301                        error!("CST header not found in refresh response");
302                        return Err(AuthError::Unexpected(StatusCode::OK));
303                    }
304                };
305
306                let token = match resp.headers().get("X-SECURITY-TOKEN") {
307                    Some(value) => {
308                        let token_str = value
309                            .to_str()
310                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
311                        debug!(
312                            "Successfully obtained refreshed X-SECURITY-TOKEN of length: {}",
313                            token_str.len()
314                        );
315                        token_str.to_owned()
316                    }
317                    None => {
318                        error!("X-SECURITY-TOKEN header not found in refresh response");
319                        return Err(AuthError::Unexpected(StatusCode::OK));
320                    }
321                };
322
323                // Parse the response body to get the account ID
324                let json: SessionResp = resp.json().await?;
325                debug!("Refreshed session for Account ID: {}", json.account_id);
326
327                // Return a new session with the updated tokens
328                Ok(IgSession::from_config(
329                    cst,
330                    token,
331                    json.account_id,
332                    self.cfg,
333                ))
334            }
335            other => {
336                error!("Session refresh failed with status: {}", other);
337                let body = resp
338                    .text()
339                    .await
340                    .unwrap_or_else(|_| "Could not read response body".to_string());
341                error!("Response body: {}", body);
342                Err(AuthError::Unexpected(other))
343            }
344        }
345    }
346
347    async fn switch_account(
348        &self,
349        session: &IgSession,
350        account_id: &str,
351        default_account: Option<bool>,
352    ) -> Result<IgSession, AuthError> {
353        // Check if the account to switch to is the same as the current one
354        if session.account_id == account_id {
355            debug!("Already on account ID: {}. No need to switch.", account_id);
356            // Return a copy of the current session with the same rate limiter configuration
357            return Ok(IgSession::from_config(
358                session.cst.clone(),
359                session.token.clone(),
360                session.account_id.clone(),
361                self.cfg,
362            ));
363        }
364
365        let url = self.rest_url("session");
366        let api_key = self.cfg.credentials.api_key.trim();
367
368        // Log the request details for debugging
369        debug!("Account switch request to URL: {}", url);
370        debug!("Using API key (length): {}", api_key.len());
371        debug!("Switching to account ID: {}", account_id);
372        debug!("Set as default account: {:?}", default_account);
373
374        // Create the request body
375        let body = AccountSwitchRequest {
376            account_id: account_id.to_string(),
377            default_account,
378        };
379
380        trace!(
381            "Request body: {}",
382            serde_json::to_string(&body).unwrap_or_default()
383        );
384
385        // Create a new client for each request
386        let client = Client::builder()
387            .user_agent(USER_AGENT)
388            .build()
389            .expect("reqwest client");
390
391        // Make the PUT request to switch accounts
392        let resp = client
393            .put(url)
394            .header("X-IG-API-KEY", api_key)
395            .header("CST", &session.cst)
396            .header("X-SECURITY-TOKEN", &session.token)
397            .header("Version", "1")
398            .header("Content-Type", "application/json; charset=UTF-8")
399            .header("Accept", "application/json; charset=UTF-8")
400            .json(&body)
401            .send()
402            .await?;
403
404        // Log the response status and headers for debugging
405        debug!("Account switch response status: {}", resp.status());
406        trace!("Response headers: {:#?}", resp.headers());
407
408        match resp.status() {
409            StatusCode::OK => {
410                // IMPORTANT: Extract CST and X-SECURITY-TOKEN from headers
411                // When switching accounts, IG API returns new security tokens in the response headers
412                // that must be used for subsequent API calls. Using the old tokens will result in
413                // "error.security.account-token-invalid" errors for all future requests.
414                // This was the root cause of the bug where switch_account appeared to work but
415                // subsequent API calls failed with authentication errors.
416                let new_cst = match resp.headers().get("CST") {
417                    Some(value) => {
418                        let cst_str = value
419                            .to_str()
420                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
421                        debug!(
422                            "Successfully obtained new CST token of length: {}",
423                            cst_str.len()
424                        );
425                        cst_str.to_owned()
426                    }
427                    None => {
428                        warn!("CST header not found in switch response, using existing token");
429                        return Err(AuthError::Unexpected(StatusCode::NO_CONTENT));
430                    }
431                };
432
433                let new_token = match resp.headers().get("X-SECURITY-TOKEN") {
434                    Some(value) => {
435                        let token_str = value
436                            .to_str()
437                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
438                        debug!(
439                            "Successfully obtained new X-SECURITY-TOKEN of length: {}",
440                            token_str.len()
441                        );
442                        token_str.to_owned()
443                    }
444                    None => {
445                        warn!(
446                            "X-SECURITY-TOKEN header not found in switch response, using existing token"
447                        );
448                        return Err(AuthError::Unexpected(StatusCode::NO_CONTENT));
449                    }
450                };
451
452                // Parse the response body
453                let switch_response: AccountSwitchResponse = resp.json().await?;
454                info!("Account switch successful to: {}", account_id);
455                trace!("Account switch response: {:?}", switch_response);
456
457                // Return a new session with the updated account ID and new tokens from the response headers
458                Ok(IgSession::from_config(
459                    new_cst,
460                    new_token,
461                    account_id.to_string(),
462                    self.cfg,
463                ))
464            }
465            other => {
466                error!("Account switch failed with status: {}", other);
467                let body = resp
468                    .text()
469                    .await
470                    .unwrap_or_else(|_| "Could not read response body".to_string());
471                error!("Response body: {}", body);
472
473                // If the error is 401 Unauthorized, it could be that the account ID is not valid
474                // or does not belong to the authenticated user
475                if other == StatusCode::UNAUTHORIZED {
476                    warn!(
477                        "Cannot switch to account ID: {}. The account might not exist or you don't have permission.",
478                        account_id
479                    );
480                }
481
482                Err(AuthError::Unexpected(other))
483            }
484        }
485    }
486
487    async fn relogin(&self, session: &IgSession) -> Result<IgSession, AuthError> {
488        // Check if tokens are expired or close to expiring (with 30 minute margin)
489        let margin = chrono::Duration::minutes(30);
490
491        let is_expired = {
492            let timer = session.token_timer.lock().unwrap();
493            timer.is_expired_w_margin(margin)
494        };
495
496        if is_expired {
497            info!("Tokens are expired or close to expiring, performing re-login");
498            self.login().await
499        } else {
500            debug!("Tokens are still valid, reusing existing session");
501            Ok(session.clone())
502        }
503    }
504
505    async fn relogin_and_switch_account(
506        &self,
507        session: &IgSession,
508        account_id: &str,
509        default_account: Option<bool>,
510    ) -> Result<IgSession, AuthError> {
511        let session = self.relogin(session).await?;
512        debug!(
513            "Relogin check completed for account: {}, trying to switch to {}",
514            session.account_id, account_id
515        );
516
517        match self
518            .switch_account(&session, account_id, default_account)
519            .await
520        {
521            Ok(new_session) => Ok(new_session),
522            Err(e) => {
523                warn!("Could not switch to account {}: {:?}.", account_id, e);
524                Err(e)
525            }
526        }
527    }
528
529    async fn login_and_switch_account(
530        &self,
531        account_id: &str,
532        default_account: Option<bool>,
533    ) -> Result<IgSession, AuthError> {
534        let session = self.login().await?;
535        self.relogin_and_switch_account(&session, account_id, default_account)
536            .await
537    }
538}