ig_client/model/
auth.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 19/10/25
5******************************************************************************/
6use crate::application::auth::Session;
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9use tracing::warn;
10
11/// Response from session creation endpoint
12///
13/// This enum handles both API v2 and v3 session responses using serde's untagged feature
14#[derive(Debug, Clone, Deserialize, Serialize)]
15#[serde(untagged)]
16pub enum SessionResponse {
17    /// API v3 session response with OAuth tokens
18    V3(V3Response),
19    /// API v2 session response with CST/X-SECURITY-TOKEN
20    V2(V2Response),
21}
22
23impl SessionResponse {
24    /// Checks if this is a v3 session response
25    pub fn is_v3(&self) -> bool {
26        matches!(self, SessionResponse::V3(_))
27    }
28
29    /// Checks if this is a v2 session response
30    pub fn is_v2(&self) -> bool {
31        matches!(self, SessionResponse::V2(_))
32    }
33
34    /// Converts the response to a Session object
35    pub fn get_session(&self) -> Session {
36        match self {
37            SessionResponse::V3(v) => Session {
38                account_id: v.account_id.clone(),
39                client_id: v.client_id.clone(),
40                lightstreamer_endpoint: v.lightstreamer_endpoint.clone(),
41                cst: None,
42                x_security_token: None,
43                oauth_token: Some(v.oauth_token.clone()),
44                api_version: 3,
45                expires_at: v.oauth_token.expire_at(1),
46            },
47            SessionResponse::V2(v) => {
48                let (cst, x_security_token) = match v.security_headers.as_ref() {
49                    Some(headers) => (
50                        Some(headers.cst.clone()),
51                        Some(headers.x_security_token.clone()),
52                    ),
53                    None => (None, None),
54                };
55                let expires_at = (Utc::now().timestamp() + (3600 * 6)) as u64; // 6 hours from now
56                Session {
57                    account_id: v.current_account_id.clone(),
58                    client_id: v.client_id.clone(),
59                    lightstreamer_endpoint: v.lightstreamer_endpoint.clone(),
60                    cst,
61                    x_security_token,
62                    oauth_token: None,
63                    api_version: 2,
64                    expires_at,
65                }
66            }
67        }
68    }
69    /// Converts the response to a Session object using v2 security headers
70    ///
71    /// # Arguments
72    /// * `headers` - Security headers (CST and X-SECURITY-TOKEN)
73    pub fn get_session_v2(&mut self, headers: &SecurityHeaders) -> Session {
74        match self {
75            SessionResponse::V3(_) => {
76                warn!("Returing V3 session from V2 headers - this may be unexpected");
77                self.get_session()
78            }
79            SessionResponse::V2(v) => {
80                v.set_security_headers(headers);
81                v.expires_in = Some(21600); // 6 hours
82                self.get_session()
83            }
84        }
85    }
86
87    /// Checks if the session is expired
88    ///
89    /// # Arguments
90    /// * `margin_seconds` - Safety margin in seconds before actual expiration
91    pub fn is_expired(&self, margin_seconds: u64) -> bool {
92        match self {
93            SessionResponse::V3(v) => v.oauth_token.is_expired(margin_seconds),
94            SessionResponse::V2(v) => v.is_expired(margin_seconds),
95        }
96    }
97}
98
99/// API v3 session response
100#[derive(Debug, Clone, Deserialize, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct V3Response {
103    /// Client identifier
104    pub client_id: String,
105    /// Account identifier
106    pub account_id: String,
107    /// Timezone offset in minutes
108    pub timezone_offset: i32,
109    /// Lightstreamer WebSocket endpoint URL
110    pub lightstreamer_endpoint: String,
111    /// OAuth token information
112    pub oauth_token: OAuthToken,
113}
114
115/// OAuth token information returned by API v3
116#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)]
117pub struct OAuthToken {
118    /// OAuth access token
119    pub access_token: String,
120    /// OAuth refresh token
121    pub refresh_token: String,
122    /// Token scope
123    pub scope: String,
124    /// Token type (typically "Bearer")
125    pub token_type: String,
126    /// Token expiry time in seconds
127    pub expires_in: String,
128    /// Timestamp when this token was created (for expiry calculation)
129    #[serde(skip, default = "chrono::Utc::now")]
130    pub created_at: chrono::DateTime<Utc>,
131}
132
133impl OAuthToken {
134    /// Checks if the OAuth token is expired or will expire soon
135    ///
136    /// # Arguments
137    /// * `margin_seconds` - Safety margin in seconds before actual expiry
138    ///
139    /// # Returns
140    /// `true` if the token is expired or will expire within the margin, `false` otherwise
141    pub fn is_expired(&self, margin_seconds: u64) -> bool {
142        let expires_in_secs = self.expires_in.parse::<i64>().unwrap_or(0);
143        let expiry_time = self.created_at + chrono::Duration::seconds(expires_in_secs);
144        let now = Utc::now();
145        let margin = chrono::Duration::seconds(margin_seconds as i64);
146
147        expiry_time - margin <= now
148    }
149
150    /// Returns the Unix timestamp when the token expires (considering the margin)
151    ///
152    /// # Arguments
153    /// * `margin_seconds` - Safety margin in seconds before actual expiry
154    ///
155    /// # Returns
156    /// Unix timestamp (seconds since epoch) when the token should be considered expired
157    pub fn expire_at(&self, margin_seconds: i64) -> u64 {
158        let expires_in_secs = self.expires_in.parse::<i64>().unwrap_or(0);
159        let expiry_time = self.created_at + chrono::Duration::seconds(expires_in_secs);
160        let margin = chrono::Duration::seconds(margin_seconds);
161
162        // Subtract margin to get the "effective" expiry time
163        let effective_expiry = expiry_time - margin;
164
165        effective_expiry.timestamp() as u64
166    }
167}
168
169/// API v2 session response
170#[derive(Debug, Clone, Deserialize, Serialize)]
171#[serde(rename_all = "camelCase")]
172pub struct V2Response {
173    /// Account type (e.g., "CFD", "SPREADBET")
174    pub account_type: String,
175    /// Account information
176    pub account_info: AccountInfo,
177    /// Currency ISO code (e.g., "GBP", "USD")
178    pub currency_iso_code: String,
179    /// Currency symbol (e.g., "£", "$")
180    pub currency_symbol: String,
181    /// Current active account ID
182    pub current_account_id: String,
183    /// Lightstreamer WebSocket endpoint URL
184    pub lightstreamer_endpoint: String,
185    /// List of all accounts owned by the user
186    pub accounts: Vec<Account>,
187    /// Client identifier
188    pub client_id: String,
189    /// Timezone offset in minutes
190    pub timezone_offset: i32,
191    /// Whether user has active demo accounts
192    pub has_active_demo_accounts: bool,
193    /// Whether user has active live accounts
194    pub has_active_live_accounts: bool,
195    /// Whether trailing stops are enabled
196    pub trailing_stops_enabled: bool,
197    /// Rerouting environment if applicable
198    pub rerouting_environment: Option<String>,
199    /// Whether dealing is enabled
200    pub dealing_enabled: bool,
201    /// Security headers (CST and X-SECURITY-TOKEN)
202    #[serde(skip)]
203    pub security_headers: Option<SecurityHeaders>,
204    /// Token expiry time in seconds
205    #[serde(skip)]
206    pub expires_in: Option<u64>,
207    /// Timestamp when this token was created (for expiry calculation)
208    #[serde(skip, default = "chrono::Utc::now")]
209    pub created_at: chrono::DateTime<Utc>,
210}
211
212impl V2Response {
213    /// Sets the security headers for this session
214    ///
215    /// # Arguments
216    /// * `headers` - Security headers containing CST and X-SECURITY-TOKEN
217    pub fn set_security_headers(&mut self, headers: &SecurityHeaders) {
218        self.security_headers = Some(headers.clone());
219    }
220
221    /// Checks if the session is expired
222    ///
223    /// # Arguments
224    /// * `margin_seconds` - Safety margin in seconds before actual expiration
225    pub fn is_expired(&self, margin_seconds: u64) -> bool {
226        if let Some(expires_in) = self.expires_in {
227            let expiry_time = self.created_at + chrono::Duration::seconds(expires_in as i64);
228            let now = Utc::now();
229            let margin = chrono::Duration::seconds(margin_seconds as i64);
230
231            expiry_time - margin <= now
232        } else {
233            panic!("expires_in not set in V2Response");
234        }
235    }
236}
237
238/// Security headers for API v2 authentication
239#[derive(Debug, Clone, Deserialize, Serialize)]
240pub struct SecurityHeaders {
241    /// Client Session Token
242    pub cst: String,
243    /// Security token for request authentication
244    pub x_security_token: String,
245    /// API key for the application
246    pub x_ig_api_key: String,
247}
248
249/// Account balance information
250#[derive(Debug, Clone, Deserialize, Serialize)]
251#[serde(rename_all = "camelCase")]
252pub struct AccountInfo {
253    /// Total account balance
254    pub balance: f64,
255    /// Amount deposited
256    pub deposit: f64,
257    /// Current profit or loss
258    pub profit_loss: f64,
259    /// Available funds for trading
260    pub available: f64,
261}
262
263/// Trading account information
264#[derive(Debug, Clone, Deserialize, Serialize)]
265#[serde(rename_all = "camelCase")]
266pub struct Account {
267    /// Unique account identifier
268    pub account_id: String,
269    /// Human-readable account name
270    pub account_name: String,
271    /// Whether this is the preferred/default account
272    pub preferred: bool,
273    /// Account type (e.g., "CFD", "SPREADBET")
274    pub account_type: String,
275}