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 pub fn get_ws_password(&self) -> String {
55 match (&self.cst, &self.x_security_token) {
56 (Some(cst), Some(x_security_token)) => {
57 format!("CST-{}|XST-{}", cst, x_security_token)
58 }
59 _ => String::new(),
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct Session {
67 pub account_id: String,
69 pub client_id: String,
71 pub lightstreamer_endpoint: String,
73 pub cst: Option<String>,
75 pub x_security_token: Option<String>,
77 pub oauth_token: Option<OAuthToken>,
79 pub api_version: u8,
81 pub expires_at: u64,
85}
86
87impl Session {
88 #[must_use]
90 pub fn is_oauth(&self) -> bool {
91 self.oauth_token.is_some()
92 }
93
94 #[must_use]
103 pub fn is_expired(&self, margin_seconds: Option<u64>) -> bool {
104 let margin = margin_seconds.unwrap_or(60);
105 let now = Utc::now().timestamp() as u64;
106 now >= (self.expires_at - margin)
107 }
108
109 #[must_use]
115 pub fn seconds_until_expiry(&self) -> u64 {
116 self.expires_at - Utc::now().timestamp() as u64
117 }
118
119 #[must_use]
124 pub fn needs_token_refresh(&self, margin_seconds: Option<u64>) -> bool {
125 self.is_expired(margin_seconds)
126 }
127
128 #[must_use]
133 pub fn get_websocket_info(&self) -> WebsocketInfo {
134 let server = if self.lightstreamer_endpoint.starts_with("http://")
136 || self.lightstreamer_endpoint.starts_with("https://")
137 {
138 format!("{}/lightstreamer", self.lightstreamer_endpoint)
139 } else {
140 format!("https://{}/lightstreamer", self.lightstreamer_endpoint)
141 };
142
143 WebsocketInfo {
144 server,
145 cst: self.cst.clone(),
146 x_security_token: self.x_security_token.clone(),
147 account_id: self.account_id.clone(),
148 }
149 }
150}
151
152impl From<SessionResponse> for Session {
153 fn from(v: SessionResponse) -> Self {
154 v.get_session()
155 }
156}
157
158pub struct Auth {
167 config: Arc<Config>,
168 client: Client,
169 session: Arc<RwLock<Option<Session>>>,
170 rate_limiter: Arc<RwLock<RateLimiter>>,
171}
172
173impl Auth {
174 pub fn new(config: Arc<Config>) -> Self {
179 let client = Client::builder()
180 .user_agent(USER_AGENT)
181 .build()
182 .expect("Failed to create HTTP client");
183
184 let rate_limiter = Arc::new(RwLock::new(RateLimiter::new(&config.rate_limiter)));
185
186 Self {
187 config,
188 client,
189 session: Arc::new(RwLock::new(None)),
190 rate_limiter,
191 }
192 }
193
194 pub async fn get_ws_info(&self) -> WebsocketInfo {
199 match self.login_v2().await {
200 Ok(sess) => sess.get_websocket_info(),
201 Err(e) => {
202 error!("Failed to get WebSocket info, login failed: {}", e);
203 WebsocketInfo::default()
204 }
205 }
206 }
207
208 pub async fn get_session(&self) -> Result<Session, AppError> {
216 let session = self.session.read().await;
217
218 if let Some(sess) = session.as_ref() {
219 if sess.needs_token_refresh(Some(300)) {
221 drop(session); debug!("OAuth token needs refresh");
223 return self.refresh_token().await;
224 }
225 return Ok(sess.clone());
226 }
227
228 drop(session);
229
230 info!("No active session, logging in");
232 self.login().await
233 }
234
235 pub async fn login(&self) -> Result<Session, AppError> {
243 let api_version = self.config.api_version.unwrap_or(2);
244
245 debug!("Logging in with API v{}", api_version);
246
247 let session = if api_version == 3 {
248 self.login_oauth().await?
249 } else {
250 self.login_v2().await?
251 };
252
253 let mut sess = self.session.write().await;
255 *sess = Some(session.clone());
256
257 info!("✓ Login successful, account: {}", session.account_id);
258 Ok(session)
259 }
260
261 async fn login_v2(&self) -> Result<Session, AppError> {
263 let url = format!("{}/session", self.config.rest_api.base_url);
264
265 let body = serde_json::json!({
266 "identifier": self.config.credentials.username,
267 "password": self.config.credentials.password,
268 });
269
270 debug!("Sending v2 login request to: {}", url);
271
272 let headers = vec![
273 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
274 ("Content-Type", "application/json"),
275 ("Version", "2"),
276 ];
277
278 let response = make_http_request(
279 &self.client,
280 self.rate_limiter.clone(),
281 Method::POST,
282 &url,
283 headers,
284 &Some(body),
285 RetryConfig::infinite(),
286 )
287 .await?;
288
289 let cst: String = match response
291 .headers()
292 .get("CST")
293 .and_then(|v| v.to_str().ok())
294 .map(String::from)
295 {
296 Some(token) => token,
297 None => {
298 error!("CST header not found in response");
299 return Err(AppError::InvalidInput("CST missing".to_string()));
300 }
301 };
302 let x_security_token: String = match response
303 .headers()
304 .get("X-SECURITY-TOKEN")
305 .and_then(|v| v.to_str().ok())
306 .map(String::from)
307 {
308 Some(token) => token,
309 None => {
310 error!("X-SECURITY-TOKEN header not found in response");
311 return Err(AppError::InvalidInput(
312 "X-SECURITY-TOKEN missing".to_string(),
313 ));
314 }
315 };
316
317 let x_ig_api_key: String = response
318 .headers()
319 .get("X-IG-API-KEY")
320 .and_then(|v| v.to_str().ok())
321 .map(String::from)
322 .unwrap_or_else(|| self.config.credentials.api_key.clone());
323
324 let security_headers: SecurityHeaders = SecurityHeaders {
325 cst,
326 x_security_token,
327 x_ig_api_key,
328 };
329
330 let body_text = response.text().await.map_err(|e| {
332 error!("Failed to read response body: {}", e);
333 AppError::Network(e)
334 })?;
335 debug!("Login response body length: {} bytes", body_text.len());
336
337 let mut response: SessionResponse = serde_json::from_str(&body_text).map_err(|e| {
339 error!("Failed to parse login response JSON: {}", e);
340 error!("Response body: {}", body_text);
341 AppError::Deserialization(format!("Failed to parse login response: {}", e))
342 })?;
343 let session = response.get_session_v2(&security_headers);
344
345 Ok(session)
346 }
347
348 async fn login_oauth(&self) -> Result<Session, AppError> {
350 let url = format!("{}/session", self.config.rest_api.base_url);
351
352 let body = serde_json::json!({
353 "identifier": self.config.credentials.username,
354 "password": self.config.credentials.password,
355 });
356
357 debug!("Sending OAuth login request to: {}", url);
358 let headers = vec![
359 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
360 ("Content-Type", "application/json"),
361 ("Version", "3"),
362 ];
363
364 let response = make_http_request(
365 &self.client,
366 self.rate_limiter.clone(),
367 Method::POST,
368 &url,
369 headers,
370 &Some(body),
371 RetryConfig::infinite(),
372 )
373 .await?;
374
375 let response: SessionResponse = response.json().await?;
376 let mut session = response.get_session();
377 if session.account_id != self.config.credentials.account_id {
378 session.account_id = self.config.credentials.account_id.clone();
379 };
380
381 assert!(session.is_oauth());
382
383 Ok(session)
384 }
385
386 pub async fn refresh_token(&self) -> Result<Session, AppError> {
394 let current_session = {
395 let session = self.session.read().await;
396 session.clone()
397 };
398
399 if let Some(sess) = current_session {
400 if sess.is_expired(Some(1)) {
401 debug!("Session expired, performing login");
402 self.login().await
403 } else {
404 Ok(sess)
405 }
406 } else {
407 warn!("No session to refresh, performing login");
408 self.login().await
409 }
410 }
411
412 pub async fn switch_account(
422 &self,
423 account_id: &str,
424 default_account: Option<bool>,
425 ) -> Result<Session, AppError> {
426 let current_session = self.get_session().await?;
427 if matches!(current_session.api_version, 3) {
428 return Err(AppError::InvalidInput(
429 "Cannot switch accounts with OAuth".to_string(),
430 ));
431 }
432
433 if current_session.account_id == account_id {
434 debug!("Already on account {}", account_id);
435 return Ok(current_session);
436 }
437
438 info!("Switching to account: {}", account_id);
439
440 let url = format!("{}/session", self.config.rest_api.base_url);
441
442 let mut body = serde_json::json!({
443 "accountId": account_id,
444 });
445
446 if let Some(default) = default_account {
447 body["defaultAccount"] = serde_json::json!(default);
448 }
449
450 let api_key = self.config.credentials.api_key.clone();
452 let auth_header_value;
453 let cst;
454 let x_security_token;
455
456 let mut headers = vec![
457 ("X-IG-API-KEY", api_key.as_str()),
458 ("Content-Type", "application/json"),
459 ("Version", "1"),
460 ];
461
462 if let Some(oauth) = ¤t_session.oauth_token {
464 auth_header_value = format!("Bearer {}", oauth.access_token);
465 headers.push(("Authorization", auth_header_value.as_str()));
466 } else {
467 if let Some(cst_val) = ¤t_session.cst {
468 cst = cst_val.clone();
469 headers.push(("CST", cst.as_str()));
470 }
471 if let Some(token_val) = ¤t_session.x_security_token {
472 x_security_token = token_val.clone();
473 headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
474 }
475 }
476
477 let _response = make_http_request(
478 &self.client,
479 self.rate_limiter.clone(),
480 Method::PUT,
481 &url,
482 headers,
483 &Some(body),
484 RetryConfig::infinite(),
485 )
486 .await?;
487
488 let mut new_session = current_session.clone();
490 new_session.account_id = account_id.to_string();
491
492 let mut session = self.session.write().await;
493 *session = Some(new_session.clone());
494
495 info!("✓ Switched to account: {}", account_id);
496 Ok(new_session)
497 }
498
499 pub async fn logout(&self) -> Result<(), AppError> {
501 info!("Logging out");
502
503 let mut session = self.session.write().await;
504 *session = None;
505
506 info!("✓ Logged out successfully");
507 Ok(())
508 }
509}
510
511#[test]
512fn test_v2_response_deserialization() {
513 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}"#;
514
515 let result: Result<crate::model::auth::SessionResponse, _> = serde_json::from_str(json);
516 match &result {
517 Ok(r) => println!("Success: is_v2={}", r.is_v2()),
518 Err(e) => println!("Error: {}", e),
519 }
520 assert!(result.is_ok(), "Failed to deserialize V2 response");
521
522 let response: crate::model::auth::V2Response = serde_json::from_str(json).unwrap();
523 assert_eq!(response.account_type, "CFD");
524}