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, 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    async fn refresh(&self, sess: &IgSession) -> Result<IgSession, AuthError> {
253        let url = self.rest_url("session/refresh-token");
254
255        // Ensure the API key is trimmed and has no whitespace
256        let api_key = self.cfg.credentials.api_key.trim();
257
258        // Log the request details for debugging
259        debug!("Refresh request to URL: {}", url);
260        debug!("Using API key (length): {}", api_key.len());
261        debug!("Using CST token (length): {}", sess.cst.len());
262        debug!("Using X-SECURITY-TOKEN (length): {}", sess.token.len());
263
264        // Create a new client for each request to avoid any potential issues with cached state
265        let client = Client::builder()
266            .user_agent(USER_AGENT)
267            .build()
268            .expect("reqwest client");
269
270        let resp = client
271            .post(url)
272            .header("X-IG-API-KEY", api_key)
273            .header("CST", &sess.cst)
274            .header("X-SECURITY-TOKEN", &sess.token)
275            .header("Version", "3")
276            .header("Content-Type", "application/json; charset=UTF-8")
277            .header("Accept", "application/json; charset=UTF-8")
278            .send()
279            .await?;
280
281        // Log the response status and headers for debugging
282        debug!("Refresh response status: {}", resp.status());
283        trace!("Response headers: {:#?}", resp.headers());
284
285        match resp.status() {
286            StatusCode::OK => {
287                // Extract CST and X-SECURITY-TOKEN from headers
288                let cst = match resp.headers().get("CST") {
289                    Some(value) => {
290                        let cst_str = value
291                            .to_str()
292                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
293                        debug!(
294                            "Successfully obtained refreshed CST token of length: {}",
295                            cst_str.len()
296                        );
297                        cst_str.to_owned()
298                    }
299                    None => {
300                        error!("CST header not found in refresh response");
301                        return Err(AuthError::Unexpected(StatusCode::OK));
302                    }
303                };
304
305                let token = match resp.headers().get("X-SECURITY-TOKEN") {
306                    Some(value) => {
307                        let token_str = value
308                            .to_str()
309                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
310                        debug!(
311                            "Successfully obtained refreshed X-SECURITY-TOKEN of length: {}",
312                            token_str.len()
313                        );
314                        token_str.to_owned()
315                    }
316                    None => {
317                        error!("X-SECURITY-TOKEN header not found in refresh response");
318                        return Err(AuthError::Unexpected(StatusCode::OK));
319                    }
320                };
321
322                // Parse the response body to get the account ID
323                let json: SessionResp = resp.json().await?;
324                debug!("Refreshed session for Account ID: {}", json.account_id);
325
326                // Return a new session with the updated tokens
327                Ok(IgSession::from_config(
328                    cst,
329                    token,
330                    json.account_id,
331                    self.cfg,
332                ))
333            }
334            other => {
335                error!("Session refresh failed with status: {}", other);
336                let body = resp
337                    .text()
338                    .await
339                    .unwrap_or_else(|_| "Could not read response body".to_string());
340                error!("Response body: {}", body);
341                Err(AuthError::Unexpected(other))
342            }
343        }
344    }
345
346    async fn switch_account(
347        &self,
348        session: &IgSession,
349        account_id: &str,
350        default_account: Option<bool>,
351    ) -> Result<IgSession, AuthError> {
352        // Check if the account to switch to is the same as the current one
353        if session.account_id == account_id {
354            debug!("Already on account ID: {}. No need to switch.", account_id);
355            // Return a copy of the current session with the same rate limiter configuration
356            return Ok(IgSession::from_config(
357                session.cst.clone(),
358                session.token.clone(),
359                session.account_id.clone(),
360                self.cfg,
361            ));
362        }
363
364        let url = self.rest_url("session");
365
366        // Ensure the API key is trimmed and has no whitespace
367        let api_key = self.cfg.credentials.api_key.trim();
368
369        // Log the request details for debugging
370        debug!("Account switch request to URL: {}", url);
371        debug!("Using API key (length): {}", api_key.len());
372        debug!("Switching to account ID: {}", account_id);
373        debug!("Set as default account: {:?}", default_account);
374
375        // Create the request body
376        let body = AccountSwitchRequest {
377            account_id: account_id.to_string(),
378            default_account,
379        };
380
381        trace!(
382            "Request body: {}",
383            serde_json::to_string(&body).unwrap_or_default()
384        );
385
386        // Create a new client for each request
387        let client = Client::builder()
388            .user_agent(USER_AGENT)
389            .build()
390            .expect("reqwest client");
391
392        // Make the PUT request to switch accounts
393        let resp = client
394            .put(url)
395            .header("X-IG-API-KEY", api_key)
396            .header("CST", &session.cst)
397            .header("X-SECURITY-TOKEN", &session.token)
398            .header("Version", "1")
399            .header("Content-Type", "application/json; charset=UTF-8")
400            .header("Accept", "application/json; charset=UTF-8")
401            .json(&body)
402            .send()
403            .await?;
404
405        // Log the response status and headers for debugging
406        debug!("Account switch response status: {}", resp.status());
407        trace!("Response headers: {:#?}", resp.headers());
408
409        match resp.status() {
410            StatusCode::OK => {
411                // Parse the response body
412                let switch_response: AccountSwitchResponse = resp.json().await?;
413                debug!("Account switch successful");
414                trace!("Account switch response: {:?}", switch_response);
415
416                // Return a new session with the updated account ID and the config's rate limiter settings
417                // The CST and token remain the same
418                Ok(IgSession::from_config(
419                    session.cst.clone(),
420                    session.token.clone(),
421                    account_id.to_string(),
422                    self.cfg,
423                ))
424            }
425            other => {
426                error!("Account switch failed with status: {}", other);
427                let body = resp
428                    .text()
429                    .await
430                    .unwrap_or_else(|_| "Could not read response body".to_string());
431                error!("Response body: {}", body);
432
433                // If the error is 401 Unauthorized, it could be that the account ID is not valid
434                // or does not belong to the authenticated user
435                if other == StatusCode::UNAUTHORIZED {
436                    warn!(
437                        "Cannot switch to account ID: {}. The account might not exist or you don't have permission.",
438                        account_id
439                    );
440                }
441
442                Err(AuthError::Unexpected(other))
443            }
444        }
445    }
446}