1use 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
17pub struct IgAuth<'a> {
19 pub(crate) cfg: &'a Config,
20 http: Client,
21}
22
23impl<'a> IgAuth<'a> {
24 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 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 #[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 const MAX_RETRIES: u32 = 3;
71 const INITIAL_RETRY_DELAY_MS: u64 = 10000; let mut retry_count = 0;
74 let mut retry_delay_ms = INITIAL_RETRY_DELAY_MS;
75
76 loop {
77 let limiter = app_non_trading_limiter();
79 limiter.wait().await;
80
81 let url = self.rest_url("session");
83
84 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 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 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 let client = Client::builder()
112 .user_agent(USER_AGENT)
113 .build()
114 .expect("reqwest client");
115
116 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 debug!("Login response status: {}", resp.status());
136 trace!("Response headers: {:#?}", resp.headers());
137
138 match resp.status() {
139 StatusCode::OK => {
140 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 let json: SessionResp = resp.json().await?;
177 let account_id = json.account_id.clone();
178
179 let session =
182 IgSession::from_config(cst.clone(), token.clone(), account_id, self.cfg);
183
184 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 if retry_count < MAX_RETRIES {
212 retry_count += 1;
213 let jitter = rand::random::<u64>() % 5000; 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 tokio::time::sleep(Duration::from_millis(delay)).await;
223
224 retry_delay_ms *= 2; 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 let api_key = self.cfg.credentials.api_key.trim();
257
258 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 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 debug!("Refresh response status: {}", resp.status());
283 trace!("Response headers: {:#?}", resp.headers());
284
285 match resp.status() {
286 StatusCode::OK => {
287 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 let json: SessionResp = resp.json().await?;
324 debug!("Refreshed session for Account ID: {}", json.account_id);
325
326 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 if session.account_id == account_id {
354 debug!("Already on account ID: {}. No need to switch.", account_id);
355 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 let api_key = self.cfg.credentials.api_key.trim();
368
369 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 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 let client = Client::builder()
388 .user_agent(USER_AGENT)
389 .build()
390 .expect("reqwest client");
391
392 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 debug!("Account switch response status: {}", resp.status());
407 trace!("Response headers: {:#?}", resp.headers());
408
409 match resp.status() {
410 StatusCode::OK => {
411 let switch_response: AccountSwitchResponse = resp.json().await?;
413 debug!("Account switch successful");
414 trace!("Account switch response: {:?}", switch_response);
415
416 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 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}