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::SessionResp,
11};
12
13/// Authentication handler for IG Markets API
14pub struct IgAuth<'a> {
15    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}