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, info, 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> {
254 let url = self.rest_url("session/refresh-token");
255
256 let api_key = self.cfg.credentials.api_key.trim();
258
259 debug!("Refresh request to URL: {}", url);
261 debug!("Using API key (length): {}", api_key.len());
262 debug!("Using CST token (length): {}", sess.cst.len());
263 debug!("Using X-SECURITY-TOKEN (length): {}", sess.token.len());
264
265 let client = Client::builder()
267 .user_agent(USER_AGENT)
268 .build()
269 .expect("reqwest client");
270
271 let resp = client
272 .post(url)
273 .header("X-IG-API-KEY", api_key)
274 .header("CST", &sess.cst)
275 .header("X-SECURITY-TOKEN", &sess.token)
276 .header("Version", "3")
277 .header("Content-Type", "application/json; charset=UTF-8")
278 .header("Accept", "application/json; charset=UTF-8")
279 .send()
280 .await?;
281
282 debug!("Refresh response status: {}", resp.status());
284 trace!("Response headers: {:#?}", resp.headers());
285
286 match resp.status() {
287 StatusCode::OK => {
288 let cst = match resp.headers().get("CST") {
290 Some(value) => {
291 let cst_str = value
292 .to_str()
293 .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
294 debug!(
295 "Successfully obtained refreshed CST token of length: {}",
296 cst_str.len()
297 );
298 cst_str.to_owned()
299 }
300 None => {
301 error!("CST header not found in refresh response");
302 return Err(AuthError::Unexpected(StatusCode::OK));
303 }
304 };
305
306 let token = match resp.headers().get("X-SECURITY-TOKEN") {
307 Some(value) => {
308 let token_str = value
309 .to_str()
310 .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
311 debug!(
312 "Successfully obtained refreshed X-SECURITY-TOKEN of length: {}",
313 token_str.len()
314 );
315 token_str.to_owned()
316 }
317 None => {
318 error!("X-SECURITY-TOKEN header not found in refresh response");
319 return Err(AuthError::Unexpected(StatusCode::OK));
320 }
321 };
322
323 let json: SessionResp = resp.json().await?;
325 debug!("Refreshed session for Account ID: {}", json.account_id);
326
327 Ok(IgSession::from_config(
329 cst,
330 token,
331 json.account_id,
332 self.cfg,
333 ))
334 }
335 other => {
336 error!("Session refresh failed with status: {}", other);
337 let body = resp
338 .text()
339 .await
340 .unwrap_or_else(|_| "Could not read response body".to_string());
341 error!("Response body: {}", body);
342 Err(AuthError::Unexpected(other))
343 }
344 }
345 }
346
347 async fn switch_account(
348 &self,
349 session: &IgSession,
350 account_id: &str,
351 default_account: Option<bool>,
352 ) -> Result<IgSession, AuthError> {
353 if session.account_id == account_id {
355 debug!("Already on account ID: {}. No need to switch.", account_id);
356 return Ok(IgSession::from_config(
358 session.cst.clone(),
359 session.token.clone(),
360 session.account_id.clone(),
361 self.cfg,
362 ));
363 }
364
365 let url = self.rest_url("session");
366 let api_key = self.cfg.credentials.api_key.trim();
367
368 debug!("Account switch request to URL: {}", url);
370 debug!("Using API key (length): {}", api_key.len());
371 debug!("Switching to account ID: {}", account_id);
372 debug!("Set as default account: {:?}", default_account);
373
374 let body = AccountSwitchRequest {
376 account_id: account_id.to_string(),
377 default_account,
378 };
379
380 trace!(
381 "Request body: {}",
382 serde_json::to_string(&body).unwrap_or_default()
383 );
384
385 let client = Client::builder()
387 .user_agent(USER_AGENT)
388 .build()
389 .expect("reqwest client");
390
391 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 debug!("Account switch response status: {}", resp.status());
406 trace!("Response headers: {:#?}", resp.headers());
407
408 match resp.status() {
409 StatusCode::OK => {
410 let new_cst = match resp.headers().get("CST") {
417 Some(value) => {
418 let cst_str = value
419 .to_str()
420 .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
421 debug!(
422 "Successfully obtained new CST token of length: {}",
423 cst_str.len()
424 );
425 cst_str.to_owned()
426 }
427 None => {
428 warn!("CST header not found in switch response, using existing token");
429 return Err(AuthError::Unexpected(StatusCode::NO_CONTENT));
430 }
431 };
432
433 let new_token = match resp.headers().get("X-SECURITY-TOKEN") {
434 Some(value) => {
435 let token_str = value
436 .to_str()
437 .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
438 debug!(
439 "Successfully obtained new X-SECURITY-TOKEN of length: {}",
440 token_str.len()
441 );
442 token_str.to_owned()
443 }
444 None => {
445 warn!(
446 "X-SECURITY-TOKEN header not found in switch response, using existing token"
447 );
448 return Err(AuthError::Unexpected(StatusCode::NO_CONTENT));
449 }
450 };
451
452 let switch_response: AccountSwitchResponse = resp.json().await?;
454 info!("Account switch successful to: {}", account_id);
455 trace!("Account switch response: {:?}", switch_response);
456
457 Ok(IgSession::from_config(
459 new_cst,
460 new_token,
461 account_id.to_string(),
462 self.cfg,
463 ))
464 }
465 other => {
466 error!("Account switch failed with status: {}", other);
467 let body = resp
468 .text()
469 .await
470 .unwrap_or_else(|_| "Could not read response body".to_string());
471 error!("Response body: {}", body);
472
473 if other == StatusCode::UNAUTHORIZED {
476 warn!(
477 "Cannot switch to account ID: {}. The account might not exist or you don't have permission.",
478 account_id
479 );
480 }
481
482 Err(AuthError::Unexpected(other))
483 }
484 }
485 }
486
487 async fn relogin(&self, session: &IgSession) -> Result<IgSession, AuthError> {
488 let margin = chrono::Duration::minutes(30);
490
491 let is_expired = {
492 let timer = session.token_timer.lock().unwrap();
493 timer.is_expired_w_margin(margin)
494 };
495
496 if is_expired {
497 info!("Tokens are expired or close to expiring, performing re-login");
498 self.login().await
499 } else {
500 debug!("Tokens are still valid, reusing existing session");
501 Ok(session.clone())
502 }
503 }
504
505 async fn relogin_and_switch_account(
506 &self,
507 session: &IgSession,
508 account_id: &str,
509 default_account: Option<bool>,
510 ) -> Result<IgSession, AuthError> {
511 let session = self.relogin(session).await?;
512 debug!(
513 "Relogin check completed for account: {}, trying to switch to {}",
514 session.account_id, account_id
515 );
516
517 match self
518 .switch_account(&session, account_id, default_account)
519 .await
520 {
521 Ok(new_session) => Ok(new_session),
522 Err(e) => {
523 warn!("Could not switch to account {}: {:?}.", account_id, e);
524 Err(e)
525 }
526 }
527 }
528
529 async fn login_and_switch_account(
530 &self,
531 account_id: &str,
532 default_account: Option<bool>,
533 ) -> Result<IgSession, AuthError> {
534 let session = self.login().await?;
535 self.relogin_and_switch_account(&session, account_id, default_account)
536 .await
537 }
538}