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