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 let sess = self.login_v2().await.ok();
200 match sess {
201 Some(sess) => sess.get_websocket_info(),
202 None => WebsocketInfo::default(),
203 }
204 }
205
206 pub async fn get_session(&self) -> Result<Session, AppError> {
214 let session = self.session.read().await;
215
216 if let Some(sess) = session.as_ref() {
217 if sess.needs_token_refresh(Some(300)) {
219 drop(session); debug!("OAuth token needs refresh");
221 return self.refresh_token().await;
222 }
223 return Ok(sess.clone());
224 }
225
226 drop(session);
227
228 info!("No active session, logging in");
230 self.login().await
231 }
232
233 pub async fn login(&self) -> Result<Session, AppError> {
241 let api_version = self.config.api_version.unwrap_or(2);
242
243 debug!("Logging in with API v{}", api_version);
244
245 let session = if api_version == 3 {
246 self.login_oauth().await?
247 } else {
248 self.login_v2().await?
249 };
250
251 let mut sess = self.session.write().await;
253 *sess = Some(session.clone());
254
255 info!("✓ Login successful, account: {}", session.account_id);
256 Ok(session)
257 }
258
259 async fn login_v2(&self) -> Result<Session, AppError> {
261 let url = format!("{}/session", self.config.rest_api.base_url);
262
263 let body = serde_json::json!({
264 "identifier": self.config.credentials.username,
265 "password": self.config.credentials.password,
266 });
267
268 debug!("Sending v2 login request to: {}", url);
269
270 let headers = vec![
271 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
272 ("Content-Type", "application/json"),
273 ("Version", "2"),
274 ];
275
276 let response = make_http_request(
277 &self.client,
278 self.rate_limiter.clone(),
279 Method::POST,
280 &url,
281 headers,
282 &Some(body),
283 RetryConfig::infinite(),
284 )
285 .await?;
286
287 let cst: String = match response
289 .headers()
290 .get("CST")
291 .and_then(|v| v.to_str().ok())
292 .map(String::from)
293 {
294 Some(token) => token,
295 None => {
296 error!("CST header not found in response");
297 return Err(AppError::InvalidInput("CST missing".to_string()));
298 }
299 };
300 let x_security_token: String = match response
301 .headers()
302 .get("X-SECURITY-TOKEN")
303 .and_then(|v| v.to_str().ok())
304 .map(String::from)
305 {
306 Some(token) => token,
307 None => {
308 error!("X-SECURITY-TOKEN header not found in response");
309 return Err(AppError::InvalidInput(
310 "X-SECURITY-TOKEN missing".to_string(),
311 ));
312 }
313 };
314
315 let x_ig_api_key: String = response
316 .headers()
317 .get("X-IG-API-KEY")
318 .and_then(|v| v.to_str().ok())
319 .map(String::from)
320 .unwrap_or_else(|| self.config.credentials.api_key.clone());
321
322 let security_headers: SecurityHeaders = SecurityHeaders {
323 cst,
324 x_security_token,
325 x_ig_api_key,
326 };
327
328 let mut response: SessionResponse = response.json().await?;
329 let session = response.get_session_v2(&security_headers);
330
331 Ok(session)
332 }
333
334 async fn login_oauth(&self) -> Result<Session, AppError> {
336 let url = format!("{}/session", self.config.rest_api.base_url);
337
338 let body = serde_json::json!({
339 "identifier": self.config.credentials.username,
340 "password": self.config.credentials.password,
341 });
342
343 debug!("Sending OAuth login request to: {}", url);
344 let headers = vec![
345 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
346 ("Content-Type", "application/json"),
347 ("Version", "3"),
348 ];
349
350 let response = make_http_request(
351 &self.client,
352 self.rate_limiter.clone(),
353 Method::POST,
354 &url,
355 headers,
356 &Some(body),
357 RetryConfig::infinite(),
358 )
359 .await?;
360
361 let response: SessionResponse = response.json().await?;
362 let mut session = response.get_session();
363 if session.account_id != self.config.credentials.account_id {
364 session.account_id = self.config.credentials.account_id.clone();
365 };
366
367 assert!(session.is_oauth());
368
369 Ok(session)
370 }
371
372 pub async fn refresh_token(&self) -> Result<Session, AppError> {
380 let current_session = {
381 let session = self.session.read().await;
382 session.clone()
383 };
384
385 if let Some(sess) = current_session {
386 if sess.is_expired(Some(1)) {
387 debug!("Session expired, performing login");
388 self.login().await
389 } else {
390 Ok(sess)
391 }
392 } else {
393 warn!("No session to refresh, performing login");
394 self.login().await
395 }
396 }
397
398 pub async fn switch_account(
408 &self,
409 account_id: &str,
410 default_account: Option<bool>,
411 ) -> Result<Session, AppError> {
412 let current_session = self.get_session().await?;
413 if matches!(current_session.api_version, 3) {
414 return Err(AppError::InvalidInput(
415 "Cannot switch accounts with OAuth".to_string(),
416 ));
417 }
418
419 if current_session.account_id == account_id {
420 debug!("Already on account {}", account_id);
421 return Ok(current_session);
422 }
423
424 info!("Switching to account: {}", account_id);
425
426 let url = format!("{}/session", self.config.rest_api.base_url);
427
428 let mut body = serde_json::json!({
429 "accountId": account_id,
430 });
431
432 if let Some(default) = default_account {
433 body["defaultAccount"] = serde_json::json!(default);
434 }
435
436 let api_key = self.config.credentials.api_key.clone();
438 let auth_header_value;
439 let cst;
440 let x_security_token;
441
442 let mut headers = vec![
443 ("X-IG-API-KEY", api_key.as_str()),
444 ("Content-Type", "application/json"),
445 ("Version", "1"),
446 ];
447
448 if let Some(oauth) = ¤t_session.oauth_token {
450 auth_header_value = format!("Bearer {}", oauth.access_token);
451 headers.push(("Authorization", auth_header_value.as_str()));
452 } else {
453 if let Some(cst_val) = ¤t_session.cst {
454 cst = cst_val.clone();
455 headers.push(("CST", cst.as_str()));
456 }
457 if let Some(token_val) = ¤t_session.x_security_token {
458 x_security_token = token_val.clone();
459 headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
460 }
461 }
462
463 let _response = make_http_request(
464 &self.client,
465 self.rate_limiter.clone(),
466 Method::PUT,
467 &url,
468 headers,
469 &Some(body),
470 RetryConfig::infinite(),
471 )
472 .await?;
473
474 let mut new_session = current_session.clone();
476 new_session.account_id = account_id.to_string();
477
478 let mut session = self.session.write().await;
479 *session = Some(new_session.clone());
480
481 info!("✓ Switched to account: {}", account_id);
482 Ok(new_session)
483 }
484
485 pub async fn logout(&self) -> Result<(), AppError> {
487 info!("Logging out");
488
489 let mut session = self.session.write().await;
490 *session = None;
491
492 info!("✓ Logged out successfully");
493 Ok(())
494 }
495}