Skip to main content

tradestation_api/
auth.rs

1//! OAuth2 Authorization Code flow with token refresh for TradeStation API.
2//!
3//! TradeStation uses OAuth2 with:
4//! - Authorization endpoint: `https://signin.tradestation.com/authorize`
5//! - Token endpoint: `https://signin.tradestation.com/oauth/token`
6//! - Access token lifetime: 20 minutes
7//! - Refresh token lifetime: ~30 days
8//!
9//! # Flow
10//!
11//! 1. Build an authorization URL with [`Credentials::authorization_url`].
12//! 2. User visits the URL and grants access.
13//! 3. Exchange the callback code via [`exchange_code`].
14//! 4. The [`Token`] auto-refreshes through [`Client::access_token`](crate::Client::access_token).
15
16use 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/// OAuth2 client credentials for the TradeStation API.
25///
26/// Obtain a `client_id` and `client_secret` from the
27/// [TradeStation Developer Portal](https://developer.tradestation.com/).
28///
29/// # Example
30///
31/// ```
32/// use tradestation_api::Credentials;
33///
34/// let creds = Credentials::new("my_client_id", "my_secret")
35///     .with_redirect_uri("http://localhost:8080/callback");
36/// ```
37#[derive(Debug, Clone)]
38pub struct Credentials {
39    /// Application client ID.
40    pub client_id: String,
41    /// Application client secret.
42    pub client_secret: String,
43    /// OAuth2 redirect URI. Defaults to `http://localhost:3000/callback`.
44    pub redirect_uri: String,
45}
46
47impl Credentials {
48    /// Create new credentials with the default redirect URI.
49    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    /// Override the redirect URI (builder pattern).
58    pub fn with_redirect_uri(mut self, uri: impl Into<String>) -> Self {
59        self.redirect_uri = uri.into();
60        self
61    }
62
63    /// Build the authorization URL for the user to visit.
64    ///
65    /// The returned URL should be opened in a browser. After the user grants
66    /// access, TradeStation redirects to `redirect_uri` with a `code` parameter.
67    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/// OAuth2 scopes controlling API access levels.
84///
85/// Use [`Scope::defaults`] for the most common combination.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Scope {
88    /// Access to market data endpoints.
89    MarketData,
90    /// Read-only access to brokerage accounts.
91    ReadAccount,
92    /// Permission to place and manage orders.
93    Trade,
94    /// Access to option spread endpoints.
95    OptionSpreads,
96    /// Access to the Matrix order entry.
97    Matrix,
98    /// OpenID Connect profile scope.
99    OpenId,
100    /// Enables refresh tokens for offline access.
101    OfflineAccess,
102}
103
104impl Scope {
105    /// Return the OAuth2 scope string value.
106    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    /// Default scopes for full API access: MarketData, ReadAccount, Trade,
119    /// OptionSpreads, Matrix, OpenId, OfflineAccess.
120    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/// OAuth2 token with expiration tracking.
134///
135/// Access tokens expire after ~20 minutes. The client automatically refreshes
136/// them using the refresh token when available.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct Token {
139    /// The bearer access token.
140    pub access_token: String,
141    /// Refresh token for obtaining new access tokens.
142    pub refresh_token: Option<String>,
143    /// Token type (typically "Bearer").
144    pub token_type: String,
145    /// When the access token expires.
146    pub expires_at: DateTime<Utc>,
147    /// When the refresh token expires (~30 days from issue).
148    pub refresh_expires_at: Option<DateTime<Utc>>,
149}
150
151impl Token {
152    /// Check if the access token is expired (with 2-minute safety buffer).
153    pub fn is_expired(&self) -> bool {
154        Utc::now() + Duration::minutes(2) >= self.expires_at
155    }
156
157    /// Check if the refresh token is expired.
158    pub fn refresh_expired(&self) -> bool {
159        self.refresh_expires_at
160            .is_some_and(|expires| Utc::now() >= expires)
161    }
162
163    /// Whether this token can be refreshed (has a refresh token that is not expired).
164    pub fn can_refresh(&self) -> bool {
165        self.refresh_token.is_some() && !self.refresh_expired()
166    }
167}
168
169/// Token response from TradeStation OAuth2 endpoint.
170#[derive(Debug, Deserialize)]
171struct TokenResponse {
172    access_token: String,
173    refresh_token: Option<String>,
174    token_type: String,
175    expires_in: i64,
176}
177
178/// Exchange an authorization code for an access/refresh token pair.
179///
180/// This is step 3 of the OAuth2 flow. The `code` comes from the redirect URI
181/// query parameter after the user authorizes the application.
182///
183/// # Errors
184///
185/// Returns [`Error::Auth`] if the token exchange fails.
186pub 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
223/// Revoke a refresh token, invalidating it for future use.
224///
225/// # Errors
226///
227/// Returns [`Error::Auth`] if the revocation request fails.
228pub 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
256/// Refresh an expired access token using the refresh token.
257///
258/// # Errors
259///
260/// Returns [`Error::Auth`] if the refresh request fails.
261pub 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}