use std::sync::Arc;
use std::time::Duration;
use reqwest::header;
use tokio::sync::Mutex;
use tracing::debug;
use crate::TastyTradeError;
use crate::api::base::TastyResult;
use crate::api::client::{RequestReport, transport_failure};
use crate::error::Environment;
use crate::types::oauth::{
AccessToken, ActiveToken, AuthorizationCode, ClientSecret, OAuthGrant, RefreshToken,
TokenErrorResponse, TokenResponse,
};
const TOKEN_PATH: &str = "/oauth/token";
struct SessionState {
grant: Option<OAuthGrant>,
active: Option<ActiveToken>,
}
pub struct OAuthSession {
http: reqwest::Client,
base_url: String,
environment: Environment,
state: Mutex<SessionState>,
}
impl std::fmt::Debug for OAuthSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthSession")
.field("environment", &self.environment)
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
impl OAuthSession {
pub(crate) async fn establish(
http: reqwest::Client,
base_url: &str,
environment: Environment,
grant: OAuthGrant,
) -> TastyResult<Arc<Self>> {
let session = Arc::new(Self {
http,
base_url: base_url.to_string(),
environment,
state: Mutex::new(SessionState {
grant: None,
active: None,
}),
});
let response = session.exchange(&grant).await?;
let lifetime = response.lifetime();
let next = match response.refresh_token.as_ref() {
Some(refresh_token) => match &grant {
OAuthGrant::Refresh { client_secret, .. }
| OAuthGrant::AuthorizationCode { client_secret, .. } => {
Some(OAuthGrant::Refresh {
client_secret: client_secret.clone(),
refresh_token: refresh_token.clone(),
})
}
},
None => match grant {
OAuthGrant::Refresh { .. } => Some(grant),
OAuthGrant::AuthorizationCode { .. } => None,
},
};
{
let mut state = session.state.lock().await;
state.grant = next;
state.active = Some(ActiveToken::new(response.access_token, lifetime));
}
debug!(
"OAuth2 session established on {} (token valid for {}s)",
environment,
lifetime.as_secs()
);
Ok(session)
}
pub async fn access_token(&self) -> TastyResult<AccessToken> {
let mut state = self.state.lock().await;
if let Some(active) = &state.active
&& !active.is_stale()
{
return Ok(active.token.clone());
}
let Some(grant) = state.grant.clone() else {
return Err(TastyTradeError::Auth(format!(
"the access token for this {} session has expired and the session has no \
refresh token; authorize again to obtain one",
self.environment
)));
};
let response = self.exchange(&grant).await?;
if let Some(refresh_token) = response.refresh_token.as_ref() {
state.grant = Some(OAuthGrant::Refresh {
client_secret: match &grant {
OAuthGrant::Refresh { client_secret, .. }
| OAuthGrant::AuthorizationCode { client_secret, .. } => client_secret.clone(),
},
refresh_token: refresh_token.clone(),
});
}
let lifetime = response.lifetime();
let token = response.access_token.clone();
state.active = Some(ActiveToken::new(response.access_token, lifetime));
debug!(
"Refreshed the {} access token ({}s)",
self.environment,
lifetime.as_secs()
);
Ok(token)
}
pub async fn expires_in(&self) -> Option<Duration> {
self.state
.lock()
.await
.active
.as_ref()
.and_then(super::super::types::oauth::ActiveToken::remaining)
}
pub async fn refresh_token(&self) -> Option<RefreshToken> {
match self.state.lock().await.grant.as_ref() {
Some(OAuthGrant::Refresh { refresh_token, .. }) => Some(refresh_token.clone()),
_ => None,
}
}
pub fn environment(&self) -> Environment {
self.environment
}
pub(crate) fn ensure_same_deployment(&self, base_url: &str) -> TastyResult<()> {
if base_url == self.base_url {
return Ok(());
}
Err(TastyTradeError::Precondition(format!(
"this session authenticated against {} and the configuration now points somewhere \
else; build a new client rather than moving an existing session between deployments",
self.environment
)))
}
async fn exchange(&self, grant: &OAuthGrant) -> TastyResult<TokenResponse> {
let report = RequestReport::new(
"POST",
format!("{TOKEN_PATH} ({})", grant.grant_type()),
self.environment,
);
let response = self
.http
.post(format!("{}{TOKEN_PATH}", self.base_url))
.form(&grant.form_parameters())
.send()
.await
.map_err(|e| transport_failure(&report, e))?;
let status = response.status();
let Ok(body) = response.text().await else {
debug!(
"POST {TOKEN_PATH}: reading the body failed after {}",
status
);
return Err(TastyTradeError::Request {
context: report.context(Some(status.as_u16())),
api: None,
});
};
if !status.is_success() {
let parsed = serde_json::from_str::<TokenErrorResponse>(&body).ok();
let code = parsed
.as_ref()
.map(TokenErrorResponse::code)
.unwrap_or("no error code");
debug!(
"POST {TOKEN_PATH} -> {} ({} bytes, {})",
status.as_u16(),
body.len(),
code
);
let credentials_refused = parsed
.as_ref()
.is_some_and(TokenErrorResponse::is_credential_failure)
|| matches!(status.as_u16(), 401 | 403);
return if credentials_refused {
Err(TastyTradeError::Auth(format!(
"the {} token endpoint refused the {} grant ({code})",
self.environment,
grant.grant_type()
)))
} else {
Err(TastyTradeError::Request {
context: report.context(Some(status.as_u16())),
api: None,
})
};
}
debug!(
"POST {TOKEN_PATH} -> {} ({} bytes in {:?})",
status.as_u16(),
body.len(),
report.elapsed()
);
serde_json::from_str::<TokenResponse>(&body).map_err(|e| {
debug!(
"POST {TOKEN_PATH}: decode failed ({:?} at line {}, column {})",
e.classify(),
e.line(),
e.column()
);
TastyTradeError::Request {
context: report.context(Some(status.as_u16())),
api: None,
}
})
}
}
pub(crate) fn refresh_grant(
client_secret: ClientSecret,
refresh_token: RefreshToken,
) -> OAuthGrant {
OAuthGrant::Refresh {
client_secret,
refresh_token,
}
}
pub(crate) fn authorization_code_grant(
code: AuthorizationCode,
client_id: String,
client_secret: ClientSecret,
redirect_uri: String,
) -> OAuthGrant {
OAuthGrant::AuthorizationCode {
code,
client_id,
client_secret,
redirect_uri,
}
}
pub(crate) fn default_headers() -> header::HeaderMap {
let mut headers = header::HeaderMap::new();
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/json"),
);
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static(concat!("tastytrade-rs/", env!("CARGO_PKG_VERSION"))),
);
headers
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_user_agent_carries_a_version() {
let headers = default_headers();
let agent = headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.expect("a user agent is not optional");
let (product, version) = agent
.split_once('/')
.expect("the venue requires <product>/<version>");
assert!(!product.is_empty(), "{agent}");
assert!(
version.chars().next().is_some_and(|c| c.is_ascii_digit()),
"the version must look like one: {agent}"
);
}
#[test]
fn no_default_content_type_can_shadow_the_form_encoding() {
assert!(default_headers().get(header::CONTENT_TYPE).is_none());
}
#[test]
fn a_session_debug_shows_where_but_not_what() {
let session = OAuthSession {
http: reqwest::Client::new(),
base_url: "https://api.cert.tastyworks.com".to_string(),
environment: Environment::Certification,
state: Mutex::new(SessionState {
grant: Some(refresh_grant(
"SENTINEL-client-secret-3Qv7".into(),
"SENTINEL-refresh-token-8Hb2".into(),
)),
active: None,
}),
};
let rendered = format!("{session:?}");
assert!(rendered.contains("Certification"), "{rendered}");
assert!(!rendered.contains("SENTINEL"), "{rendered}");
}
#[test]
fn a_session_refuses_to_move_between_deployments() {
let session = OAuthSession {
http: reqwest::Client::new(),
base_url: "https://api.cert.tastyworks.com".to_string(),
environment: Environment::Certification,
state: Mutex::new(SessionState {
grant: None,
active: None,
}),
};
assert!(
session
.ensure_same_deployment("https://api.cert.tastyworks.com")
.is_ok()
);
let error = session
.ensure_same_deployment("https://api.tastyworks.com")
.expect_err("a session must not follow the configuration to production");
assert!(
matches!(error, TastyTradeError::Precondition(_)),
"nothing was sent, so this is a precondition: {error:?}"
);
}
#[tokio::test]
async fn a_session_that_cannot_refresh_says_so_when_the_token_dies() {
let session = OAuthSession {
http: reqwest::Client::new(),
base_url: "http://127.0.0.1:1".to_string(),
environment: Environment::Certification,
state: Mutex::new(SessionState {
grant: None,
active: Some(ActiveToken::new(
"SENTINEL-access-token-5Nd9".into(),
Duration::ZERO,
)),
}),
};
let error = session
.access_token()
.await
.expect_err("an expired token with no grant cannot be renewed");
assert!(matches!(error, TastyTradeError::Auth(_)), "{error:?}");
assert!(!error.is_retryable(), "asking again cannot help");
assert!(!format!("{error}").contains("SENTINEL"), "{error}");
}
#[tokio::test]
async fn a_live_token_is_reused() {
let session = OAuthSession {
http: reqwest::Client::new(),
base_url: "http://127.0.0.1:1".to_string(),
environment: Environment::Certification,
state: Mutex::new(SessionState {
grant: None,
active: Some(ActiveToken::new(
"SENTINEL-access-token-5Nd9".into(),
Duration::from_secs(900),
)),
}),
};
let token = session
.access_token()
.await
.expect("the token is still live");
assert_eq!(token.expose_secret(), "SENTINEL-access-token-5Nd9");
assert!(session.expires_in().await.is_some());
assert!(session.refresh_token().await.is_none());
}
}