ig_client/session/
auth.rs

1// Authentication module for IG Markets API
2
3use async_trait::async_trait;
4use reqwest::{Client, StatusCode};
5
6use crate::{
7    config::Config,
8    error::AuthError,
9    session::interface::{IgAuthenticator, IgSession},
10    session::response::{AccountSwitchRequest, AccountSwitchResponse, SessionResp},
11};
12
13/// Authentication handler for IG Markets API
14pub struct IgAuth<'a> {
15    pub(crate) cfg: &'a Config,
16    http: Client,
17}
18
19impl<'a> IgAuth<'a> {
20    /// Creates a new IG authentication handler
21    ///
22    /// # Arguments
23    /// * `cfg` - Reference to the configuration
24    ///
25    /// # Returns
26    /// * A new IgAuth instance
27    pub fn new(cfg: &'a Config) -> Self {
28        Self {
29            cfg,
30            http: Client::builder()
31                .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")
32                .build()
33                .expect("reqwest client"),
34        }
35    }
36
37    /// Returns the correct base URL (demo vs live) according to the configuration
38    fn rest_url(&self, path: &str) -> String {
39        format!(
40            "{}/{}",
41            self.cfg.rest_api.base_url.trim_end_matches('/'),
42            path.trim_start_matches('/')
43        )
44    }
45
46    /// Retrieves a reference to the `Client` instance.
47    ///
48    /// This method returns a reference to the `Client` object,
49    /// which is typically used for making HTTP requests or interacting
50    /// with other network-related services.
51    ///
52    /// # Returns
53    ///
54    /// * `&Client` - A reference to the internally stored `Client` object.
55    ///
56    #[allow(dead_code)]
57    fn get_client(&self) -> &Client {
58        &self.http
59    }
60}
61
62#[async_trait]
63impl IgAuthenticator for IgAuth<'_> {
64    async fn login(&self) -> Result<IgSession, AuthError> {
65        // Following the exact approach from trading-ig Python library
66        let url = self.rest_url("session");
67
68        // Ensure the API key is trimmed and has no whitespace
69        let api_key = self.cfg.credentials.api_key.trim();
70        let username = self.cfg.credentials.username.trim();
71        let password = self.cfg.credentials.password.trim();
72
73        // Log the request details for debugging
74        tracing::info!("Login request to URL: {}", url);
75        tracing::info!("Using API key (length): {}", api_key.len());
76        tracing::info!("Using username: {}", username);
77
78        // Create the body exactly as in the Python library
79        let body = serde_json::json!({
80            "identifier": username,
81            "password": password,
82            "encryptedPassword": false
83        });
84
85        tracing::debug!(
86            "Request body: {}",
87            serde_json::to_string(&body).unwrap_or_default()
88        );
89
90        // Create a new client for each request to avoid any potential issues with cached state
91        let client = Client::builder()
92            .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")
93            .build()
94            .expect("reqwest client");
95
96        // Add headers exactly as in the Python library
97        let resp = client
98            .post(url)
99            .header("X-IG-API-KEY", api_key)
100            .header("Content-Type", "application/json; charset=UTF-8")
101            .header("Accept", "application/json; charset=UTF-8")
102            .header("Version", "2")
103            .json(&body)
104            .send()
105            .await?;
106
107        // Log the response status and headers for debugging
108        tracing::info!("Login response status: {}", resp.status());
109        tracing::debug!("Response headers: {:#?}", resp.headers());
110
111        match resp.status() {
112            StatusCode::OK => {
113                // Extract CST and X-SECURITY-TOKEN from headers
114                let cst = match resp.headers().get("CST") {
115                    Some(value) => {
116                        let cst_str = value
117                            .to_str()
118                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
119                        tracing::info!(
120                            "Successfully obtained CST token of length: {}",
121                            cst_str.len()
122                        );
123                        cst_str.to_owned()
124                    }
125                    None => {
126                        tracing::error!("CST header not found in response");
127                        return Err(AuthError::Unexpected(StatusCode::OK));
128                    }
129                };
130
131                let token = match resp.headers().get("X-SECURITY-TOKEN") {
132                    Some(value) => {
133                        let token_str = value
134                            .to_str()
135                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
136                        tracing::info!(
137                            "Successfully obtained X-SECURITY-TOKEN of length: {}",
138                            token_str.len()
139                        );
140                        token_str.to_owned()
141                    }
142                    None => {
143                        tracing::error!("X-SECURITY-TOKEN header not found in response");
144                        return Err(AuthError::Unexpected(StatusCode::OK));
145                    }
146                };
147
148                // Parse the response body to get the account ID
149                let json: SessionResp = resp.json().await?;
150                tracing::info!("Account ID: {}", json.account_id);
151
152                Ok(IgSession {
153                    cst,
154                    token,
155                    account_id: json.account_id,
156                })
157            }
158            StatusCode::UNAUTHORIZED => {
159                tracing::error!("Authentication failed with UNAUTHORIZED");
160                let body = resp
161                    .text()
162                    .await
163                    .unwrap_or_else(|_| "Could not read response body".to_string());
164                tracing::error!("Response body: {}", body);
165                Err(AuthError::BadCredentials)
166            }
167            StatusCode::FORBIDDEN => {
168                tracing::error!("Authentication failed with FORBIDDEN");
169                let body = resp
170                    .text()
171                    .await
172                    .unwrap_or_else(|_| "Could not read response body".to_string());
173                tracing::error!("Response body: {}", body);
174                Err(AuthError::BadCredentials)
175            }
176            other => {
177                tracing::error!("Authentication failed with unexpected status: {}", other);
178                let body = resp
179                    .text()
180                    .await
181                    .unwrap_or_else(|_| "Could not read response body".to_string());
182                tracing::error!("Response body: {}", body);
183                Err(AuthError::Unexpected(other))
184            }
185        }
186    }
187
188    async fn refresh(&self, sess: &IgSession) -> Result<IgSession, AuthError> {
189        let url = self.rest_url("session/refresh-token");
190
191        // Ensure the API key is trimmed and has no whitespace
192        let api_key = self.cfg.credentials.api_key.trim();
193
194        // Log the request details for debugging
195        tracing::info!("Refresh request to URL: {}", url);
196        tracing::info!("Using API key (length): {}", api_key.len());
197        tracing::info!("Using CST token (length): {}", sess.cst.len());
198        tracing::info!("Using X-SECURITY-TOKEN (length): {}", sess.token.len());
199
200        // Create a new client for each request to avoid any potential issues with cached state
201        let client = Client::builder()
202            .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")
203            .build()
204            .expect("reqwest client");
205
206        let resp = client
207            .post(url)
208            .header("X-IG-API-KEY", api_key)
209            .header("CST", &sess.cst)
210            .header("X-SECURITY-TOKEN", &sess.token)
211            .header("Version", "3")
212            .header("Content-Type", "application/json; charset=UTF-8")
213            .header("Accept", "application/json; charset=UTF-8")
214            .send()
215            .await?;
216
217        // Log the response status and headers for debugging
218        tracing::info!("Refresh response status: {}", resp.status());
219        tracing::debug!("Response headers: {:#?}", resp.headers());
220
221        match resp.status() {
222            StatusCode::OK => {
223                // Extract CST and X-SECURITY-TOKEN from headers
224                let cst = match resp.headers().get("CST") {
225                    Some(value) => {
226                        let cst_str = value
227                            .to_str()
228                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
229                        tracing::info!(
230                            "Successfully obtained refreshed CST token of length: {}",
231                            cst_str.len()
232                        );
233                        cst_str.to_owned()
234                    }
235                    None => {
236                        tracing::error!("CST header not found in refresh response");
237                        return Err(AuthError::Unexpected(StatusCode::OK));
238                    }
239                };
240
241                let token = match resp.headers().get("X-SECURITY-TOKEN") {
242                    Some(value) => {
243                        let token_str = value
244                            .to_str()
245                            .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
246                        tracing::info!(
247                            "Successfully obtained refreshed X-SECURITY-TOKEN of length: {}",
248                            token_str.len()
249                        );
250                        token_str.to_owned()
251                    }
252                    None => {
253                        tracing::error!("X-SECURITY-TOKEN header not found in refresh response");
254                        return Err(AuthError::Unexpected(StatusCode::OK));
255                    }
256                };
257
258                // Parse the response body to get the account ID
259                let json: SessionResp = resp.json().await?;
260                tracing::info!("Refreshed session for Account ID: {}", json.account_id);
261
262                Ok(IgSession {
263                    cst,
264                    token,
265                    account_id: json.account_id,
266                })
267            }
268            other => {
269                tracing::error!("Session refresh failed with status: {}", other);
270                let body = resp
271                    .text()
272                    .await
273                    .unwrap_or_else(|_| "Could not read response body".to_string());
274                tracing::error!("Response body: {}", body);
275                Err(AuthError::Unexpected(other))
276            }
277        }
278    }
279
280    async fn switch_account(
281        &self,
282        session: &IgSession,
283        account_id: &str,
284        default_account: Option<bool>,
285    ) -> Result<IgSession, AuthError> {
286        // Check if the account to switch to is the same as the current one
287        if session.account_id == account_id {
288            tracing::info!("Already on account ID: {}. No need to switch.", account_id);
289            // Return a copy of the current session
290            return Ok(IgSession {
291                cst: session.cst.clone(),
292                token: session.token.clone(),
293                account_id: session.account_id.clone(),
294            });
295        }
296
297        let url = self.rest_url("session");
298
299        // Ensure the API key is trimmed and has no whitespace
300        let api_key = self.cfg.credentials.api_key.trim();
301
302        // Log the request details for debugging
303        tracing::info!("Account switch request to URL: {}", url);
304        tracing::info!("Using API key (length): {}", api_key.len());
305        tracing::info!("Switching to account ID: {}", account_id);
306        tracing::info!("Set as default account: {:?}", default_account);
307
308        // Create the request body
309        let body = AccountSwitchRequest {
310            account_id: account_id.to_string(),
311            default_account,
312        };
313
314        tracing::debug!(
315            "Request body: {}",
316            serde_json::to_string(&body).unwrap_or_default()
317        );
318
319        // Create a new client for each request
320        let client = Client::builder()
321            .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")
322            .build()
323            .expect("reqwest client");
324
325        // Make the PUT request to switch accounts
326        let resp = client
327            .put(url)
328            .header("X-IG-API-KEY", api_key)
329            .header("CST", &session.cst)
330            .header("X-SECURITY-TOKEN", &session.token)
331            .header("Version", "1")
332            .header("Content-Type", "application/json; charset=UTF-8")
333            .header("Accept", "application/json; charset=UTF-8")
334            .json(&body)
335            .send()
336            .await?;
337
338        // Log the response status and headers for debugging
339        tracing::info!("Account switch response status: {}", resp.status());
340        tracing::debug!("Response headers: {:#?}", resp.headers());
341
342        match resp.status() {
343            StatusCode::OK => {
344                // Parse the response body
345                let switch_response: AccountSwitchResponse = resp.json().await?;
346                tracing::info!("Account switch successful");
347                tracing::debug!("Account switch response: {:?}", switch_response);
348
349                // Return a new session with the updated account ID
350                // The CST and token remain the same
351                Ok(IgSession {
352                    cst: session.cst.clone(),
353                    token: session.token.clone(),
354                    account_id: account_id.to_string(),
355                })
356            }
357            other => {
358                tracing::error!("Account switch failed with status: {}", other);
359                let body = resp
360                    .text()
361                    .await
362                    .unwrap_or_else(|_| "Could not read response body".to_string());
363                tracing::error!("Response body: {}", body);
364
365                // Si el error es 401 Unauthorized, podría ser que el ID de cuenta no sea válido
366                // o no pertenezca al usuario autenticado
367                if other == StatusCode::UNAUTHORIZED {
368                    tracing::warn!(
369                        "Cannot switch to account ID: {}. The account might not exist or you don't have permission.",
370                        account_id
371                    );
372                }
373
374                Err(AuthError::Unexpected(other))
375            }
376        }
377    }
378}