1use chrono::{DateTime, Duration, Utc};
17use serde::{Deserialize, Serialize};
18
19use crate::Error;
20
21const AUTH_URL: &str = "https://signin.tradestation.com/authorize";
22const TOKEN_URL: &str = "https://signin.tradestation.com/oauth/token";
23
24#[derive(Debug, Clone)]
38pub struct Credentials {
39 pub client_id: String,
41 pub client_secret: String,
43 pub redirect_uri: String,
45}
46
47impl Credentials {
48 pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
50 Self {
51 client_id: client_id.into(),
52 client_secret: client_secret.into(),
53 redirect_uri: "http://localhost:3000/callback".to_string(),
54 }
55 }
56
57 pub fn with_redirect_uri(mut self, uri: impl Into<String>) -> Self {
59 self.redirect_uri = uri.into();
60 self
61 }
62
63 pub fn authorization_url(&self, scopes: &[Scope]) -> String {
68 let scope_str: String = scopes
69 .iter()
70 .map(|s| s.as_str())
71 .collect::<Vec<_>>()
72 .join(" ");
73 format!(
74 "{}?response_type=code&client_id={}&redirect_uri={}&audience=https://api.tradestation.com&scope={}",
75 AUTH_URL,
76 urlencoding::encode(&self.client_id),
77 urlencoding::encode(&self.redirect_uri),
78 urlencoding::encode(&scope_str),
79 )
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Scope {
88 MarketData,
90 ReadAccount,
92 Trade,
94 OptionSpreads,
96 Matrix,
98 OpenId,
100 OfflineAccess,
102}
103
104impl Scope {
105 pub fn as_str(&self) -> &'static str {
107 match self {
108 Scope::MarketData => "MarketData",
109 Scope::ReadAccount => "ReadAccount",
110 Scope::Trade => "Trade",
111 Scope::OptionSpreads => "OptionSpreads",
112 Scope::Matrix => "Matrix",
113 Scope::OpenId => "openid",
114 Scope::OfflineAccess => "offline_access",
115 }
116 }
117
118 pub fn defaults() -> Vec<Scope> {
121 vec![
122 Scope::MarketData,
123 Scope::ReadAccount,
124 Scope::Trade,
125 Scope::OptionSpreads,
126 Scope::Matrix,
127 Scope::OpenId,
128 Scope::OfflineAccess,
129 ]
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct Token {
139 pub access_token: String,
141 pub refresh_token: Option<String>,
143 pub token_type: String,
145 pub expires_at: DateTime<Utc>,
147 pub refresh_expires_at: Option<DateTime<Utc>>,
149}
150
151impl Token {
152 pub fn is_expired(&self) -> bool {
154 Utc::now() + Duration::minutes(2) >= self.expires_at
155 }
156
157 pub fn refresh_expired(&self) -> bool {
159 self.refresh_expires_at
160 .is_some_and(|expires| Utc::now() >= expires)
161 }
162
163 pub fn can_refresh(&self) -> bool {
165 self.refresh_token.is_some() && !self.refresh_expired()
166 }
167}
168
169#[derive(Debug, Deserialize)]
171struct TokenResponse {
172 access_token: String,
173 refresh_token: Option<String>,
174 token_type: String,
175 expires_in: i64,
176}
177
178pub async fn exchange_code(
187 http: &reqwest::Client,
188 credentials: &Credentials,
189 code: &str,
190) -> Result<Token, Error> {
191 let resp = http
192 .post(TOKEN_URL)
193 .form(&[
194 ("grant_type", "authorization_code"),
195 ("code", code),
196 ("client_id", &credentials.client_id),
197 ("client_secret", &credentials.client_secret),
198 ("redirect_uri", &credentials.redirect_uri),
199 ])
200 .send()
201 .await?;
202
203 if !resp.status().is_success() {
204 let status = resp.status().as_u16();
205 let body = resp.text().await.unwrap_or_default();
206 return Err(Error::Auth(format!(
207 "Token exchange failed ({status}): {body}"
208 )));
209 }
210
211 let token_resp: TokenResponse = resp.json().await?;
212 let now = Utc::now();
213
214 Ok(Token {
215 access_token: token_resp.access_token,
216 refresh_token: token_resp.refresh_token,
217 token_type: token_resp.token_type,
218 expires_at: now + Duration::seconds(token_resp.expires_in),
219 refresh_expires_at: Some(now + Duration::days(30)),
220 })
221}
222
223pub async fn revoke_token(
229 http: &reqwest::Client,
230 credentials: &Credentials,
231 token: &str,
232) -> Result<(), Error> {
233 let resp = http
234 .post(TOKEN_URL)
235 .header("Content-Type", "application/x-www-form-urlencoded")
236 .form(&[
237 ("token", token),
238 ("token_type_hint", "refresh_token"),
239 ("client_id", &credentials.client_id),
240 ("client_secret", &credentials.client_secret),
241 ])
242 .send()
243 .await?;
244
245 if !resp.status().is_success() {
246 let status = resp.status().as_u16();
247 let body = resp.text().await.unwrap_or_default();
248 return Err(Error::Auth(format!(
249 "Token revocation failed ({status}): {body}"
250 )));
251 }
252
253 Ok(())
254}
255
256pub async fn refresh_token(
262 http: &reqwest::Client,
263 credentials: &Credentials,
264 refresh_tok: &str,
265) -> Result<Token, Error> {
266 let resp = http
267 .post(TOKEN_URL)
268 .form(&[
269 ("grant_type", "refresh_token"),
270 ("refresh_token", refresh_tok),
271 ("client_id", &credentials.client_id),
272 ("client_secret", &credentials.client_secret),
273 ])
274 .send()
275 .await?;
276
277 if !resp.status().is_success() {
278 let status = resp.status().as_u16();
279 let body = resp.text().await.unwrap_or_default();
280 return Err(Error::Auth(format!(
281 "Token refresh failed ({status}): {body}"
282 )));
283 }
284
285 let token_resp: TokenResponse = resp.json().await?;
286 let now = Utc::now();
287
288 Ok(Token {
289 access_token: token_resp.access_token,
290 refresh_token: token_resp.refresh_token,
291 token_type: token_resp.token_type,
292 expires_at: now + Duration::seconds(token_resp.expires_in),
293 refresh_expires_at: Some(now + Duration::days(30)),
294 })
295}