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, SessionV3Resp},
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        // Determine which API version to use
70        // Default to v3 (OAuth) - requires Authorization + IG-ACCOUNT-ID headers
71        let api_version = self.cfg.api_version.unwrap_or(3);
72
73        debug!("Using API version {} for authentication", api_version);
74
75        match api_version {
76            2 => self.login_v2().await,
77            3 => self.login_v3().await,
78            _ => {
79                error!("Invalid API version: {}. Must be 2 or 3", api_version);
80                Err(AuthError::Unexpected(StatusCode::BAD_REQUEST))
81            }
82        }
83    }
84
85    async fn login_v2(&self) -> Result<IgSession, AuthError> {
86        // Configuration for retries
87        const MAX_RETRIES: u32 = 3;
88        const INITIAL_RETRY_DELAY_MS: u64 = 10000; // 10 seconds
89
90        let mut retry_count = 0;
91        let mut retry_delay_ms = INITIAL_RETRY_DELAY_MS;
92
93        loop {
94            // Use the global app rate limiter for unauthenticated requests
95            let limiter = app_non_trading_limiter();
96            limiter.wait().await;
97
98            // Following the exact approach from trading-ig Python library
99            let url = self.rest_url("session");
100
101            // Ensure the API key is trimmed and has no whitespace
102            let api_key = self.cfg.credentials.api_key.trim();
103            let username = self.cfg.credentials.username.trim();
104            let password = self.cfg.credentials.password.trim();
105
106            // Log the request details for debugging
107            debug!("Login v2 request to URL: {}", url);
108            debug!("Using API key (length): {}", api_key.len());
109            debug!("Using username: {}", username);
110
111            if retry_count > 0 {
112                debug!("Retry attempt {} of {}", retry_count, MAX_RETRIES);
113            }
114
115            // Create the body exactly as in the Python library
116            let body = serde_json::json!({
117                "identifier": username,
118                "password": password,
119                "encryptedPassword": false
120            });
121
122            debug!(
123                "Request body: {}",
124                serde_json::to_string(&body).unwrap_or_default()
125            );
126
127            // Create a new client for each request to avoid any potential issues with cached state
128            let client = Client::builder()
129                .user_agent(USER_AGENT)
130                .build()
131                .expect("reqwest client");
132
133            // Add headers exactly as in the Python library
134            let resp = match client
135                .post(url.clone())
136                .header("X-IG-API-KEY", api_key)
137                .header("Content-Type", "application/json; charset=UTF-8")
138                .header("Accept", "application/json; charset=UTF-8")
139                .header("Version", "2")
140                .json(&body)
141                .send()
142                .await
143            {
144                Ok(resp) => resp,
145                Err(e) => {
146                    error!("Failed to send login request: {}", e);
147                    return Err(AuthError::Unexpected(StatusCode::INTERNAL_SERVER_ERROR));
148                }
149            };
150
151            // Log the response status and headers for debugging
152            debug!("Login v2 response status: {}", resp.status());
153            trace!("Response headers: {:#?}", resp.headers());
154
155            match resp.status() {
156                StatusCode::OK => {
157                    // Extract CST and X-SECURITY-TOKEN from headers
158                    let cst = match resp.headers().get("CST") {
159                        Some(value) => {
160                            let cst_str = value
161                                .to_str()
162                                .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
163                            debug!(
164                                "Successfully obtained CST token of length: {}",
165                                cst_str.len()
166                            );
167                            cst_str.to_owned()
168                        }
169                        None => {
170                            error!("CST header not found in response");
171                            return Err(AuthError::Unexpected(StatusCode::OK));
172                        }
173                    };
174
175                    let token = match resp.headers().get("X-SECURITY-TOKEN") {
176                        Some(value) => {
177                            let token_str = value
178                                .to_str()
179                                .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
180                            debug!(
181                                "Successfully obtained X-SECURITY-TOKEN of length: {}",
182                                token_str.len()
183                            );
184                            token_str.to_owned()
185                        }
186                        None => {
187                            error!("X-SECURITY-TOKEN header not found in response");
188                            return Err(AuthError::Unexpected(StatusCode::OK));
189                        }
190                    };
191
192                    // Extract account ID from the response
193                    let json: SessionResp = resp.json().await?;
194                    let account_id = json.account_id.clone();
195
196                    // Return a new session with the CST, token, and account ID
197                    // Use the rate limit type and safety margin from the config
198                    let session =
199                        IgSession::from_config(cst.clone(), token.clone(), account_id, self.cfg);
200
201                    // Log rate limiter stats if available
202                    if let Some(stats) = session.get_rate_limit_stats().await {
203                        debug!("Rate limiter initialized: {}", stats);
204                    }
205
206                    return Ok(session);
207                }
208                StatusCode::UNAUTHORIZED => {
209                    error!("Authentication failed with UNAUTHORIZED");
210                    let body = resp
211                        .text()
212                        .await
213                        .unwrap_or_else(|_| "Could not read response body".to_string());
214                    error!("Response body: {}", body);
215                    return Err(AuthError::BadCredentials);
216                }
217                StatusCode::FORBIDDEN => {
218                    error!("Authentication failed with FORBIDDEN");
219                    let body = resp
220                        .text()
221                        .await
222                        .unwrap_or_else(|_| "Could not read response body".to_string());
223
224                    if body.contains("exceeded-api-key-allowance") {
225                        error!("Rate Limit Exceeded: {}", &body);
226
227                        // Implementing retry with exponential backoff for this specific case
228                        if retry_count < MAX_RETRIES {
229                            retry_count += 1;
230                            // Using a longer delay and adding some randomness to avoid patterns
231                            let jitter = rand::random::<u64>() % 5000;
232                            let delay = retry_delay_ms + jitter;
233                            warn!(
234                                "Rate limit exceeded. Retrying in {} ms (attempt {} of {})",
235                                delay, retry_count, MAX_RETRIES
236                            );
237
238                            tokio::time::sleep(Duration::from_millis(delay)).await;
239
240                            // Increase the waiting time exponentially for the next retry
241                            retry_delay_ms *= 2; // Exponential backoff
242                            continue;
243                        } else {
244                            error!(
245                                "Maximum retry attempts ({}) reached. Giving up.",
246                                MAX_RETRIES
247                            );
248                            return Err(AuthError::RateLimitExceeded);
249                        }
250                    }
251
252                    error!("Response body: {}", body);
253                    return Err(AuthError::BadCredentials);
254                }
255                other => {
256                    error!("Authentication failed with unexpected status: {}", other);
257                    let body = resp
258                        .text()
259                        .await
260                        .unwrap_or_else(|_| "Could not read response body".to_string());
261                    error!("Response body: {}", body);
262                    return Err(AuthError::Unexpected(other));
263                }
264            }
265        }
266    }
267
268    async fn login_v3(&self) -> Result<IgSession, AuthError> {
269        // Configuration for retries
270        const MAX_RETRIES: u32 = 3;
271        const INITIAL_RETRY_DELAY_MS: u64 = 10000; // 10 seconds
272
273        let mut retry_count = 0;
274        let mut retry_delay_ms = INITIAL_RETRY_DELAY_MS;
275
276        loop {
277            // Use the global app rate limiter for unauthenticated requests
278            let limiter = app_non_trading_limiter();
279            limiter.wait().await;
280
281            let url = self.rest_url("session");
282
283            // Ensure credentials are trimmed
284            let api_key = self.cfg.credentials.api_key.trim();
285            let username = self.cfg.credentials.username.trim();
286            let password = self.cfg.credentials.password.trim();
287
288            // Log the request details for debugging
289            debug!("Login v3 request to URL: {}", url);
290            debug!("Using API key (length): {}", api_key.len());
291            debug!("Using username: {}", username);
292
293            if retry_count > 0 {
294                debug!("Retry attempt {} of {}", retry_count, MAX_RETRIES);
295            }
296
297            // Create the body for API v3
298            let body = serde_json::json!({
299                "identifier": username,
300                "password": password,
301                "encryptedPassword": null
302            });
303
304            debug!(
305                "Request body: {}",
306                serde_json::to_string(&body).unwrap_or_default()
307            );
308
309            // Create a new client for each request
310            let client = Client::builder()
311                .user_agent(USER_AGENT)
312                .build()
313                .expect("reqwest client");
314
315            // Make the request with version 3
316            let resp = match client
317                .post(url.clone())
318                .header("X-IG-API-KEY", api_key)
319                .header("Content-Type", "application/json")
320                .header("Version", "3")
321                .json(&body)
322                .send()
323                .await
324            {
325                Ok(resp) => resp,
326                Err(e) => {
327                    error!("Failed to send login v3 request: {}", e);
328                    return Err(AuthError::Unexpected(StatusCode::INTERNAL_SERVER_ERROR));
329                }
330            };
331
332            // Log the response status and headers for debugging
333            debug!("Login v3 response status: {}", resp.status());
334            trace!("Response headers: {:#?}", resp.headers());
335
336            match resp.status() {
337                StatusCode::OK => {
338                    // Parse the JSON response
339                    let json: SessionV3Resp = resp.json().await?;
340
341                    debug!("Successfully authenticated with OAuth");
342                    debug!("Account ID: {}", json.account_id);
343                    debug!("Client ID: {}", json.client_id);
344                    debug!("Lightstreamer endpoint: {}", json.lightstreamer_endpoint);
345                    debug!(
346                        "Access token length: {}",
347                        json.oauth_token.access_token.len()
348                    );
349                    debug!("Token expires in: {} seconds", json.oauth_token.expires_in);
350
351                    // Create a new session with OAuth tokens
352                    let session = IgSession::from_oauth(
353                        json.oauth_token,
354                        json.account_id,
355                        json.client_id,
356                        json.lightstreamer_endpoint,
357                        self.cfg,
358                    );
359
360                    // Log rate limiter stats if available
361                    if let Some(stats) = session.get_rate_limit_stats().await {
362                        debug!("Rate limiter initialized: {}", stats);
363                    }
364
365                    return Ok(session);
366                }
367                StatusCode::UNAUTHORIZED => {
368                    error!("Authentication failed with UNAUTHORIZED");
369                    let body = resp
370                        .text()
371                        .await
372                        .unwrap_or_else(|_| "Could not read response body".to_string());
373                    error!("Response body: {}", body);
374                    return Err(AuthError::BadCredentials);
375                }
376                StatusCode::FORBIDDEN => {
377                    error!("Authentication failed with FORBIDDEN");
378                    let body = resp
379                        .text()
380                        .await
381                        .unwrap_or_else(|_| "Could not read response body".to_string());
382
383                    if body.contains("exceeded-api-key-allowance") {
384                        error!("Rate Limit Exceeded: {}", &body);
385
386                        if retry_count < MAX_RETRIES {
387                            retry_count += 1;
388                            let jitter = rand::random::<u64>() % 5000;
389                            let delay = retry_delay_ms + jitter;
390                            warn!(
391                                "Rate limit exceeded. Retrying in {} ms (attempt {} of {})",
392                                delay, retry_count, MAX_RETRIES
393                            );
394
395                            tokio::time::sleep(Duration::from_millis(delay)).await;
396                            retry_delay_ms *= 2;
397                            continue;
398                        } else {
399                            error!(
400                                "Maximum retry attempts ({}) reached. Giving up.",
401                                MAX_RETRIES
402                            );
403                            return Err(AuthError::RateLimitExceeded);
404                        }
405                    }
406
407                    error!("Response body: {}", body);
408                    return Err(AuthError::BadCredentials);
409                }
410                other => {
411                    error!("Authentication failed with unexpected status: {}", other);
412                    let body = resp
413                        .text()
414                        .await
415                        .unwrap_or_else(|_| "Could not read response body".to_string());
416                    error!("Response body: {}", body);
417                    return Err(AuthError::Unexpected(other));
418                }
419            }
420        }
421    }
422
423    // only valid for Bearer tokens
424    async fn refresh(&self, sess: &IgSession) -> Result<IgSession, AuthError> {
425        let url = self.rest_url("session/refresh-token");
426
427        // Ensure the API key is trimmed and has no whitespace
428        let api_key = self.cfg.credentials.api_key.trim();
429
430        // Log the request details for debugging
431        debug!("Refresh request to URL: {}", url);
432        debug!("Using API key (length): {}", api_key.len());
433        debug!("Using CST token (length): {}", sess.cst.len());
434        debug!("Using X-SECURITY-TOKEN (length): {}", sess.token.len());
435
436        // Create a new client for each request to avoid any potential issues with cached state
437        let client = Client::builder()
438            .user_agent(USER_AGENT)
439            .build()
440            .expect("reqwest client");
441
442        let resp = client
443            .post(url)
444            .header("X-IG-API-KEY", api_key)
445            .header("CST", &sess.cst)
446            .header("X-SECURITY-TOKEN", &sess.token)
447            .header("Version", "3")
448            .header("Content-Type", "application/json; charset=UTF-8")
449            .header("Accept", "application/json; charset=UTF-8")
450            .send()
451            .await?;
452
453        // Log the response status and headers for debugging
454        debug!("Refresh response status: {}", resp.status());
455        trace!("Response headers: {:#?}", resp.headers());
456
457        match resp.status() {
458            StatusCode::OK => {
459                // Extract CST and X-SECURITY-TOKEN from headers
460                let cst = match resp.headers().get("CST") {
461                    Some(value) => {
462                        let cst_str = value
463                            .to_str()
464                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
465                        debug!(
466                            "Successfully obtained refreshed CST token of length: {}",
467                            cst_str.len()
468                        );
469                        cst_str.to_owned()
470                    }
471                    None => {
472                        error!("CST header not found in refresh response");
473                        return Err(AuthError::Unexpected(StatusCode::OK));
474                    }
475                };
476
477                let token = match resp.headers().get("X-SECURITY-TOKEN") {
478                    Some(value) => {
479                        let token_str = value
480                            .to_str()
481                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
482                        debug!(
483                            "Successfully obtained refreshed X-SECURITY-TOKEN of length: {}",
484                            token_str.len()
485                        );
486                        token_str.to_owned()
487                    }
488                    None => {
489                        error!("X-SECURITY-TOKEN header not found in refresh response");
490                        return Err(AuthError::Unexpected(StatusCode::OK));
491                    }
492                };
493
494                // Parse the response body to get the account ID
495                let json: SessionResp = resp.json().await?;
496                debug!("Refreshed session for Account ID: {}", json.account_id);
497
498                // Return a new session with the updated tokens
499                Ok(IgSession::from_config(
500                    cst,
501                    token,
502                    json.account_id,
503                    self.cfg,
504                ))
505            }
506            other => {
507                error!("Session refresh failed with status: {}", other);
508                let body = resp
509                    .text()
510                    .await
511                    .unwrap_or_else(|_| "Could not read response body".to_string());
512                error!("Response body: {}", body);
513                Err(AuthError::Unexpected(other))
514            }
515        }
516    }
517
518    async fn switch_account(
519        &self,
520        session: &IgSession,
521        account_id: &str,
522        default_account: Option<bool>,
523    ) -> Result<IgSession, AuthError> {
524        // Check if the account to switch to is the same as the current one
525        if session.account_id == account_id {
526            debug!("Already on account ID: {}. No need to switch.", account_id);
527            // Return a clone of the current session to preserve all tokens including OAuth
528            return Ok(session.clone());
529        }
530
531        let url = self.rest_url("session");
532        let api_key = self.cfg.credentials.api_key.trim();
533
534        // Log the request details for debugging
535        debug!("Account switch request to URL: {}", url);
536        debug!("Using API key (length): {}", api_key.len());
537        debug!("Switching to account ID: {}", account_id);
538        debug!("Set as default account: {:?}", default_account);
539
540        // Create the request body
541        let body = AccountSwitchRequest {
542            account_id: account_id.to_string(),
543            default_account,
544        };
545
546        trace!(
547            "Request body: {}",
548            serde_json::to_string(&body).unwrap_or_default()
549        );
550
551        // Create a new client for each request
552        let client = Client::builder()
553            .user_agent(USER_AGENT)
554            .build()
555            .expect("reqwest client");
556
557        // Make the PUT request to switch accounts
558        let mut request = client
559            .put(url)
560            .header("X-IG-API-KEY", api_key)
561            .header("Version", "1")
562            .header("Content-Type", "application/json; charset=UTF-8")
563            .header("Accept", "application/json; charset=UTF-8");
564
565        // Add authentication headers based on session type
566        if let Some(oauth_token) = &session.oauth_token {
567            // Use OAuth Bearer token + IG-ACCOUNT-ID header
568            debug!("Using OAuth authentication for account switch");
569            request = request
570                .header(
571                    "Authorization",
572                    format!("Bearer {}", oauth_token.access_token),
573                )
574                .header("IG-ACCOUNT-ID", &session.account_id);
575        } else {
576            // Use CST and X-SECURITY-TOKEN (v2)
577            debug!("Using CST authentication for account switch");
578            request = request
579                .header("CST", &session.cst)
580                .header("X-SECURITY-TOKEN", &session.token);
581        }
582
583        let resp = request.json(&body).send().await?;
584
585        // Log the response status and headers for debugging
586        debug!("Account switch response status: {}", resp.status());
587        trace!("Response headers: {:#?}", resp.headers());
588
589        match resp.status() {
590            StatusCode::OK => {
591                // IMPORTANT: Extract CST and X-SECURITY-TOKEN from headers
592                // When switching accounts, IG API returns new security tokens in the response headers
593                // that must be used for subsequent API calls. Using the old tokens will result in
594                // "error.security.account-token-invalid" errors for all future requests.
595                // This was the root cause of the bug where switch_account appeared to work but
596                // subsequent API calls failed with authentication errors.
597                let new_cst = match resp.headers().get("CST") {
598                    Some(value) => {
599                        let cst_str = value
600                            .to_str()
601                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
602                        debug!(
603                            "Successfully obtained new CST token of length: {}",
604                            cst_str.len()
605                        );
606                        cst_str.to_owned()
607                    }
608                    None => {
609                        warn!("CST header not found in switch response, using existing token");
610                        session.cst.clone()
611                    }
612                };
613
614                let new_token = match resp.headers().get("X-SECURITY-TOKEN") {
615                    Some(value) => {
616                        let token_str = value
617                            .to_str()
618                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
619                        debug!(
620                            "Successfully obtained new X-SECURITY-TOKEN of length: {}",
621                            token_str.len()
622                        );
623                        token_str.to_owned()
624                    }
625                    None => {
626                        warn!(
627                            "X-SECURITY-TOKEN header not found in switch response, using existing token"
628                        );
629                        return Err(AuthError::Unexpected(StatusCode::NO_CONTENT));
630                    }
631                };
632
633                // Parse the response body
634                let switch_response: AccountSwitchResponse = resp.json().await?;
635                info!("Account switch successful to: {}", account_id);
636                trace!("Account switch response: {:?}", switch_response);
637
638                // Return a new session with the updated account ID
639                // If the original session used OAuth, preserve the OAuth tokens
640                // Otherwise, use the new CST and X-SECURITY-TOKEN from the response
641                if session.oauth_token.is_some() {
642                    // OAuth session - preserve OAuth tokens and update account ID
643                    let mut new_session = session.clone();
644                    new_session.account_id = account_id.to_string();
645                    Ok(new_session)
646                } else {
647                    // CST session - use new tokens from response headers
648                    Ok(IgSession::from_config(
649                        new_cst,
650                        new_token,
651                        account_id.to_string(),
652                        self.cfg,
653                    ))
654                }
655            }
656            other => {
657                error!("Account switch failed with status: {}", other);
658                let body = resp
659                    .text()
660                    .await
661                    .unwrap_or_else(|_| "Could not read response body".to_string());
662                error!("Response body: {}", body);
663
664                // If the error is 401 Unauthorized, it could be that the account ID is not valid
665                // or does not belong to the authenticated user
666                if other == StatusCode::UNAUTHORIZED {
667                    warn!(
668                        "Cannot switch to account ID: {}. The account might not exist or you don't have permission.",
669                        account_id
670                    );
671                }
672
673                Err(AuthError::Unexpected(other))
674            }
675        }
676    }
677
678    async fn relogin(&self, session: &IgSession) -> Result<IgSession, AuthError> {
679        // Check if tokens are expired or close to expiring (with 30 minute margin)
680        let margin = chrono::Duration::minutes(30);
681
682        let is_expired = {
683            let timer = session.token_timer.lock().unwrap();
684            timer.is_expired_w_margin(margin)
685        };
686
687        if is_expired {
688            info!("Tokens are expired or close to expiring, performing re-login");
689            self.login().await
690        } else {
691            debug!("Tokens are still valid, reusing existing session");
692            Ok(session.clone())
693        }
694    }
695
696    async fn relogin_and_switch_account(
697        &self,
698        session: &IgSession,
699        account_id: &str,
700        default_account: Option<bool>,
701    ) -> Result<IgSession, AuthError> {
702        let session = self.relogin(session).await?;
703        debug!(
704            "Relogin check completed for account: {}, trying to switch to {}",
705            session.account_id, account_id
706        );
707
708        match self
709            .switch_account(&session, account_id, default_account)
710            .await
711        {
712            Ok(new_session) => Ok(new_session),
713            Err(e) => {
714                warn!("Could not switch to account {}: {:?}.", account_id, e);
715                Err(e)
716            }
717        }
718    }
719
720    async fn login_and_switch_account(
721        &self,
722        account_id: &str,
723        default_account: Option<bool>,
724    ) -> Result<IgSession, AuthError> {
725        let session = self.login().await?;
726        self.relogin_and_switch_account(&session, account_id, default_account)
727            .await
728    }
729}