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 WebsocketInfo {
135 server: self.lightstreamer_endpoint.clone() + "/lightstreamer",
136 cst: self.cst.clone(),
137 x_security_token: self.x_security_token.clone(),
138 account_id: self.account_id.clone(),
139 }
140 }
141}
142
143impl From<SessionResponse> for Session {
144 fn from(v: SessionResponse) -> Self {
145 v.get_session()
146 }
147}
148
149pub struct Auth {
158 config: Arc<Config>,
159 client: Client,
160 session: Arc<RwLock<Option<Session>>>,
161 rate_limiter: Arc<RwLock<RateLimiter>>,
162}
163
164impl Auth {
165 pub fn new(config: Arc<Config>) -> Self {
170 let client = Client::builder()
171 .user_agent(USER_AGENT)
172 .build()
173 .expect("Failed to create HTTP client");
174
175 let rate_limiter = Arc::new(RwLock::new(RateLimiter::new(&config.rate_limiter)));
176
177 Self {
178 config,
179 client,
180 session: Arc::new(RwLock::new(None)),
181 rate_limiter,
182 }
183 }
184
185 pub async fn get_ws_info(&self) -> WebsocketInfo {
190 let sess = self.login_v2().await.ok();
191 match sess {
192 Some(sess) => sess.get_websocket_info(),
193 None => WebsocketInfo::default(),
194 }
195 }
196
197 pub async fn get_session(&self) -> Result<Session, AppError> {
205 let session = self.session.read().await;
206
207 if let Some(sess) = session.as_ref() {
208 if sess.needs_token_refresh(Some(300)) {
210 drop(session); debug!("OAuth token needs refresh");
212 return self.refresh_token().await;
213 }
214 return Ok(sess.clone());
215 }
216
217 drop(session);
218
219 info!("No active session, logging in");
221 self.login().await
222 }
223
224 pub async fn login(&self) -> Result<Session, AppError> {
232 let api_version = self.config.api_version.unwrap_or(2);
233
234 debug!("Logging in with API v{}", api_version);
235
236 let session = if api_version == 3 {
237 self.login_oauth().await?
238 } else {
239 self.login_v2().await?
240 };
241
242 let mut sess = self.session.write().await;
244 *sess = Some(session.clone());
245
246 info!("✓ Login successful, account: {}", session.account_id);
247 Ok(session)
248 }
249
250 async fn login_v2(&self) -> Result<Session, AppError> {
252 let url = format!("{}/session", self.config.rest_api.base_url);
253
254 let body = serde_json::json!({
255 "identifier": self.config.credentials.username,
256 "password": self.config.credentials.password,
257 });
258
259 debug!("Sending v2 login request to: {}", url);
260
261 let headers = vec![
262 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
263 ("Content-Type", "application/json"),
264 ("Version", "2"),
265 ];
266
267 let response = make_http_request(
268 &self.client,
269 self.rate_limiter.clone(),
270 Method::POST,
271 &url,
272 headers,
273 &Some(body),
274 RetryConfig::infinite(),
275 )
276 .await?;
277
278 let cst: String = match response
280 .headers()
281 .get("CST")
282 .and_then(|v| v.to_str().ok())
283 .map(String::from)
284 {
285 Some(token) => token,
286 None => {
287 error!("CST header not found in response");
288 return Err(AppError::InvalidInput("CST missing".to_string()));
289 }
290 };
291 let x_security_token: String = match response
292 .headers()
293 .get("X-SECURITY-TOKEN")
294 .and_then(|v| v.to_str().ok())
295 .map(String::from)
296 {
297 Some(token) => token,
298 None => {
299 error!("X-SECURITY-TOKEN header not found in response");
300 return Err(AppError::InvalidInput(
301 "X-SECURITY-TOKEN missing".to_string(),
302 ));
303 }
304 };
305
306 let x_ig_api_key: String = response
307 .headers()
308 .get("X-IG-API-KEY")
309 .and_then(|v| v.to_str().ok())
310 .map(String::from)
311 .unwrap_or_else(|| self.config.credentials.api_key.clone());
312
313 let security_headers: SecurityHeaders = SecurityHeaders {
314 cst,
315 x_security_token,
316 x_ig_api_key,
317 };
318
319 let mut response: SessionResponse = response.json().await?;
320 let session = response.get_session_v2(&security_headers);
321
322 Ok(session)
323 }
324
325 async fn login_oauth(&self) -> Result<Session, AppError> {
327 let url = format!("{}/session", self.config.rest_api.base_url);
328
329 let body = serde_json::json!({
330 "identifier": self.config.credentials.username,
331 "password": self.config.credentials.password,
332 });
333
334 debug!("Sending OAuth login request to: {}", url);
335
336 let headers = vec![
337 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
338 ("Content-Type", "application/json"),
339 ("Version", "3"),
340 ];
341
342 let response = make_http_request(
343 &self.client,
344 self.rate_limiter.clone(),
345 Method::POST,
346 &url,
347 headers,
348 &Some(body),
349 RetryConfig::infinite(),
350 )
351 .await?;
352
353 let response: SessionResponse = response.json().await?;
354 let session = response.get_session();
355 assert!(session.is_oauth());
356
357 Ok(session)
358 }
359
360 pub async fn refresh_token(&self) -> Result<Session, AppError> {
368 let current_session = {
369 let session = self.session.read().await;
370 session.clone()
371 };
372
373 if let Some(sess) = current_session {
374 if sess.is_expired(Some(1)) {
375 debug!("Session expired, performing login");
376 self.login().await
377 } else {
378 Ok(sess)
379 }
380 } else {
381 warn!("No session to refresh, performing login");
382 self.login().await
383 }
384 }
385
386 pub async fn switch_account(
396 &self,
397 account_id: &str,
398 default_account: Option<bool>,
399 ) -> Result<Session, AppError> {
400 let current_session = self.get_session().await?;
401 if matches!(current_session.api_version, 3) {
402 return Err(AppError::InvalidInput(
403 "Cannot switch accounts with OAuth".to_string(),
404 ));
405 }
406
407 if current_session.account_id == account_id {
408 debug!("Already on account {}", account_id);
409 return Ok(current_session);
410 }
411
412 info!("Switching to account: {}", account_id);
413
414 let url = format!("{}/session", self.config.rest_api.base_url);
415
416 let mut body = serde_json::json!({
417 "accountId": account_id,
418 });
419
420 if let Some(default) = default_account {
421 body["defaultAccount"] = serde_json::json!(default);
422 }
423
424 let api_key = self.config.credentials.api_key.clone();
426 let auth_header_value;
427 let cst;
428 let x_security_token;
429
430 let mut headers = vec![
431 ("X-IG-API-KEY", api_key.as_str()),
432 ("Content-Type", "application/json"),
433 ("Version", "1"),
434 ];
435
436 if let Some(oauth) = ¤t_session.oauth_token {
438 auth_header_value = format!("Bearer {}", oauth.access_token);
439 headers.push(("Authorization", auth_header_value.as_str()));
440 } else {
441 if let Some(cst_val) = ¤t_session.cst {
442 cst = cst_val.clone();
443 headers.push(("CST", cst.as_str()));
444 }
445 if let Some(token_val) = ¤t_session.x_security_token {
446 x_security_token = token_val.clone();
447 headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
448 }
449 }
450
451 let _response = make_http_request(
452 &self.client,
453 self.rate_limiter.clone(),
454 Method::PUT,
455 &url,
456 headers,
457 &Some(body),
458 RetryConfig::infinite(),
459 )
460 .await?;
461
462 let mut new_session = current_session.clone();
464 new_session.account_id = account_id.to_string();
465
466 let mut session = self.session.write().await;
467 *session = Some(new_session.clone());
468
469 info!("✓ Switched to account: {}", account_id);
470 Ok(new_session)
471 }
472
473 pub async fn logout(&self) -> Result<(), AppError> {
475 info!("Logging out");
476
477 let mut session = self.session.write().await;
478 *session = None;
479
480 info!("✓ Logged out successfully");
481 Ok(())
482 }
483}