1use crate::application::config::Config;
16use crate::application::rate_limiter::RateLimiter;
17use crate::error::AppError;
18pub(crate) use crate::model::auth::{OAuthToken, SecurityHeaders, SessionResponse};
19use crate::model::http::make_http_request;
20use crate::model::retry::RetryConfig;
21use crate::prelude::Deserialize;
22use chrono::Utc;
23use pretty_simple_display::{DebugPretty, DisplaySimple};
24use reqwest::{Client, Method};
25use serde::Serialize;
26use std::sync::Arc;
27use tokio::sync::RwLock;
28use tracing::{debug, error, info, warn};
29
30const USER_AGENT: &str = "ig-client/0.6.0";
31
32#[derive(DebugPretty, Clone, Default, Serialize, Deserialize, DisplaySimple)]
37pub struct WebsocketInfo {
38 pub server: String,
40 pub cst: Option<String>,
42 pub x_security_token: Option<String>,
44 pub account_id: String,
46}
47
48impl WebsocketInfo {
49 #[must_use]
55 pub fn get_ws_password(&self) -> String {
56 match (&self.cst, &self.x_security_token) {
57 (Some(cst), Some(x_security_token)) => {
58 format!("CST-{}|XST-{}", cst, x_security_token)
59 }
60 _ => String::new(),
61 }
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct Session {
68 pub account_id: String,
70 pub client_id: String,
72 pub lightstreamer_endpoint: String,
74 pub cst: Option<String>,
76 pub x_security_token: Option<String>,
78 pub oauth_token: Option<OAuthToken>,
80 pub api_version: u8,
82 pub expires_at: u64,
86}
87
88impl Session {
89 #[must_use]
91 #[inline]
92 pub fn is_oauth(&self) -> bool {
93 self.oauth_token.is_some()
94 }
95
96 #[must_use]
105 #[inline]
106 pub fn is_expired(&self, margin_seconds: Option<u64>) -> bool {
107 let margin = margin_seconds.unwrap_or(60);
108 let now = Utc::now().timestamp() as u64;
109 now >= (self.expires_at - margin)
110 }
111
112 #[must_use]
118 #[inline]
119 pub fn seconds_until_expiry(&self) -> u64 {
120 self.expires_at - Utc::now().timestamp() as u64
121 }
122
123 #[must_use]
128 #[inline]
129 pub fn needs_token_refresh(&self, margin_seconds: Option<u64>) -> bool {
130 self.is_expired(margin_seconds)
131 }
132
133 #[must_use]
138 pub fn get_websocket_info(&self) -> WebsocketInfo {
139 let server = if self.lightstreamer_endpoint.starts_with("http://")
141 || self.lightstreamer_endpoint.starts_with("https://")
142 {
143 format!("{}/lightstreamer", self.lightstreamer_endpoint)
144 } else {
145 format!("https://{}/lightstreamer", self.lightstreamer_endpoint)
146 };
147
148 WebsocketInfo {
149 server,
150 cst: self.cst.clone(),
151 x_security_token: self.x_security_token.clone(),
152 account_id: self.account_id.clone(),
153 }
154 }
155}
156
157impl From<SessionResponse> for Session {
158 fn from(v: SessionResponse) -> Self {
159 v.get_session()
160 }
161}
162
163pub struct Auth {
172 config: Arc<Config>,
173 client: Client,
174 session: Arc<RwLock<Option<Session>>>,
175 rate_limiter: Arc<RwLock<RateLimiter>>,
176}
177
178impl Auth {
179 pub fn new(config: Arc<Config>) -> Self {
184 let client = Client::builder()
185 .user_agent(USER_AGENT)
186 .build()
187 .expect("Failed to create HTTP client");
188
189 let rate_limiter = Arc::new(RwLock::new(RateLimiter::new(&config.rate_limiter)));
190
191 Self {
192 config,
193 client,
194 session: Arc::new(RwLock::new(None)),
195 rate_limiter,
196 }
197 }
198
199 pub async fn get_ws_info(&self) -> WebsocketInfo {
204 match self.login_v2().await {
205 Ok(sess) => sess.get_websocket_info(),
206 Err(e) => {
207 error!("Failed to get WebSocket info, login failed: {}", e);
208 WebsocketInfo::default()
209 }
210 }
211 }
212
213 pub async fn get_session(&self) -> Result<Session, AppError> {
221 let session = self.session.read().await;
222
223 if let Some(sess) = session.as_ref() {
224 if sess.needs_token_refresh(Some(300)) {
226 drop(session); debug!("OAuth token needs refresh");
228 return self.refresh_token().await;
229 }
230 return Ok(sess.clone());
231 }
232
233 drop(session);
234
235 info!("No active session, logging in");
237 self.login().await
238 }
239
240 pub async fn login(&self) -> Result<Session, AppError> {
248 let api_version = self.config.api_version.unwrap_or(2);
249
250 debug!("Logging in with API v{}", api_version);
251
252 let session = if api_version == 3 {
253 self.login_oauth().await?
254 } else {
255 self.login_v2().await?
256 };
257
258 let mut sess = self.session.write().await;
260 *sess = Some(session.clone());
261
262 info!("✓ Login successful, account: {}", session.account_id);
263 Ok(session)
264 }
265
266 async fn login_v2(&self) -> Result<Session, AppError> {
268 let url = format!("{}/session", self.config.rest_api.base_url);
269
270 let body = serde_json::json!({
271 "identifier": self.config.credentials.username,
272 "password": self.config.credentials.password,
273 });
274
275 debug!("Sending v2 login request to: {}", url);
276
277 let headers = vec![
278 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
279 ("Content-Type", "application/json"),
280 ("Version", "2"),
281 ];
282
283 let response = make_http_request(
284 &self.client,
285 self.rate_limiter.clone(),
286 Method::POST,
287 &url,
288 headers,
289 &Some(body),
290 RetryConfig::infinite(),
291 )
292 .await?;
293
294 let cst: String = match response
296 .headers()
297 .get("CST")
298 .and_then(|v| v.to_str().ok())
299 .map(String::from)
300 {
301 Some(token) => token,
302 None => {
303 error!("CST header not found in response");
304 return Err(AppError::InvalidInput("CST missing".to_string()));
305 }
306 };
307 let x_security_token: String = match response
308 .headers()
309 .get("X-SECURITY-TOKEN")
310 .and_then(|v| v.to_str().ok())
311 .map(String::from)
312 {
313 Some(token) => token,
314 None => {
315 error!("X-SECURITY-TOKEN header not found in response");
316 return Err(AppError::InvalidInput(
317 "X-SECURITY-TOKEN missing".to_string(),
318 ));
319 }
320 };
321
322 let x_ig_api_key: String = response
323 .headers()
324 .get("X-IG-API-KEY")
325 .and_then(|v| v.to_str().ok())
326 .map(String::from)
327 .unwrap_or_else(|| self.config.credentials.api_key.clone());
328
329 let security_headers: SecurityHeaders = SecurityHeaders {
330 cst,
331 x_security_token,
332 x_ig_api_key,
333 };
334
335 let body_text = response.text().await.map_err(|e| {
337 error!("Failed to read response body: {}", e);
338 AppError::Network(e)
339 })?;
340 debug!("Login response body length: {} bytes", body_text.len());
341
342 let mut response: SessionResponse = serde_json::from_str(&body_text).map_err(|e| {
344 error!("Failed to parse login response JSON: {}", e);
345 error!("Response body: {}", body_text);
346 AppError::Deserialization(format!("Failed to parse login response: {}", e))
347 })?;
348 let session = response.get_session_v2(&security_headers);
349
350 Ok(session)
351 }
352
353 async fn login_oauth(&self) -> Result<Session, AppError> {
355 let url = format!("{}/session", self.config.rest_api.base_url);
356
357 let body = serde_json::json!({
358 "identifier": self.config.credentials.username,
359 "password": self.config.credentials.password,
360 });
361
362 debug!("Sending OAuth login request to: {}", url);
363 let headers = vec![
364 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
365 ("Content-Type", "application/json"),
366 ("Version", "3"),
367 ];
368
369 let response = make_http_request(
370 &self.client,
371 self.rate_limiter.clone(),
372 Method::POST,
373 &url,
374 headers,
375 &Some(body),
376 RetryConfig::infinite(),
377 )
378 .await?;
379
380 let response: SessionResponse = response.json().await?;
381 let mut session = response.get_session();
382 if session.account_id != self.config.credentials.account_id {
383 session.account_id = self.config.credentials.account_id.clone();
384 };
385
386 assert!(session.is_oauth());
387
388 Ok(session)
389 }
390
391 pub async fn refresh_token(&self) -> Result<Session, AppError> {
399 let current_session = {
400 let session = self.session.read().await;
401 session.clone()
402 };
403
404 if let Some(sess) = current_session {
405 if sess.is_expired(Some(1)) {
406 debug!("Session expired, performing login");
407 self.login().await
408 } else {
409 Ok(sess)
410 }
411 } else {
412 warn!("No session to refresh, performing login");
413 self.login().await
414 }
415 }
416
417 pub async fn switch_account(
427 &self,
428 account_id: &str,
429 default_account: Option<bool>,
430 ) -> Result<Session, AppError> {
431 let current_session = self.get_session().await?;
432 if matches!(current_session.api_version, 3) {
433 return Err(AppError::InvalidInput(
434 "Cannot switch accounts with OAuth".to_string(),
435 ));
436 }
437
438 if current_session.account_id == account_id {
439 debug!("Already on account {}", account_id);
440 return Ok(current_session);
441 }
442
443 info!("Switching to account: {}", account_id);
444
445 let url = format!("{}/session", self.config.rest_api.base_url);
446
447 let mut body = serde_json::json!({
448 "accountId": account_id,
449 });
450
451 if let Some(default) = default_account {
452 body["defaultAccount"] = serde_json::json!(default);
453 }
454
455 let api_key = self.config.credentials.api_key.clone();
457 let auth_header_value;
458 let cst;
459 let x_security_token;
460
461 let mut headers = vec![
462 ("X-IG-API-KEY", api_key.as_str()),
463 ("Content-Type", "application/json"),
464 ("Version", "1"),
465 ];
466
467 if let Some(oauth) = ¤t_session.oauth_token {
469 auth_header_value = format!("Bearer {}", oauth.access_token);
470 headers.push(("Authorization", auth_header_value.as_str()));
471 } else {
472 if let Some(cst_val) = ¤t_session.cst {
473 cst = cst_val.clone();
474 headers.push(("CST", cst.as_str()));
475 }
476 if let Some(token_val) = ¤t_session.x_security_token {
477 x_security_token = token_val.clone();
478 headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
479 }
480 }
481
482 let _response = make_http_request(
483 &self.client,
484 self.rate_limiter.clone(),
485 Method::PUT,
486 &url,
487 headers,
488 &Some(body),
489 RetryConfig::infinite(),
490 )
491 .await?;
492
493 let mut new_session = current_session.clone();
495 new_session.account_id = account_id.to_string();
496
497 let mut session = self.session.write().await;
498 *session = Some(new_session.clone());
499
500 info!("✓ Switched to account: {}", account_id);
501 Ok(new_session)
502 }
503
504 pub async fn logout(&self) -> Result<(), AppError> {
506 info!("Logging out");
507
508 let mut session = self.session.write().await;
509 *session = None;
510
511 info!("✓ Logged out successfully");
512 Ok(())
513 }
514}
515
516#[test]
517fn test_v2_response_deserialization() -> Result<(), serde_json::Error> {
518 let json = r#"{"accountType":"CFD","accountInfo":{"balance":21065.86,"deposit":3033.31,"profitLoss":-285.27,"available":16659.01},"currencyIsoCode":"EUR","currencySymbol":"E","currentAccountId":"ZZZZZ","lightstreamerEndpoint":"https://demo-apd.marketdatasystems.com","accounts":[{"accountId":"Z405P5","accountName":"Turbo24","preferred":false,"accountType":"PHYSICAL"},{"accountId":"ZHJ5N","accountName":"DEMO_A","preferred":false,"accountType":"CFD"},{"accountId":"ZZZZZ","accountName":"Opciones","preferred":true,"accountType":"CFD"}],"clientId":"101290216","timezoneOffset":1,"hasActiveDemoAccounts":true,"hasActiveLiveAccounts":true,"trailingStopsEnabled":false,"reroutingEnvironment":null,"dealingEnabled":true}"#;
519
520 let result: crate::model::auth::SessionResponse = serde_json::from_str(json)?;
521 println!("Success: is_v2={}", result.is_v2());
522
523 let response: crate::model::auth::V2Response = serde_json::from_str(json)?;
524 assert_eq!(response.account_type, "CFD");
525 Ok(())
526}
527
528#[test]
529fn test_v2_response_deserialization_prod() -> Result<(), serde_json::Error> {
530 let json = r#"{"accountType":"CFD","accountInfo":{"balance":18791.56,"deposit":3300.18,"profitLoss":187.42,"available":14952.68},"currencyIsoCode":"EUR","currencySymbol":"E","currentAccountId":"BS0Y3","lightstreamerEndpoint":"https://apd.marketdatasystems.com","accounts":[{"accountId":"BS0Y3","accountName":"Opciones Prod","preferred":true,"accountType":"CFD"},{"accountId":"BSI1I","accountName":"Barreras y Opciones","preferred":false,"accountType":"CFD"},{"accountId":"BSU96","accountName":"Turbos","preferred":false,"accountType":"PHYSICAL"},{"accountId":"BTCKN","accountName":"CFD","preferred":false,"accountType":"CFD"},{"accountId":"BXNIZ","accountName":"Principal","preferred":false,"accountType":"CFD"}],"clientId":"102828353","timezoneOffset":1,"hasActiveDemoAccounts":true,"hasActiveLiveAccounts":true,"trailingStopsEnabled":false,"reroutingEnvironment":null,"dealingEnabled":true}"#;
531
532 let result: crate::model::auth::SessionResponse = serde_json::from_str(json)?;
533 println!("Success: is_v2={}", result.is_v2());
534
535 let response: crate::model::auth::V2Response = serde_json::from_str(json)?;
536 assert_eq!(response.account_type, "CFD");
537 assert_eq!(response.current_account_id, "BS0Y3");
538 Ok(())
539}