ig_client/application/auth.rs
1/******************************************************************************
2 Author: Joaquín Béjar García
3 Email: jb@taunais.com
4 Date: 19/10/25
5******************************************************************************/
6
7//! Authentication module for IG Markets API
8//!
9//! This module provides a simplified authentication interface that handles:
10//! - API v2 (CST/X-SECURITY-TOKEN) authentication
11//! - API v3 (OAuth) authentication with automatic token refresh
12//! - Account switching
13//! - Automatic re-authentication when tokens expire
14
15use crate::application::config::Config;
16use crate::application::http::make_http_request;
17use crate::application::rate_limiter::RateLimiter;
18use crate::constants::USER_AGENT;
19use crate::error::{AppError, AuthError};
20pub(crate) use crate::model::auth::{SecurityHeaders, SessionResponse};
21use crate::model::retry::RetryConfig;
22use reqwest::{Client, Method};
23use std::sync::Arc;
24use tokio::sync::RwLock;
25use tracing::{debug, error, info, warn};
26// `OAuthToken` and `chrono::Utc` are only referenced from the unit tests below
27// (the `Session` data type that used them in production moved to
28// `crate::model::auth`), so they are imported under `cfg(test)` to keep the
29// non-test build free of unused-import warnings.
30#[cfg(test)]
31use crate::model::auth::OAuthToken;
32#[cfg(test)]
33use chrono::Utc;
34
35// `Session` and `WebsocketInfo` are pure data types and live in the model
36// layer (`crate::model::auth`). They are re-exported here so the historical
37// public paths `crate::application::auth::Session` /
38// `crate::application::auth::WebsocketInfo` keep resolving, and so the auth
39// I/O manager below can reference them unqualified.
40pub use crate::model::auth::{Session, WebsocketInfo};
41
42/// Merges freshly-issued v2 security tokens into a session after an account
43/// switch.
44///
45/// IG returns a new `X-SECURITY-TOKEN` (and sometimes a new `CST`) in the
46/// response to `PUT /session`. When a token is present it replaces the stale
47/// one; when absent the existing token is preserved — the switch response does
48/// not always re-issue both, and nulling a token would break the next request.
49/// All other session fields are carried through unchanged.
50#[must_use]
51fn apply_switch_headers(mut session: Session, cst: Option<&str>, xst: Option<&str>) -> Session {
52 if let Some(cst) = cst {
53 session.cst = Some(cst.to_string());
54 }
55 if let Some(xst) = xst {
56 session.x_security_token = Some(xst.to_string());
57 }
58 session
59}
60
61/// Decides whether a v2 login should switch to the configured account.
62///
63/// Returns `true` only when a *real* account was configured (not empty and not
64/// the [`DEFAULT_ACCOUNT_ID`](crate::constants::DEFAULT_ACCOUNT_ID) sentinel) and
65/// it differs from the account the login landed on. OAuth (v3) pins the account
66/// elsewhere and is never switched here. Guarding the sentinel is essential: it
67/// is non-empty, so without this check an unconfigured client would try to
68/// switch to `"default_account_id"`, which IG rejects, failing an otherwise-valid
69/// login.
70#[must_use]
71fn should_switch_account(api_version: u8, configured: &str, current: &str) -> bool {
72 api_version != 3
73 && !configured.is_empty()
74 && configured != crate::constants::DEFAULT_ACCOUNT_ID
75 && configured != current
76}
77
78/// Selects the proactive-refresh safety margin (in seconds) for a session based
79/// on its authentication model.
80///
81/// v3 (OAuth) access tokens are short-lived (~60s), so the v2 margin would keep
82/// them permanently "about to expire" and force a login on every call; a small
83/// [`PROACTIVE_REFRESH_MARGIN_V3_SECS`](crate::constants::PROACTIVE_REFRESH_MARGIN_V3_SECS)
84/// margin is used instead. v2 (CST / X-SECURITY-TOKEN) sessions last ~6h, so the
85/// larger
86/// [`PROACTIVE_REFRESH_MARGIN_V2_SECS`](crate::constants::PROACTIVE_REFRESH_MARGIN_V2_SECS)
87/// margin gives ample lead time.
88///
89/// The *same* margin is used by both [`Auth::get_session`] (to decide a refresh
90/// is due) and [`Auth::refresh_token`] (to actually perform it), so the
91/// proactive-refresh window is consistent and always fires — the previous 300s /
92/// 1s mismatch meant the advertised margin never triggered a refresh.
93#[must_use]
94fn proactive_refresh_margin_secs(session: &Session) -> u64 {
95 if session.is_oauth() {
96 crate::constants::PROACTIVE_REFRESH_MARGIN_V3_SECS
97 } else {
98 crate::constants::PROACTIVE_REFRESH_MARGIN_V2_SECS
99 }
100}
101
102/// Ensures a v3 (OAuth) login actually produced an OAuth session.
103///
104/// The v3 `/session` endpoint is expected to return an OAuth body. Because
105/// [`SessionResponse`] is untagged, a v2-shaped body sent to the v3 request
106/// still deserializes successfully — as a v2 session carrying no OAuth token.
107/// That is a server-side / protocol mismatch, not a client bug. On the reactive
108/// [`force_refresh`](Auth::force_refresh) -> [`login`](Auth::login) path a 401
109/// reaches this code more often, so a mismatched body must surface as a typed
110/// error rather than panic.
111///
112/// The offending response body / token is never logged.
113///
114/// # Errors
115/// Returns [`AppError::Unauthorized`] when the session lacks an OAuth token.
116fn ensure_oauth_session(session: Session) -> Result<Session, AppError> {
117 if session.is_oauth() {
118 Ok(session)
119 } else {
120 error!("v3 login response did not contain an OAuth token");
121 Err(AppError::Unauthorized)
122 }
123}
124
125/// Authentication manager for IG Markets API
126///
127/// Handles all authentication operations including:
128/// - Login with API v2 or v3
129/// - Automatic OAuth token refresh
130/// - Account switching
131/// - Session management
132/// - Rate limiting for API requests
133pub struct Auth {
134 config: Arc<Config>,
135 client: Client,
136 session: Arc<RwLock<Option<Session>>>,
137 // `RateLimiter` is `Clone` and already wraps each governor bucket in an
138 // `Arc`, so it is shared directly without an outer `RwLock`: the limiter is
139 // configured once at construction and never write-swapped.
140 rate_limiter: RateLimiter,
141}
142
143impl Auth {
144 /// Creates a new Auth instance, returning an error if the HTTP client
145 /// cannot be constructed.
146 ///
147 /// This is the sole constructor for [`Auth`]: it surfaces a TLS /
148 /// client-builder failure as a typed [`AppError`] instead of panicking, so
149 /// callers can handle a broken TLS backend gracefully.
150 ///
151 /// # Arguments
152 /// * `config` - Configuration containing credentials and API settings
153 ///
154 /// # Errors
155 /// Returns [`AppError::Network`] if the underlying `reqwest` client cannot
156 /// be built (e.g. the system TLS backend fails to initialize).
157 pub fn try_new(config: Arc<Config>) -> Result<Self, AppError> {
158 let rate_limiter = RateLimiter::new(&config.rate_limiter);
159 Self::with_rate_limiter(config, rate_limiter)
160 }
161
162 /// Builds an `Auth` that paces against a caller-supplied limiter.
163 ///
164 /// IG meters its allowance per API key, and a login is one of the requests
165 /// it counts. When a key's session and its data requests pace against
166 /// separate limiters, the key's real budget is the sum of the two and IG
167 /// rejects requests the client believed were within budget. Sharing one
168 /// limiter per key is what makes the local pacing match the remote one.
169 ///
170 /// # Arguments
171 /// * `config` - Configuration containing credentials and API settings
172 /// * `rate_limiter` - The limiter owned by this key
173 ///
174 /// # Errors
175 /// Returns [`AppError::Network`] if the underlying `reqwest` client cannot
176 /// be built (e.g. the system TLS backend fails to initialize).
177 pub fn with_rate_limiter(
178 config: Arc<Config>,
179 rate_limiter: RateLimiter,
180 ) -> Result<Self, AppError> {
181 let client = Client::builder().user_agent(USER_AGENT).build()?;
182
183 Ok(Self {
184 config,
185 client,
186 session: Arc::new(RwLock::new(None)),
187 rate_limiter,
188 })
189 }
190
191 /// Gets WebSocket connection information for Lightstreamer, reusing the
192 /// cached session.
193 ///
194 /// This calls [`Auth::get_session`], which returns the cached session when
195 /// it is still valid and only logs in (storing the new session) when none
196 /// exists or it has expired. The configured API version is honoured — no
197 /// per-call v2 re-login is performed.
198 ///
199 /// # Returns
200 /// * `Ok(WebsocketInfo)` - Endpoint and authentication tokens for the
201 /// current session.
202 /// * `Err(AppError)` - If session retrieval (login / refresh) fails.
203 ///
204 /// # Errors
205 /// Returns [`AppError`] when the session cannot be retrieved (login or token
206 /// refresh failure).
207 pub async fn ws_info(&self) -> Result<WebsocketInfo, AppError> {
208 let session = self.get_session().await?;
209 Ok(session.get_websocket_info())
210 }
211
212 /// Gets the WebSocket password for Lightstreamer authentication
213 ///
214 /// # Returns
215 /// * WebSocket password in format "CST-{cst}|XST-{token}" or empty string if session is not available
216 #[deprecated(
217 note = "use ws_info() which reuses the cached session and returns a typed error instead of a default-on-error WebsocketInfo"
218 )]
219 pub async fn get_ws_info(&self) -> WebsocketInfo {
220 self.ws_info().await.unwrap_or_default()
221 }
222
223 /// Gets the current session, ensuring tokens are valid
224 ///
225 /// This method automatically refreshes expired OAuth tokens or re-authenticates if needed.
226 ///
227 /// # Returns
228 /// * `Ok(Session)` - Valid session with fresh tokens
229 /// * `Err(AppError)` - If authentication fails
230 pub async fn get_session(&self) -> Result<Session, AppError> {
231 let session = self.session.read().await;
232
233 if let Some(sess) = session.as_ref() {
234 // Refresh proactively once the session enters its refresh margin. The
235 // margin is derived from the session type so it matches the one
236 // `refresh_token` re-checks with — otherwise the refresh detour would
237 // hand back the same near-expired session (the old 300s/1s mismatch).
238 let margin = proactive_refresh_margin_secs(sess);
239 if sess.needs_token_refresh(Some(margin)) {
240 drop(session); // Release read lock
241 debug!(margin_secs = margin, "session within refresh margin");
242 return self.refresh_token().await;
243 }
244 return Ok(sess.clone());
245 }
246
247 drop(session);
248
249 // No session exists, need to login
250 info!("No active session, logging in");
251 self.login().await
252 }
253
254 /// Performs initial login to IG Markets API
255 ///
256 /// Automatically detects API version from config and uses appropriate authentication method.
257 ///
258 /// # Returns
259 /// * `Ok(Session)` - Authenticated session
260 /// * `Err(AppError)` - If login fails
261 pub async fn login(&self) -> Result<Session, AppError> {
262 let api_version = self.config.api_version.unwrap_or(2);
263
264 debug!("Logging in with API v{}", api_version);
265
266 let session = if api_version == 3 {
267 self.login_oauth().await?
268 } else {
269 self.login_v2().await?
270 };
271
272 // Store session. The write guard is scoped so it is released before any
273 // account-selection switch below: `switch_account` reads `self.session`
274 // via `get_session`, and holding the guard across that call would
275 // deadlock.
276 {
277 let mut sess = self.session.write().await;
278 *sess = Some(session.clone());
279 }
280
281 info!("✓ Login successful, account: {}", session.account_id);
282
283 // v2 account selection: the v2 `/session` response reports IG's
284 // current/default account, which may differ from the configured one.
285 // Switch to the configured account when it is set and differs. This
286 // cannot recurse: the session was just stored above, so
287 // `switch_account` -> `get_session` returns the cached (valid) session
288 // and never triggers another login. OAuth (v3) already pins the account
289 // in `login_oauth`, so it is skipped here.
290 if api_version != 3 {
291 let configured = self.config.credentials.account_id.clone();
292 if should_switch_account(api_version, &configured, &session.account_id) {
293 info!("selecting configured account after v2 login");
294 // `Box::pin` breaks the compile-time async recursion cycle
295 // (login -> switch_account -> get_session -> login). The cycle
296 // is runtime-bounded: the session was stored above, so
297 // `get_session` returns it without logging in again.
298 return Box::pin(self.switch_account(&configured, None)).await;
299 }
300 }
301
302 Ok(session)
303 }
304
305 /// Performs login using API v2 (CST/X-SECURITY-TOKEN) with automatic retry on rate limit
306 async fn login_v2(&self) -> Result<Session, AppError> {
307 let url = format!("{}/session", self.config.rest_api.base_url);
308
309 let body = serde_json::json!({
310 "identifier": self.config.credentials.username,
311 "password": self.config.credentials.password,
312 });
313
314 debug!("Sending v2 login request to: {}", url);
315
316 let headers = vec![
317 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
318 ("Content-Type", "application/json"),
319 ("Version", "2"),
320 ];
321
322 let response = make_http_request(
323 &self.client,
324 &self.rate_limiter,
325 Method::POST,
326 &url,
327 headers,
328 &Some(body),
329 RetryConfig::default(),
330 )
331 .await?;
332
333 // Extract CST and X-SECURITY-TOKEN from headers
334 let cst: String = match response
335 .headers()
336 .get("CST")
337 .and_then(|v| v.to_str().ok())
338 .map(String::from)
339 {
340 Some(token) => token,
341 None => {
342 // A rejected / malformed auth response, not bad caller input:
343 // surface a typed auth error naming the missing header.
344 error!("missing cst header in login response");
345 return Err(AuthError::MissingSessionToken("cst".to_string()).into());
346 }
347 };
348 let x_security_token: String = match response
349 .headers()
350 .get("X-SECURITY-TOKEN")
351 .and_then(|v| v.to_str().ok())
352 .map(String::from)
353 {
354 Some(token) => token,
355 None => {
356 // A rejected / malformed auth response, not bad caller input:
357 // surface a typed auth error naming the missing header.
358 error!("missing x-security-token header in login response");
359 return Err(AuthError::MissingSessionToken("x-security-token".to_string()).into());
360 }
361 };
362
363 let x_ig_api_key: String = response
364 .headers()
365 .get("X-IG-API-KEY")
366 .and_then(|v| v.to_str().ok())
367 .map(String::from)
368 .unwrap_or_else(|| self.config.credentials.api_key.clone());
369
370 let security_headers: SecurityHeaders = SecurityHeaders {
371 cst,
372 x_security_token,
373 x_ig_api_key,
374 };
375
376 // Get response body as text first for debugging
377 let body_text = response.text().await.map_err(|e| {
378 error!("Failed to read response body: {}", e);
379 AppError::Network(e)
380 })?;
381 debug!("Login response body length: {} bytes", body_text.len());
382
383 // Parse the JSON
384 let mut response: SessionResponse = serde_json::from_str(&body_text).map_err(|e| {
385 // Never log the body: the `/session` response carries credentials.
386 error!(
387 endpoint = %url,
388 body_len = body_text.len(),
389 "failed to parse login response: {}",
390 e
391 );
392 AppError::Deserialization(format!("Failed to parse login response: {}", e))
393 })?;
394 let session = response.get_session_v2(&security_headers);
395
396 Ok(session)
397 }
398
399 /// Performs login using API v3 (OAuth) with automatic retry on rate limit
400 async fn login_oauth(&self) -> Result<Session, AppError> {
401 let url = format!("{}/session", self.config.rest_api.base_url);
402
403 let body = serde_json::json!({
404 "identifier": self.config.credentials.username,
405 "password": self.config.credentials.password,
406 });
407
408 debug!("Sending OAuth login request to: {}", url);
409 let headers = vec![
410 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
411 ("Content-Type", "application/json"),
412 ("Version", "3"),
413 ];
414
415 let response = make_http_request(
416 &self.client,
417 &self.rate_limiter,
418 Method::POST,
419 &url,
420 headers,
421 &Some(body),
422 RetryConfig::default(),
423 )
424 .await?;
425
426 let response: SessionResponse = response.json().await?;
427 let mut session = response.get_session();
428 if session.account_id != self.config.credentials.account_id {
429 session.account_id = self.config.credentials.account_id.clone();
430 };
431
432 // A v2-shaped body on the v3 path (server-side mismatch) must not panic;
433 // surface it as a typed error instead.
434 ensure_oauth_session(session)
435 }
436
437 /// Proactively refreshes the session when it is within its refresh margin.
438 ///
439 /// This is the *proactive* path (driven by the local clock). It re-checks the
440 /// cached session against the same margin
441 /// [`get_session`](Self::get_session) used to decide a refresh was due, so
442 /// the two stay consistent and the refresh actually fires when the session is
443 /// close to expiry. If the session is still comfortably valid it is returned
444 /// unchanged; otherwise a full [`login`](Self::login) is performed.
445 ///
446 /// For the reactive 401 / server-side-invalidation path — where the local
447 /// clock still considers the token valid but IG has already rejected it — use
448 /// [`force_refresh`](Self::force_refresh), which re-authenticates
449 /// unconditionally.
450 ///
451 /// # Returns
452 /// * `Ok(Session)` - A valid session (refreshed if it was within margin).
453 /// * `Err(AppError)` - If re-authentication fails.
454 ///
455 /// # Errors
456 /// Returns [`AppError`] when a required login fails (network, credentials, or
457 /// rate limiting).
458 pub async fn refresh_token(&self) -> Result<Session, AppError> {
459 let current_session = {
460 let session = self.session.read().await;
461 session.clone()
462 };
463
464 if let Some(sess) = current_session {
465 // Honour the SAME margin `get_session` used to route here, so a
466 // session inside the proactive window is actually re-authenticated
467 // instead of being handed back near-expired.
468 let margin = proactive_refresh_margin_secs(&sess);
469 if sess.is_expired(Some(margin)) {
470 debug!(
471 margin_secs = margin,
472 "session within refresh margin, logging in"
473 );
474 self.login().await
475 } else {
476 Ok(sess)
477 }
478 } else {
479 warn!("No session to refresh, performing login");
480 self.login().await
481 }
482 }
483
484 /// Forces a fresh re-authentication regardless of local expiry state.
485 ///
486 /// This is the reactive 401 / server-side-invalidation path. When IG rejects
487 /// a token that the local clock still considers valid (server-side
488 /// invalidation, a concurrent login elsewhere, or clock skew), a proactive
489 /// [`refresh_token`](Self::refresh_token) would see a "valid" session and
490 /// hand back the *same* stale token, so the replayed request would fail
491 /// again. `force_refresh` ignores local expiry and performs a full
492 /// [`login`](Self::login), which fetches and stores a brand-new session.
493 ///
494 /// It cannot loop back through the 401 handler: [`login`](Self::login) issues
495 /// its HTTP requests through
496 /// [`make_http_request`] directly, not
497 /// through the [`HttpClient`](crate::application::http::HttpClient) refresh-and-replay
498 /// path, so a 401 encountered *during* login surfaces as a typed error rather
499 /// than recursing into `force_refresh`.
500 ///
501 /// # Returns
502 /// * `Ok(Session)` - A freshly authenticated session with new tokens.
503 /// * `Err(AppError)` - If re-authentication fails.
504 ///
505 /// # Errors
506 /// Returns [`AppError`] when the login request fails (network, credentials,
507 /// or rate limiting).
508 pub async fn force_refresh(&self) -> Result<Session, AppError> {
509 debug!("forcing re-authentication, ignoring local expiry");
510 self.login().await
511 }
512
513 /// Switches to a different trading account
514 ///
515 /// # Arguments
516 /// * `account_id` - The account ID to switch to
517 /// * `default_account` - Whether to set as default account
518 ///
519 /// # Returns
520 /// * `Ok(Session)` - New session for the switched account
521 /// * `Err(AppError)` - If account switch fails
522 pub async fn switch_account(
523 &self,
524 account_id: &str,
525 default_account: Option<bool>,
526 ) -> Result<Session, AppError> {
527 let current_session = self.get_session().await?;
528 if matches!(current_session.api_version, 3) {
529 return Err(AppError::InvalidInput(
530 "Cannot switch accounts with OAuth".to_string(),
531 ));
532 }
533
534 if current_session.account_id == account_id {
535 debug!("Already on account {}", account_id);
536 return Ok(current_session);
537 }
538
539 info!("Switching to account: {}", account_id);
540
541 let url = format!("{}/session", self.config.rest_api.base_url);
542
543 let mut body = serde_json::json!({
544 "accountId": account_id,
545 });
546
547 if let Some(default) = default_account {
548 body["defaultAccount"] = serde_json::json!(default);
549 }
550
551 // Build headers with authentication. Only the v2 (CST /
552 // X-SECURITY-TOKEN) path is reachable here: OAuth sessions
553 // (`api_version == 3`) are rejected above, so no `Authorization: Bearer`
554 // branch is needed.
555 let api_key = self.config.credentials.api_key.clone();
556 let cst;
557 let x_security_token;
558
559 let mut headers = vec![
560 ("X-IG-API-KEY", api_key.as_str()),
561 ("Content-Type", "application/json"),
562 ("Version", "1"),
563 ];
564
565 if let Some(cst_val) = ¤t_session.cst {
566 cst = cst_val.clone();
567 headers.push(("CST", cst.as_str()));
568 }
569 if let Some(token_val) = ¤t_session.x_security_token {
570 x_security_token = token_val.clone();
571 headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
572 }
573
574 let response = make_http_request(
575 &self.client,
576 &self.rate_limiter,
577 Method::PUT,
578 &url,
579 headers,
580 &Some(body),
581 RetryConfig::default(),
582 )
583 .await?;
584
585 // IG re-issues the X-SECURITY-TOKEN (and sometimes CST) in the switch
586 // response. Read them from the headers and merge them in; a missing
587 // header keeps the existing token rather than nulling it.
588 let new_cst = response
589 .headers()
590 .get("CST")
591 .and_then(|v| v.to_str().ok())
592 .map(String::from);
593 let new_x_security_token = response
594 .headers()
595 .get("X-SECURITY-TOKEN")
596 .and_then(|v| v.to_str().ok())
597 .map(String::from);
598
599 // IG re-issues X-SECURITY-TOKEN on a successful switch. Its absence on a
600 // 2xx response is anomalous (proxy stripping / response-shape drift): the
601 // old token is kept below, but flag it since it will 401 the next call.
602 if new_x_security_token.is_none() {
603 warn!("switch response carried no X-SECURITY-TOKEN; keeping the previous token");
604 }
605
606 // After switching, update the session with the fresh tokens and the new
607 // account id.
608 let mut new_session = apply_switch_headers(
609 current_session.clone(),
610 new_cst.as_deref(),
611 new_x_security_token.as_deref(),
612 );
613 new_session.account_id = account_id.to_string();
614
615 {
616 let mut session = self.session.write().await;
617 *session = Some(new_session.clone());
618 }
619
620 info!("✓ Switched to account: {}", account_id);
621 Ok(new_session)
622 }
623
624 /// Logs out and clears the current session.
625 ///
626 /// Before clearing local state, this issues `DELETE /session` (IG API
627 /// Version 1) with the current authentication headers. For a **v2** session
628 /// (CST / X-SECURITY-TOKEN) this invalidates the session server-side. For a
629 /// **v3 / OAuth** session there is no IG token-revocation endpoint and the
630 /// short-lived access token has usually already expired, so `DELETE /session`
631 /// is best-effort: logout clears local state and the OAuth tokens lapse on
632 /// their own expiry rather than being actively revoked. A `401 Unauthorized`
633 /// (or an already-invalid OAuth token) is treated as already-logged-out and
634 /// reported as success.
635 ///
636 /// The local session is always cleared, even when the server-side call
637 /// fails, so the client is never wedged in an authenticated-but-unusable
638 /// state; the underlying failure is still returned as a typed error.
639 ///
640 /// # Errors
641 /// Returns [`AppError`] when the server-side logout request fails for a
642 /// reason other than an already-invalid session (401). The local session is
643 /// cleared regardless.
644 pub async fn logout(&self) -> Result<(), AppError> {
645 info!("Logging out");
646
647 // Snapshot the current session without holding the lock across the HTTP
648 // call. With no session there is nothing to revoke server-side.
649 let current_session = {
650 let session = self.session.read().await;
651 session.clone()
652 };
653
654 let server_result = match current_session {
655 Some(sess) => self.revoke_session(&sess).await,
656 None => Ok(()),
657 };
658
659 // Always clear local state so the client is never wedged, regardless of
660 // the server-side outcome.
661 {
662 let mut session = self.session.write().await;
663 *session = None;
664 }
665
666 match server_result {
667 Ok(()) => {
668 info!("✓ Logged out successfully");
669 Ok(())
670 }
671 Err(e) => {
672 // The error type carries no secrets (see `AppError`), so it is
673 // safe to log; local state has already been cleared.
674 error!("server-side logout failed: {}", e);
675 Err(e)
676 }
677 }
678 }
679
680 /// Issues `DELETE /session` (IG API Version 1) to terminate the session
681 /// server-side.
682 ///
683 /// A `401 Unauthorized` (or an already-invalid OAuth token) means the
684 /// session is no longer valid server-side and is treated as success.
685 ///
686 /// # Errors
687 /// Returns [`AppError`] if the request fails for any reason other than an
688 /// already-invalid session.
689 async fn revoke_session(&self, session: &Session) -> Result<(), AppError> {
690 let url = format!("{}/session", self.config.rest_api.base_url);
691 let api_key = self.config.credentials.api_key.clone();
692
693 let auth_header_value;
694 let cst;
695 let x_security_token;
696
697 let mut headers = vec![
698 ("X-IG-API-KEY", api_key.as_str()),
699 ("Content-Type", "application/json"),
700 ("Version", "1"),
701 ];
702
703 if let Some(oauth) = &session.oauth_token {
704 auth_header_value = format!("Bearer {}", oauth.access_token);
705 headers.push(("Authorization", auth_header_value.as_str()));
706 headers.push(("IG-ACCOUNT-ID", session.account_id.as_str()));
707 } else {
708 if let Some(cst_val) = &session.cst {
709 cst = cst_val.clone();
710 headers.push(("CST", cst.as_str()));
711 }
712 if let Some(token_val) = &session.x_security_token {
713 x_security_token = token_val.clone();
714 headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
715 }
716 }
717
718 match make_http_request(
719 &self.client,
720 &self.rate_limiter,
721 Method::DELETE,
722 &url,
723 headers,
724 &None::<()>,
725 RetryConfig::default(),
726 )
727 .await
728 {
729 Ok(_) => Ok(()),
730 // A 401 (or an expired OAuth token) means the session is already
731 // invalid server-side: the logout goal is met.
732 Err(AppError::Unauthorized | AppError::OAuthTokenExpired) => {
733 debug!("session already invalid server-side; treating as logged out");
734 Ok(())
735 }
736 Err(e) => Err(e),
737 }
738 }
739}
740
741#[cfg(test)]
742mod session_lifecycle_tests {
743 use super::*;
744
745 fn v2_session(cst: Option<&str>, xst: Option<&str>) -> Session {
746 Session {
747 account_id: "ACC-OLD".to_string(),
748 client_id: "CLIENT1".to_string(),
749 lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
750 cst: cst.map(String::from),
751 x_security_token: xst.map(String::from),
752 oauth_token: None,
753 api_version: 2,
754 expires_at: 123,
755 }
756 }
757
758 #[test]
759 fn test_apply_switch_headers_replaces_xst_and_keeps_other_fields() {
760 let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
761 // A switch response that only re-issues the X-SECURITY-TOKEN.
762 let updated = apply_switch_headers(session, None, Some("NEW-XST"));
763
764 assert_eq!(updated.x_security_token.as_deref(), Some("NEW-XST"));
765 // CST is preserved when the header is absent.
766 assert_eq!(updated.cst.as_deref(), Some("OLD-CST"));
767 // Unrelated fields carry through unchanged.
768 assert_eq!(updated.account_id, "ACC-OLD");
769 assert_eq!(updated.expires_at, 123);
770 assert_eq!(updated.api_version, 2);
771 }
772
773 #[test]
774 fn test_apply_switch_headers_updates_both_tokens() {
775 let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
776 let updated = apply_switch_headers(session, Some("NEW-CST"), Some("NEW-XST"));
777
778 assert_eq!(updated.cst.as_deref(), Some("NEW-CST"));
779 assert_eq!(updated.x_security_token.as_deref(), Some("NEW-XST"));
780 }
781
782 #[test]
783 fn test_apply_switch_headers_none_preserves_existing_tokens() {
784 let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
785 // A switch response carrying neither header must not null the tokens.
786 let updated = apply_switch_headers(session, None, None);
787
788 assert_eq!(updated.cst.as_deref(), Some("OLD-CST"));
789 assert_eq!(updated.x_security_token.as_deref(), Some("OLD-XST"));
790 }
791
792 #[test]
793 fn test_should_switch_account_guards_the_sentinel_and_v3() {
794 use crate::constants::DEFAULT_ACCOUNT_ID;
795
796 // Real configured account that differs from the current one: switch.
797 assert!(should_switch_account(2, "ACC-B", "ACC-A"));
798 // Unconfigured default sentinel: must NOT switch (would fail login).
799 assert!(!should_switch_account(2, DEFAULT_ACCOUNT_ID, "ACC-A"));
800 // Empty configured account: must not switch.
801 assert!(!should_switch_account(2, "", "ACC-A"));
802 // Already on the configured account: no switch needed.
803 assert!(!should_switch_account(2, "ACC-A", "ACC-A"));
804 // OAuth (v3) is never switched through this path.
805 assert!(!should_switch_account(3, "ACC-B", "ACC-A"));
806 }
807
808 #[tokio::test]
809 async fn test_ws_info_uses_cached_session_without_login() {
810 let auth =
811 Auth::try_new(Arc::new(Config::default())).expect("auth construction should succeed");
812
813 // Seed a valid (non-expired) v2 session with known tokens. Because the
814 // session is valid, `ws_info` -> `get_session` must return it without a
815 // network login (a login would require real credentials and fail).
816 let expires_at = (Utc::now().timestamp() + 3600) as u64;
817 let seeded = Session {
818 account_id: "ACC123".to_string(),
819 client_id: "CLIENT1".to_string(),
820 lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
821 cst: Some("CST-TOKEN".to_string()),
822 x_security_token: Some("XST-TOKEN".to_string()),
823 oauth_token: None,
824 api_version: 2,
825 expires_at,
826 };
827 {
828 let mut guard = auth.session.write().await;
829 *guard = Some(seeded);
830 }
831
832 let ws = match auth.ws_info().await {
833 Ok(ws) => ws,
834 Err(e) => panic!("ws_info should return Ok for a cached session: {e}"),
835 };
836
837 // The returned info derives from the cached session's tokens, proving no
838 // fresh login occurred.
839 assert_eq!(ws.account_id, "ACC123");
840 assert_eq!(ws.cst.as_deref(), Some("CST-TOKEN"));
841 assert_eq!(ws.x_security_token.as_deref(), Some("XST-TOKEN"));
842 assert!(ws.server.contains("demo-apd.marketdatasystems.com"));
843 }
844}
845
846#[cfg(test)]
847mod expiry_and_refresh_tests {
848 use super::*;
849
850 fn v2_session(expires_at: u64) -> Session {
851 Session {
852 account_id: "ACC123".to_string(),
853 client_id: "CLIENT1".to_string(),
854 lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
855 cst: Some("CST-TOKEN".to_string()),
856 x_security_token: Some("XST-TOKEN".to_string()),
857 oauth_token: None,
858 api_version: 2,
859 expires_at,
860 }
861 }
862
863 fn v3_session(expires_at: u64) -> Session {
864 Session {
865 account_id: "ACC123".to_string(),
866 client_id: "CLIENT1".to_string(),
867 lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
868 cst: None,
869 x_security_token: None,
870 oauth_token: Some(OAuthToken {
871 access_token: "ACCESS".to_string(),
872 refresh_token: "REFRESH".to_string(),
873 scope: "read write".to_string(),
874 token_type: "Bearer".to_string(),
875 expires_in: "60".to_string(),
876 created_at: Utc::now(),
877 }),
878 api_version: 3,
879 expires_at,
880 }
881 }
882
883 #[test]
884 fn test_seconds_until_expiry_expired_session_returns_zero() {
885 // expires_at 100s in the past -> saturating to 0, never a huge u64.
886 let past = u64::try_from(Utc::now().timestamp())
887 .unwrap_or(0)
888 .saturating_sub(100);
889 let session = v2_session(past);
890 assert_eq!(session.seconds_until_expiry(), 0);
891 }
892
893 #[test]
894 fn test_seconds_until_expiry_valid_session_is_positive() {
895 let future = u64::try_from(Utc::now().timestamp())
896 .unwrap_or(0)
897 .saturating_add(3600);
898 let session = v2_session(future);
899 // Allow a small slack for the wall-clock read inside the method.
900 assert!(session.seconds_until_expiry() > 3500);
901 }
902
903 #[test]
904 fn test_time_until_expiry_expired_session_is_zero() {
905 let past = u64::try_from(Utc::now().timestamp())
906 .unwrap_or(0)
907 .saturating_sub(100);
908 let session = v2_session(past);
909 assert_eq!(session.time_until_expiry(), std::time::Duration::ZERO);
910 }
911
912 #[test]
913 fn test_is_expired_large_margin_does_not_underflow() {
914 // Tiny expires_at with an enormous margin must not underflow / panic;
915 // it simply reports the session as expired.
916 let session = v2_session(1);
917 assert!(session.is_expired(Some(u64::MAX)));
918 assert!(session.is_expired(Some(1000)));
919 }
920
921 #[test]
922 fn test_is_expired_valid_session_within_margin_still_valid() {
923 let future = u64::try_from(Utc::now().timestamp())
924 .unwrap_or(0)
925 .saturating_add(3600);
926 let session = v2_session(future);
927 assert!(!session.is_expired(Some(60)));
928 }
929
930 #[test]
931 fn test_proactive_refresh_margin_v3_is_small_v2_is_large() {
932 let v3 = v3_session(0);
933 let v2 = v2_session(0);
934 assert_eq!(
935 proactive_refresh_margin_secs(&v3),
936 crate::constants::PROACTIVE_REFRESH_MARGIN_V3_SECS
937 );
938 assert_eq!(
939 proactive_refresh_margin_secs(&v2),
940 crate::constants::PROACTIVE_REFRESH_MARGIN_V2_SECS
941 );
942 // v3's short-lived tokens get a tighter margin than v2's 6h sessions.
943 assert!(proactive_refresh_margin_secs(&v3) < proactive_refresh_margin_secs(&v2));
944 }
945
946 #[tokio::test]
947 async fn test_refresh_token_returns_cached_valid_session_without_login() {
948 // refresh_token has an expiry gate: a comfortably-valid session is
949 // returned unchanged, so no network login is attempted. This is the
950 // behaviour that differs from force_refresh (which always re-logs in).
951 let auth =
952 Auth::try_new(Arc::new(Config::default())).expect("auth construction should succeed");
953 let future = u64::try_from(Utc::now().timestamp())
954 .unwrap_or(0)
955 .saturating_add(3600);
956 let seeded = v2_session(future);
957 {
958 let mut guard = auth.session.write().await;
959 *guard = Some(seeded);
960 }
961
962 let refreshed = match auth.refresh_token().await {
963 Ok(session) => session,
964 Err(e) => panic!("refresh_token should return the cached session: {e}"),
965 };
966 // Same cached tokens returned: no re-authentication happened.
967 assert_eq!(refreshed.account_id, "ACC123");
968 assert_eq!(refreshed.cst.as_deref(), Some("CST-TOKEN"));
969 assert_eq!(refreshed.expires_at, future);
970 }
971
972 #[test]
973 fn test_force_refresh_is_public_and_present() {
974 // Compile-time proof that the additive 401-path API exists. Invoking it
975 // would perform a real login, so it is not called here (no network in
976 // unit tests).
977 let _ = Auth::force_refresh;
978 }
979}
980
981#[cfg(test)]
982mod oauth_login_guard_tests {
983 use super::*;
984
985 // Captured real IG demo v2 `/session` body (same shape as the existing
986 // deserialization tests). Deserialized through the untagged
987 // `SessionResponse` it lands on the V2 variant, so the derived session
988 // carries no OAuth token — exactly the mismatch `login_oauth` must reject.
989 const V2_BODY: &str = 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}"#;
990
991 #[test]
992 fn test_ensure_oauth_session_v2_body_on_v3_path_yields_unauthorized()
993 -> Result<(), serde_json::Error> {
994 // Deserialize a v2-shaped body the way `login_oauth` does after a v3
995 // request, then run it through the same guard.
996 let response: SessionResponse = serde_json::from_str(V2_BODY)?;
997 let session = response.get_session();
998 // A v2 body carries no OAuth token.
999 assert!(!session.is_oauth());
1000
1001 // The guard maps this to a typed error instead of panicking (the old
1002 // `assert!(session.is_oauth())` would have aborted here).
1003 match ensure_oauth_session(session) {
1004 Err(AppError::Unauthorized) => Ok(()),
1005 other => panic!("expected AppError::Unauthorized, got {other:?}"),
1006 }
1007 }
1008
1009 #[test]
1010 fn test_ensure_oauth_session_oauth_body_passes_through() {
1011 // A genuine v3 session passes the guard unchanged.
1012 let session = Session {
1013 account_id: "ACC123".to_string(),
1014 client_id: "CLIENT1".to_string(),
1015 lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
1016 cst: None,
1017 x_security_token: None,
1018 oauth_token: Some(OAuthToken {
1019 access_token: "ACCESS".to_string(),
1020 refresh_token: "REFRESH".to_string(),
1021 scope: "read write".to_string(),
1022 token_type: "Bearer".to_string(),
1023 expires_in: "60".to_string(),
1024 created_at: Utc::now(),
1025 }),
1026 api_version: 3,
1027 expires_at: 0,
1028 };
1029 assert!(ensure_oauth_session(session).is_ok());
1030 }
1031}
1032
1033#[cfg(test)]
1034mod redaction_tests {
1035 use super::*;
1036
1037 fn secret_session() -> Session {
1038 Session {
1039 account_id: "ACC123".to_string(),
1040 client_id: "CLIENT1".to_string(),
1041 lightstreamer_endpoint: "https://ls.example.com".to_string(),
1042 cst: Some("SECRET-CST-VALUE".to_string()),
1043 x_security_token: Some("SECRET-XST-VALUE".to_string()),
1044 oauth_token: Some(OAuthToken {
1045 access_token: "SECRET-ACCESS-VALUE".to_string(),
1046 refresh_token: "SECRET-REFRESH-VALUE".to_string(),
1047 scope: "read write".to_string(),
1048 token_type: "Bearer".to_string(),
1049 expires_in: "60".to_string(),
1050 created_at: chrono::Utc::now(),
1051 }),
1052 api_version: 3,
1053 expires_at: 0,
1054 }
1055 }
1056
1057 #[test]
1058 fn test_session_debug_redacts_tokens() {
1059 let session = secret_session();
1060 let rendered = format!("{session:?}");
1061
1062 assert!(!rendered.contains("SECRET-CST-VALUE"));
1063 assert!(!rendered.contains("SECRET-XST-VALUE"));
1064 assert!(!rendered.contains("SECRET-ACCESS-VALUE"));
1065 assert!(!rendered.contains("SECRET-REFRESH-VALUE"));
1066 assert!(rendered.contains("<redacted>"));
1067 // Non-secret fields stay visible.
1068 assert!(rendered.contains("ACC123"));
1069 assert!(rendered.contains("ls.example.com"));
1070 }
1071
1072 #[test]
1073 fn test_websocket_info_debug_redacts_tokens() {
1074 let ws = WebsocketInfo {
1075 server: "https://ls.example.com/lightstreamer".to_string(),
1076 cst: Some("SECRET-CST-VALUE".to_string()),
1077 x_security_token: Some("SECRET-XST-VALUE".to_string()),
1078 account_id: "ACC123".to_string(),
1079 };
1080 let rendered = format!("{ws:?}");
1081
1082 assert!(!rendered.contains("SECRET-CST-VALUE"));
1083 assert!(!rendered.contains("SECRET-XST-VALUE"));
1084 assert!(rendered.contains("Some(<redacted>)"));
1085 assert!(rendered.contains("ACC123"));
1086 assert!(rendered.contains("ls.example.com"));
1087 }
1088
1089 #[test]
1090 fn test_websocket_info_display_redacts_tokens() {
1091 let ws = WebsocketInfo {
1092 server: "https://ls.example.com/lightstreamer".to_string(),
1093 cst: Some("SECRET-CST-VALUE".to_string()),
1094 x_security_token: Some("SECRET-XST-VALUE".to_string()),
1095 account_id: "ACC123".to_string(),
1096 };
1097 let rendered = format!("{ws}");
1098
1099 assert!(!rendered.contains("SECRET-CST-VALUE"));
1100 assert!(!rendered.contains("SECRET-XST-VALUE"));
1101 assert!(rendered.contains("<redacted>"));
1102 assert!(rendered.contains("ACC123"));
1103
1104 // `None` tokens render as `None`, not as a redacted placeholder.
1105 let ws_none = WebsocketInfo {
1106 server: "https://ls.example.com/lightstreamer".to_string(),
1107 cst: None,
1108 x_security_token: None,
1109 account_id: "ACC123".to_string(),
1110 };
1111 assert!(format!("{ws_none}").contains("None"));
1112 }
1113}