1use 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
16pub struct IgAuth<'a> {
18 pub(crate) cfg: &'a Config,
19 http: Client,
20}
21
22impl<'a> IgAuth<'a> {
23 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 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 #[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 const MAX_RETRIES: u32 = 3;
70 const INITIAL_RETRY_DELAY_MS: u64 = 10000; let mut retry_count = 0;
73 let mut retry_delay_ms = INITIAL_RETRY_DELAY_MS;
74
75 loop {
76 let limiter = app_non_trading_limiter();
78 limiter.wait().await;
79
80 let url = self.rest_url("session");
82
83 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 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 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 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 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 info!("Login response status: {}", resp.status());
135 debug!("Response headers: {:#?}", resp.headers());
136
137 match resp.status() {
138 StatusCode::OK => {
139 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 let json: SessionResp = resp.json().await?;
176 let account_id = json.account_id.clone();
177
178 let session =
181 IgSession::from_config(cst.clone(), token.clone(), account_id, self.cfg);
182
183 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 if retry_count < MAX_RETRIES {
211 retry_count += 1;
212 let jitter = rand::random::<u64>() % 5000; 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 tokio::time::sleep(Duration::from_millis(delay)).await;
222
223 retry_delay_ms *= 2; 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 let api_key = self.cfg.credentials.api_key.trim();
256
257 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 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 info!("Refresh response status: {}", resp.status());
282 tracing::debug!("Response headers: {:#?}", resp.headers());
283
284 match resp.status() {
285 StatusCode::OK => {
286 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 let json: SessionResp = resp.json().await?;
323 info!("Refreshed session for Account ID: {}", json.account_id);
324
325 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 if session.account_id == account_id {
353 info!("Already on account ID: {}. No need to switch.", account_id);
354 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 let api_key = self.cfg.credentials.api_key.trim();
367
368 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 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 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 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 info!("Account switch response status: {}", resp.status());
406 tracing::debug!("Response headers: {:#?}", resp.headers());
407
408 match resp.status() {
409 StatusCode::OK => {
410 let switch_response: AccountSwitchResponse = resp.json().await?;
412 info!("Account switch successful");
413 tracing::debug!("Account switch response: {:?}", switch_response);
414
415 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 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}