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