ig_client/session/
auth.rs

1// Authentication module for IG Markets API
2
3use crate::{
4    config::Config,
5    error::AuthError,
6    session::interface::{IgAuthenticator, IgSession},
7    session::response::{AccountSwitchRequest, AccountSwitchResponse, SessionResp},
8    utils::rate_limiter::app_non_trading_limiter,
9};
10use async_trait::async_trait;
11use rand;
12use reqwest::{Client, StatusCode};
13use std::time::Duration;
14use tracing::{debug, error, info, warn};
15
16/// Authentication handler for IG Markets API
17pub struct IgAuth<'a> {
18    pub(crate) cfg: &'a Config,
19    http: Client,
20}
21
22impl<'a> IgAuth<'a> {
23    /// Creates a new IG authentication handler
24    ///
25    /// # Arguments
26    /// * `cfg` - Reference to the configuration
27    ///
28    /// # Returns
29    /// * A new IgAuth instance
30    pub fn new(cfg: &'a Config) -> Self {
31        Self {
32            cfg,
33            http: Client::builder()
34                .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
35                .build()
36                .expect("reqwest client"),
37        }
38    }
39
40    /// Returns the correct base URL (demo vs live) according to the configuration
41    fn rest_url(&self, path: &str) -> String {
42        format!(
43            "{}/{}",
44            self.cfg.rest_api.base_url.trim_end_matches('/'),
45            path.trim_start_matches('/')
46        )
47    }
48
49    /// Retrieves a reference to the `Client` instance.
50    ///
51    /// This method returns a reference to the `Client` object,
52    /// which is typically used for making HTTP requests or interacting
53    /// with other network-related services.
54    ///
55    /// # Returns
56    ///
57    /// * `&Client` - A reference to the internally stored `Client` object.
58    ///
59    #[allow(dead_code)]
60    fn get_client(&self) -> &Client {
61        &self.http
62    }
63}
64
65#[async_trait]
66impl IgAuthenticator for IgAuth<'_> {
67    async fn login(&self) -> Result<IgSession, AuthError> {
68        // Configuración para reintentos
69        const MAX_RETRIES: u32 = 3;
70        const INITIAL_RETRY_DELAY_MS: u64 = 10000; // 10 segundos
71
72        let mut retry_count = 0;
73        let mut retry_delay_ms = INITIAL_RETRY_DELAY_MS;
74
75        loop {
76            // Use the global app rate limiter for unauthenticated requests
77            let limiter = app_non_trading_limiter();
78            limiter.wait().await;
79
80            // Following the exact approach from trading-ig Python library
81            let url = self.rest_url("session");
82
83            // Ensure the API key is trimmed and has no whitespace
84            let api_key = self.cfg.credentials.api_key.trim();
85            let username = self.cfg.credentials.username.trim();
86            let password = self.cfg.credentials.password.trim();
87
88            // Log the request details for debugging
89            info!("Login request to URL: {}", url);
90            info!("Using API key (length): {}", api_key.len());
91            info!("Using username: {}", username);
92
93            if retry_count > 0 {
94                info!("Retry attempt {} of {}", retry_count, MAX_RETRIES);
95            }
96
97            // Create the body exactly as in the Python library
98            let body = serde_json::json!({
99                "identifier": username,
100                "password": password,
101                "encryptedPassword": false
102            });
103
104            debug!(
105                "Request body: {}",
106                serde_json::to_string(&body).unwrap_or_default()
107            );
108
109            // Create a new client for each request to avoid any potential issues with cached state
110            let client = Client::builder()
111                .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
112                .build()
113                .expect("reqwest client");
114
115            // Add headers exactly as in the Python library
116            let resp = match client
117                .post(url.clone())
118                .header("X-IG-API-KEY", api_key)
119                .header("Content-Type", "application/json; charset=UTF-8")
120                .header("Accept", "application/json; charset=UTF-8")
121                .header("Version", "2")
122                .json(&body)
123                .send()
124                .await
125            {
126                Ok(resp) => resp,
127                Err(e) => {
128                    error!("Failed to send login request: {}", e);
129                    return Err(AuthError::Unexpected(StatusCode::INTERNAL_SERVER_ERROR));
130                }
131            };
132
133            // Log the response status and headers for debugging
134            info!("Login response status: {}", resp.status());
135            debug!("Response headers: {:#?}", resp.headers());
136
137            match resp.status() {
138                StatusCode::OK => {
139                    // Extract CST and X-SECURITY-TOKEN from headers
140                    let cst = match resp.headers().get("CST") {
141                        Some(value) => {
142                            let cst_str = value
143                                .to_str()
144                                .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
145                            info!(
146                                "Successfully obtained CST token of length: {}",
147                                cst_str.len()
148                            );
149                            cst_str.to_owned()
150                        }
151                        None => {
152                            error!("CST header not found in response");
153                            return Err(AuthError::Unexpected(StatusCode::OK));
154                        }
155                    };
156
157                    let token = match resp.headers().get("X-SECURITY-TOKEN") {
158                        Some(value) => {
159                            let token_str = value
160                                .to_str()
161                                .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
162                            info!(
163                                "Successfully obtained X-SECURITY-TOKEN of length: {}",
164                                token_str.len()
165                            );
166                            token_str.to_owned()
167                        }
168                        None => {
169                            error!("X-SECURITY-TOKEN header not found in response");
170                            return Err(AuthError::Unexpected(StatusCode::OK));
171                        }
172                    };
173
174                    // Extract account ID from the response
175                    let json: SessionResp = resp.json().await?;
176                    let account_id = json.account_id.clone();
177
178                    // Return a new session with the CST, token, and account ID
179                    // Use the rate limit type and safety margin from the config
180                    let session =
181                        IgSession::from_config(cst.clone(), token.clone(), account_id, self.cfg);
182
183                    // Log rate limiter stats if available
184                    if let Some(stats) = session.get_rate_limit_stats().await {
185                        debug!("Rate limiter initialized: {}", stats);
186                    }
187
188                    return Ok(session);
189                }
190                StatusCode::UNAUTHORIZED => {
191                    error!("Authentication failed with UNAUTHORIZED");
192                    let body = resp
193                        .text()
194                        .await
195                        .unwrap_or_else(|_| "Could not read response body".to_string());
196                    error!("Response body: {}", body);
197                    return Err(AuthError::BadCredentials);
198                }
199                StatusCode::FORBIDDEN => {
200                    error!("Authentication failed with FORBIDDEN");
201                    let body = resp
202                        .text()
203                        .await
204                        .unwrap_or_else(|_| "Could not read response body".to_string());
205
206                    if body.contains("exceeded-api-key-allowance") {
207                        error!("Rate Limit Exceeded: {}", &body);
208
209                        // Implementamos reintento con espera exponencial para este caso específico
210                        if retry_count < MAX_RETRIES {
211                            retry_count += 1;
212                            // Usamos un retraso más largo y añadimos un poco de aleatoriedad para evitar patrones
213                            let jitter = rand::random::<u64>() % 5000; // Hasta 5 segundos de jitter
214                            let delay = retry_delay_ms + jitter;
215                            warn!(
216                                "Rate limit exceeded. Retrying in {} ms (attempt {} of {})",
217                                delay, retry_count, MAX_RETRIES
218                            );
219
220                            // Esperar antes de reintentar
221                            tokio::time::sleep(Duration::from_millis(delay)).await;
222
223                            // Aumentar el tiempo de espera exponencialmente para el próximo reintento
224                            retry_delay_ms *= 2; // Exponential backoff
225                            continue;
226                        } else {
227                            error!(
228                                "Maximum retry attempts ({}) reached. Giving up.",
229                                MAX_RETRIES
230                            );
231                            return Err(AuthError::RateLimitExceeded);
232                        }
233                    }
234
235                    error!("Response body: {}", body);
236                    return Err(AuthError::BadCredentials);
237                }
238                other => {
239                    error!("Authentication failed with unexpected status: {}", other);
240                    let body = resp
241                        .text()
242                        .await
243                        .unwrap_or_else(|_| "Could not read response body".to_string());
244                    error!("Response body: {}", body);
245                    return Err(AuthError::Unexpected(other));
246                }
247            }
248        }
249    }
250
251    async fn refresh(&self, sess: &IgSession) -> Result<IgSession, AuthError> {
252        let url = self.rest_url("session/refresh-token");
253
254        // Ensure the API key is trimmed and has no whitespace
255        let api_key = self.cfg.credentials.api_key.trim();
256
257        // Log the request details for debugging
258        info!("Refresh request to URL: {}", url);
259        info!("Using API key (length): {}", api_key.len());
260        info!("Using CST token (length): {}", sess.cst.len());
261        info!("Using X-SECURITY-TOKEN (length): {}", sess.token.len());
262
263        // Create a new client for each request to avoid any potential issues with cached state
264        let client = Client::builder()
265            .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
266            .build()
267            .expect("reqwest client");
268
269        let resp = client
270            .post(url)
271            .header("X-IG-API-KEY", api_key)
272            .header("CST", &sess.cst)
273            .header("X-SECURITY-TOKEN", &sess.token)
274            .header("Version", "3")
275            .header("Content-Type", "application/json; charset=UTF-8")
276            .header("Accept", "application/json; charset=UTF-8")
277            .send()
278            .await?;
279
280        // Log the response status and headers for debugging
281        info!("Refresh response status: {}", resp.status());
282        tracing::debug!("Response headers: {:#?}", resp.headers());
283
284        match resp.status() {
285            StatusCode::OK => {
286                // Extract CST and X-SECURITY-TOKEN from headers
287                let cst = match resp.headers().get("CST") {
288                    Some(value) => {
289                        let cst_str = value
290                            .to_str()
291                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
292                        info!(
293                            "Successfully obtained refreshed CST token of length: {}",
294                            cst_str.len()
295                        );
296                        cst_str.to_owned()
297                    }
298                    None => {
299                        error!("CST header not found in refresh response");
300                        return Err(AuthError::Unexpected(StatusCode::OK));
301                    }
302                };
303
304                let token = match resp.headers().get("X-SECURITY-TOKEN") {
305                    Some(value) => {
306                        let token_str = value
307                            .to_str()
308                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
309                        info!(
310                            "Successfully obtained refreshed X-SECURITY-TOKEN of length: {}",
311                            token_str.len()
312                        );
313                        token_str.to_owned()
314                    }
315                    None => {
316                        error!("X-SECURITY-TOKEN header not found in refresh response");
317                        return Err(AuthError::Unexpected(StatusCode::OK));
318                    }
319                };
320
321                // Parse the response body to get the account ID
322                let json: SessionResp = resp.json().await?;
323                info!("Refreshed session for Account ID: {}", json.account_id);
324
325                // Return a new session with the updated tokens
326                Ok(IgSession::from_config(
327                    cst,
328                    token,
329                    json.account_id,
330                    self.cfg,
331                ))
332            }
333            other => {
334                error!("Session refresh failed with status: {}", other);
335                let body = resp
336                    .text()
337                    .await
338                    .unwrap_or_else(|_| "Could not read response body".to_string());
339                error!("Response body: {}", body);
340                Err(AuthError::Unexpected(other))
341            }
342        }
343    }
344
345    async fn switch_account(
346        &self,
347        session: &IgSession,
348        account_id: &str,
349        default_account: Option<bool>,
350    ) -> Result<IgSession, AuthError> {
351        // Check if the account to switch to is the same as the current one
352        if session.account_id == account_id {
353            info!("Already on account ID: {}. No need to switch.", account_id);
354            // Return a copy of the current session with the same rate limiter configuration
355            return Ok(IgSession::from_config(
356                session.cst.clone(),
357                session.token.clone(),
358                session.account_id.clone(),
359                self.cfg,
360            ));
361        }
362
363        let url = self.rest_url("session");
364
365        // Ensure the API key is trimmed and has no whitespace
366        let api_key = self.cfg.credentials.api_key.trim();
367
368        // Log the request details for debugging
369        info!("Account switch request to URL: {}", url);
370        info!("Using API key (length): {}", api_key.len());
371        info!("Switching to account ID: {}", account_id);
372        info!("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        tracing::debug!(
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("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
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        info!("Account switch response status: {}", resp.status());
406        tracing::debug!("Response headers: {:#?}", resp.headers());
407
408        match resp.status() {
409            StatusCode::OK => {
410                // Parse the response body
411                let switch_response: AccountSwitchResponse = resp.json().await?;
412                info!("Account switch successful");
413                tracing::debug!("Account switch response: {:?}", switch_response);
414
415                // Return a new session with the updated account ID and the config's rate limiter settings
416                // The CST and token remain the same
417                Ok(IgSession::from_config(
418                    session.cst.clone(),
419                    session.token.clone(),
420                    account_id.to_string(),
421                    self.cfg,
422                ))
423            }
424            other => {
425                error!("Account switch failed with status: {}", other);
426                let body = resp
427                    .text()
428                    .await
429                    .unwrap_or_else(|_| "Could not read response body".to_string());
430                error!("Response body: {}", body);
431
432                // Si el error es 401 Unauthorized, podría ser que el ID de cuenta no sea válido
433                // o no pertenezca al usuario autenticado
434                if other == StatusCode::UNAUTHORIZED {
435                    tracing::warn!(
436                        "Cannot switch to account ID: {}. The account might not exist or you don't have permission.",
437                        account_id
438                    );
439                }
440
441                Err(AuthError::Unexpected(other))
442            }
443        }
444    }
445}