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 let headers = vec![
336 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
337 ("Content-Type", "application/json"),
338 ("Version", "3"),
339 ];
340
341 let response = make_http_request(
342 &self.client,
343 self.rate_limiter.clone(),
344 Method::POST,
345 &url,
346 headers,
347 &Some(body),
348 RetryConfig::infinite(),
349 )
350 .await?;
351
352 let response: SessionResponse = response.json().await?;
353 let mut session = response.get_session();
354 if session.account_id != self.config.credentials.account_id {
355 session.account_id = self.config.credentials.account_id.clone();
356 };
357
358 assert!(session.is_oauth());
359
360 Ok(session)
361 }
362
363 pub async fn refresh_token(&self) -> Result<Session, AppError> {
371 let current_session = {
372 let session = self.session.read().await;
373 session.clone()
374 };
375
376 if let Some(sess) = current_session {
377 if sess.is_expired(Some(1)) {
378 debug!("Session expired, performing login");
379 self.login().await
380 } else {
381 Ok(sess)
382 }
383 } else {
384 warn!("No session to refresh, performing login");
385 self.login().await
386 }
387 }
388
389 pub async fn switch_account(
399 &self,
400 account_id: &str,
401 default_account: Option<bool>,
402 ) -> Result<Session, AppError> {
403 let current_session = self.get_session().await?;
404 if matches!(current_session.api_version, 3) {
405 return Err(AppError::InvalidInput(
406 "Cannot switch accounts with OAuth".to_string(),
407 ));
408 }
409
410 if current_session.account_id == account_id {
411 debug!("Already on account {}", account_id);
412 return Ok(current_session);
413 }
414
415 info!("Switching to account: {}", account_id);
416
417 let url = format!("{}/session", self.config.rest_api.base_url);
418
419 let mut body = serde_json::json!({
420 "accountId": account_id,
421 });
422
423 if let Some(default) = default_account {
424 body["defaultAccount"] = serde_json::json!(default);
425 }
426
427 let api_key = self.config.credentials.api_key.clone();
429 let auth_header_value;
430 let cst;
431 let x_security_token;
432
433 let mut headers = vec![
434 ("X-IG-API-KEY", api_key.as_str()),
435 ("Content-Type", "application/json"),
436 ("Version", "1"),
437 ];
438
439 if let Some(oauth) = ¤t_session.oauth_token {
441 auth_header_value = format!("Bearer {}", oauth.access_token);
442 headers.push(("Authorization", auth_header_value.as_str()));
443 } else {
444 if let Some(cst_val) = ¤t_session.cst {
445 cst = cst_val.clone();
446 headers.push(("CST", cst.as_str()));
447 }
448 if let Some(token_val) = ¤t_session.x_security_token {
449 x_security_token = token_val.clone();
450 headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
451 }
452 }
453
454 let _response = make_http_request(
455 &self.client,
456 self.rate_limiter.clone(),
457 Method::PUT,
458 &url,
459 headers,
460 &Some(body),
461 RetryConfig::infinite(),
462 )
463 .await?;
464
465 let mut new_session = current_session.clone();
467 new_session.account_id = account_id.to_string();
468
469 let mut session = self.session.write().await;
470 *session = Some(new_session.clone());
471
472 info!("✓ Switched to account: {}", account_id);
473 Ok(new_session)
474 }
475
476 pub async fn logout(&self) -> Result<(), AppError> {
478 info!("Logging out");
479
480 let mut session = self.session.write().await;
481 *session = None;
482
483 info!("✓ Logged out successfully");
484 Ok(())
485 }
486}